From b53ba81a6adf10268b01156812ab9a115ddf7522 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 3 Jan 2019 11:13:24 +0100 Subject: [PATCH 01/96] Add no_updates parameter in Client Useful to completely disable incoming updates for batch programs --- pyrogram/client/client.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index 3dcaaf12..daef9d34 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -151,6 +151,12 @@ class Client(Methods, BaseClient): 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). """ def __init__(self, @@ -173,7 +179,8 @@ class Client(Methods, BaseClient): workers: int = BaseClient.WORKERS, workdir: str = BaseClient.WORKDIR, config_file: str = BaseClient.CONFIG_FILE, - plugins_dir: str = None): + plugins_dir: str = None, + no_updates: bool = None): super().__init__() self.session_name = session_name @@ -197,6 +204,7 @@ class Client(Methods, BaseClient): self.workdir = workdir self.config_file = config_file self.plugins_dir = plugins_dir + self.no_updates = no_updates self.dispatcher = Dispatcher(self, workers) @@ -943,6 +951,9 @@ class Client(Methods, BaseClient): if not self.is_started: raise ConnectionError("Client has not been started") + if self.no_updates: + data = functions.InvokeWithoutUpdates(data) + r = self.session.send(data, retries, timeout) self.fetch_peers(getattr(r, "users", [])) From 7c008ca4e353a648de1ebd4ac3bc241ff589c5b7 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 3 Jan 2019 11:42:15 +0100 Subject: [PATCH 02/96] Add a bunch of takeout related errors --- compiler/error/source/400_BAD_REQUEST.tsv | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compiler/error/source/400_BAD_REQUEST.tsv b/compiler/error/source/400_BAD_REQUEST.tsv index c0a5da73..1837d8df 100644 --- a/compiler/error/source/400_BAD_REQUEST.tsv +++ b/compiler/error/source/400_BAD_REQUEST.tsv @@ -80,4 +80,6 @@ USER_ADMIN_INVALID The action requires admin privileges INPUT_USER_DEACTIVATED The target user has been deactivated PASSWORD_RECOVERY_NA The password recovery e-mail is not available PASSWORD_EMPTY The password entered is empty -PHONE_NUMBER_FLOOD This number has tried to login too many times \ No newline at end of file +PHONE_NUMBER_FLOOD This number has tried to login too many times +TAKEOUT_INVALID The takeout id is invalid +TAKEOUT_REQUIRED The method must be invoked inside a takeout session \ No newline at end of file From 4f6990d735d63404ff7c5297e9c7b12d2f76599b Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 3 Jan 2019 12:20:42 +0100 Subject: [PATCH 03/96] Add takeout parameter in Client This lets the client use a takeout session instead of a normal one. Takeout sessions are useful for exporting Telegram data. Methods invoked inside a takeout session are less prone to throw FloodWait exceptions. --- pyrogram/client/client.py | 22 +++++++++++++++++++++- pyrogram/client/ext/base_client.py | 2 ++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index daef9d34..bd72c582 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -157,6 +157,13 @@ class Client(Methods, BaseClient): 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, @@ -180,7 +187,8 @@ class Client(Methods, BaseClient): workdir: str = BaseClient.WORKDIR, config_file: str = BaseClient.CONFIG_FILE, plugins_dir: str = None, - no_updates: bool = None): + no_updates: bool = None, + takeout: bool = None): super().__init__() self.session_name = session_name @@ -205,6 +213,7 @@ class Client(Methods, BaseClient): self.config_file = config_file self.plugins_dir = plugins_dir self.no_updates = no_updates + self.takeout = takeout self.dispatcher = Dispatcher(self, workers) @@ -261,6 +270,10 @@ class Client(Methods, BaseClient): 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: @@ -316,6 +329,10 @@ class Client(Methods, BaseClient): 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() @@ -954,6 +971,9 @@ class Client(Methods, BaseClient): 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", [])) diff --git a/pyrogram/client/ext/base_client.py b/pyrogram/client/ext/base_client.py index 7aee5302..cecbf5fb 100644 --- a/pyrogram/client/ext/base_client.py +++ b/pyrogram/client/ext/base_client.py @@ -90,6 +90,8 @@ class BaseClient: self.is_started = None self.is_idle = None + self.takeout_id = None + self.updates_queue = Queue() self.updates_workers_list = [] self.download_queue = Queue() From 9256185b5a59fa2f7959a4e5052e3a3b3e2d6f84 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 3 Jan 2019 18:27:47 +0100 Subject: [PATCH 04/96] Update copyright year --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 478683c3..f7d2b44e 100644 --- a/README.rst +++ b/README.rst @@ -61,7 +61,7 @@ and documentation. Any help is appreciated! Copyright & License ------------------- -- Copyright (C) 2017-2018 Dan Tès +- Copyright (C) 2017-2019 Dan Tès - Licensed under the terms of the `GNU Lesser General Public License v3 or later (LGPLv3+)`_ .. _`Telegram`: https://telegram.org/ From d69a93d253c203a43bd0c0da5bc21c08be3a4899 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 3 Jan 2019 20:53:48 +0100 Subject: [PATCH 05/96] Automatically cast message and caption arguments to str --- pyrogram/client/style/html.py | 10 +++++----- pyrogram/client/style/markdown.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyrogram/client/style/html.py b/pyrogram/client/style/html.py index 0014c4d9..9a72a565 100644 --- a/pyrogram/client/style/html.py +++ b/pyrogram/client/style/html.py @@ -38,12 +38,12 @@ class HTML: def __init__(self, peers_by_id): self.peers_by_id = peers_by_id - def parse(self, text): + def parse(self, message: str): entities = [] - text = utils.add_surrogates(text) + message = utils.add_surrogates(str(message)) offset = 0 - for match in self.HTML_RE.finditer(text): + for match in self.HTML_RE.finditer(message): start = match.start() - offset style, url, body = match.group(1, 3, 4) @@ -73,12 +73,12 @@ class HTML: continue entities.append(entity) - text = text.replace(match.group(), body) + message = message.replace(match.group(), body) offset += len(style) * 2 + 5 + (len(url) + 8 if url else 0) # TODO: OrderedDict to be removed in Python3.6 return OrderedDict([ - ("message", utils.remove_surrogates(text)), + ("message", utils.remove_surrogates(message)), ("entities", entities) ]) diff --git a/pyrogram/client/style/markdown.py b/pyrogram/client/style/markdown.py index 7bd96ed6..05a11a25 100644 --- a/pyrogram/client/style/markdown.py +++ b/pyrogram/client/style/markdown.py @@ -56,7 +56,7 @@ class Markdown: self.peers_by_id = peers_by_id def parse(self, message: str): - message = utils.add_surrogates(message).strip() + message = utils.add_surrogates(str(message)).strip() entities = [] offset = 0 From 36f987e9791af3843a41ea95982028961dcbc018 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 3 Jan 2019 20:58:38 +0100 Subject: [PATCH 06/96] Add Filters.me Useful to filter messages coming from the current running user. Does the same thing as Filters.user("me") --- pyrogram/client/filters/filters.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyrogram/client/filters/filters.py b/pyrogram/client/filters/filters.py index c0ebfdcf..ea6d7202 100644 --- a/pyrogram/client/filters/filters.py +++ b/pyrogram/client/filters/filters.py @@ -61,6 +61,9 @@ class Filters: create = create + me = create("Me", lambda _, m: bool(m.from_user and m.from_user.is_self)) + """Filter messages coming from you yourself""" + bot = create("Bot", lambda _, m: bool(m.from_user and m.from_user.is_bot)) """Filter messages coming from bots""" From fe4e8c5a4208d3446d5aa169242a39622918b4c8 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 14:36:42 +0100 Subject: [PATCH 07/96] Rename get_history's "reversed" parameter to "reverse" It was colliding with the built-in "reversed" function --- pyrogram/client/methods/messages/get_history.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyrogram/client/methods/messages/get_history.py b/pyrogram/client/methods/messages/get_history.py index c2f4226b..14af0110 100644 --- a/pyrogram/client/methods/messages/get_history.py +++ b/pyrogram/client/methods/messages/get_history.py @@ -30,7 +30,7 @@ class GetHistory(BaseClient): offset: int = 0, offset_id: int = 0, offset_date: int = 0, - reversed: bool = False): + reverse: bool = False): """Use this method to retrieve the history of a chat. You can get up to 100 messages at once. @@ -55,7 +55,7 @@ class GetHistory(BaseClient): offset_date (``int``, *optional*): Pass a date in Unix time as offset to retrieve only older messages starting from that date. - reversed (``bool``, *optional*): + reverse (``bool``, *optional*): Pass True to retrieve the messages in reversed order (from older to most recent). Returns: @@ -72,7 +72,7 @@ class GetHistory(BaseClient): peer=self.resolve_peer(chat_id), offset_id=offset_id, offset_date=offset_date, - add_offset=offset - (limit if reversed else 0), + add_offset=offset - (limit if reverse else 0), limit=limit, max_id=0, min_id=0, @@ -81,7 +81,7 @@ class GetHistory(BaseClient): ) ) - if reversed: + if reverse: messages.messages.reverse() return messages From fe89974523c8ae8d604f96b6c53671fd7bc2ef37 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 14:37:26 +0100 Subject: [PATCH 08/96] Add get_history signature to BaseClient Also make other method parameters generic --- pyrogram/client/ext/base_client.py | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/pyrogram/client/ext/base_client.py b/pyrogram/client/ext/base_client.py index cecbf5fb..93d4e57e 100644 --- a/pyrogram/client/ext/base_client.py +++ b/pyrogram/client/ext/base_client.py @@ -23,8 +23,6 @@ from threading import Lock from pyrogram import __version__ from ..style import Markdown, HTML -from ...api.core import Object -from ...session import Session from ...session.internals import MsgId @@ -99,33 +97,23 @@ class BaseClient: self.disconnect_handler = None - def send(self, data: Object, retries: int = Session.MAX_RETRIES, timeout: float = Session.WAIT_TIMEOUT): + def send(self, *args, **kwargs): pass - def resolve_peer(self, peer_id: int or str): + def resolve_peer(self, *args, **kwargs): pass - def fetch_peers(self, entities): + def fetch_peers(self, *args, **kwargs): pass - def add_handler(self, handler, group: int = 0) -> tuple: + def add_handler(self, *args, **kwargs): pass - def save_file( - self, - path: str, - file_id: int = None, - file_part: int = 0, - progress: callable = None, - progress_args: tuple = () - ): + def save_file(self, *args, **kwargs): pass - def get_messages( - self, - chat_id: int or str, - message_ids: int or list = None, - reply_to_message_ids: int or list = None, - replies: int = 1 - ): + def get_messages(self, *args, **kwargs): + pass + + def get_history(self, *args, **kwargs): pass From 7e3513f8ee0b94643c750dae4b127c6cebbaab7e Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 14:38:08 +0100 Subject: [PATCH 09/96] Wait in case of flood errors in get_messages --- pyrogram/client/methods/messages/get_messages.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/methods/messages/get_messages.py b/pyrogram/client/methods/messages/get_messages.py index f5497629..f24e996f 100644 --- a/pyrogram/client/methods/messages/get_messages.py +++ b/pyrogram/client/methods/messages/get_messages.py @@ -16,12 +16,17 @@ # You should have received a copy of the GNU Lesser General Public License # along with Pyrogram. If not, see . +import logging +import time from typing import Union, Iterable import pyrogram from pyrogram.api import functions, types +from pyrogram.api.errors import FloodWait from ...ext import BaseClient +log = logging.getLogger(__name__) + class GetMessages(BaseClient): def get_messages(self, @@ -78,6 +83,15 @@ class GetMessages(BaseClient): else: rpc = functions.messages.GetMessages(id=ids) - messages = pyrogram.Messages._parse(self, self.send(rpc), replies) + while True: + try: + r = self.send(rpc) + except FloodWait as e: + log.warning("Sleeping for {}s".format(e.x)) + time.sleep(e.x) + else: + break + + messages = pyrogram.Messages._parse(self, r, replies=replies) return messages if is_iterable else messages.messages[0] From 04542dbddf5ac4179ea3e8a950f062afaec95b4e Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 14:42:39 +0100 Subject: [PATCH 10/96] Make parsing multiple Messages more efficient This is achieved by not calling get_messages for each single reply. Instead, all the available replies are retrieved in one request only. --- .../types/messages_and_media/messages.py | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/pyrogram/client/types/messages_and_media/messages.py b/pyrogram/client/types/messages_and_media/messages.py index 67dc2367..d89f0bad 100644 --- a/pyrogram/client/types/messages_and_media/messages.py +++ b/pyrogram/client/types/messages_and_media/messages.py @@ -52,9 +52,38 @@ class Messages(PyrogramType, Update): users = {i.id: i for i in messages.users} chats = {i.id: i for i in messages.chats} + total_count = getattr(messages, "count", len(messages.messages)) + + if not messages.messages: + return Messages( + total_count=total_count, + messages=[], + client=client + ) + + parsed_messages = [Message._parse(client, message, users, chats, replies=0) for message in messages.messages] + + if replies: + messages_with_replies = {i.id: getattr(i, "reply_to_msg_id", None) for i in messages.messages} + reply_message_ids = [i[0] for i in filter(lambda x: x[1] is not None, messages_with_replies.items())] + + if reply_message_ids: + reply_messages = client.get_messages( + parsed_messages[0].chat.id, + reply_to_message_ids=reply_message_ids, + replies=0 + ).messages + + for message in parsed_messages: + reply_id = messages_with_replies[message.message_id] + + for reply in reply_messages: + if reply.message_id == reply_id: + message.reply_to_message = reply + return Messages( - total_count=getattr(messages, "count", len(messages.messages)), - messages=[Message._parse(client, message, users, chats, replies) for message in messages.messages], + total_count=total_count, + messages=parsed_messages, client=client ) From fbc18cace0948b48c1569792cf8596d0ea45ec53 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 15:36:43 +0100 Subject: [PATCH 11/96] Update get_history docstrings --- pyrogram/client/methods/messages/get_history.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyrogram/client/methods/messages/get_history.py b/pyrogram/client/methods/messages/get_history.py index 14af0110..d45623f4 100644 --- a/pyrogram/client/methods/messages/get_history.py +++ b/pyrogram/client/methods/messages/get_history.py @@ -31,9 +31,10 @@ class GetHistory(BaseClient): offset_id: int = 0, offset_date: int = 0, reverse: bool = False): - """Use this method to retrieve the history of a chat. + """Use this method to retrieve a chunk of the history of a chat. You can get up to 100 messages at once. + For a more convenient way of getting a chat history see :meth:`iter_history`. Args: chat_id (``int`` | ``str``): @@ -72,7 +73,7 @@ class GetHistory(BaseClient): peer=self.resolve_peer(chat_id), offset_id=offset_id, offset_date=offset_date, - add_offset=offset - (limit if reverse else 0), + add_offset=offset * (-1 if reverse else 1) - (limit if reverse else 0), limit=limit, max_id=0, min_id=0, From 8628d3a56d6fd36929468c13e2c6c73e2a2d89a2 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 15:37:08 +0100 Subject: [PATCH 12/96] Add iter_history method For #170 --- docs/source/pyrogram/Client.rst | 1 + pyrogram/client/methods/messages/__init__.py | 4 +- .../client/methods/messages/iter_history.py | 93 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 pyrogram/client/methods/messages/iter_history.py diff --git a/docs/source/pyrogram/Client.rst b/docs/source/pyrogram/Client.rst index 14796ef2..78b5a32b 100644 --- a/docs/source/pyrogram/Client.rst +++ b/docs/source/pyrogram/Client.rst @@ -62,6 +62,7 @@ Messages delete_messages get_messages get_history + iter_history send_poll vote_poll retract_vote diff --git a/pyrogram/client/methods/messages/__init__.py b/pyrogram/client/methods/messages/__init__.py index 237b6493..d5d81044 100644 --- a/pyrogram/client/methods/messages/__init__.py +++ b/pyrogram/client/methods/messages/__init__.py @@ -25,6 +25,7 @@ from .edit_message_text import EditMessageText from .forward_messages import ForwardMessages from .get_history import GetHistory from .get_messages import GetMessages +from .iter_history import IterHistory from .retract_vote import RetractVote from .send_animation import SendAnimation from .send_audio import SendAudio @@ -70,6 +71,7 @@ class Messages( SendPoll, VotePoll, RetractVote, - DownloadMedia + DownloadMedia, + IterHistory ): pass diff --git a/pyrogram/client/methods/messages/iter_history.py b/pyrogram/client/methods/messages/iter_history.py new file mode 100644 index 00000000..ab587988 --- /dev/null +++ b/pyrogram/client/methods/messages/iter_history.py @@ -0,0 +1,93 @@ +# 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 . + +from typing import Union, Generator + +import pyrogram +from ...ext import BaseClient + + +class IterHistory(BaseClient): + def iter_history(self, + chat_id: Union[int, str], + limit: int = 0, + offset: int = 0, + offset_id: int = 0, + offset_date: int = 0, + reverse: bool = False) -> Generator["pyrogram.Message", None, None]: + """Use this method to iterate through a chat history sequentially. + + This convenience method does the same as repeatedly calling :meth:`get_history` in a loop, thus saving you from + the hassle of setting up boilerplate code. It is useful for getting the whole chat history with a single call. + + Args: + chat_id (``int`` | ``str``): + Unique identifier (int) or username (str) of the target chat. + For your personal cloud (Saved Messages) you can simply use "me" or "self". + For a contact that exists in your Telegram address book you can use his phone number (str). + + limit (``int``, *optional*): + Limits the number of messages to be retrieved. + By default, no limit is applied and all messages are returned. + + offset (``int``, *optional*): + Sequential number of the first message to be returned.. + Negative values are also accepted and become useful in case you set offset_id or offset_date. + + offset_id (``int``, *optional*): + Identifier of the first message to be returned. + + offset_date (``int``, *optional*): + Pass a date in Unix time as offset to retrieve only older messages starting from that date. + + reverse (``bool``, *optional*): + Pass True to retrieve the messages in reversed order (from older to most recent). + + Returns: + A generator yielding :obj:`Message ` objects. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + """ + offset_id = offset_id or (1 if reverse else 0) + current = 0 + total = limit or (1 << 31) - 1 + limit = min(100, total) + + while True: + messages = self.get_history( + chat_id=chat_id, + limit=limit, + offset=offset, + offset_id=offset_id, + offset_date=offset_date, + reverse=reverse + ).messages + + if not messages: + return + + offset_id = messages[-1].message_id + (1 if reverse else 0) + + for message in messages: + yield message + + current += 1 + + if current >= total: + return From 4d1d70082b31c03bae90056cd3df0c8db18a89cb Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 16:08:05 +0100 Subject: [PATCH 13/96] Sleep in case of get_dialogs flood waits --- pyrogram/client/methods/chats/get_dialogs.py | 40 +++++++++++++------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/pyrogram/client/methods/chats/get_dialogs.py b/pyrogram/client/methods/chats/get_dialogs.py index f80518b0..35fc7752 100644 --- a/pyrogram/client/methods/chats/get_dialogs.py +++ b/pyrogram/client/methods/chats/get_dialogs.py @@ -16,10 +16,16 @@ # You should have received a copy of the GNU Lesser General Public License # along with Pyrogram. If not, see . +import logging +import time + import pyrogram from pyrogram.api import functions, types +from pyrogram.api.errors import FloodWait from ...ext import BaseClient +log = logging.getLogger(__name__) + class GetDialogs(BaseClient): def get_dialogs(self, @@ -29,6 +35,7 @@ class GetDialogs(BaseClient): """Use this method to get the user's dialogs You can get up to 100 dialogs at once. + For a more convenient way of getting a user's dialogs see :meth:`iter_dialogs`. Args: offset_date (``int``): @@ -50,18 +57,25 @@ class GetDialogs(BaseClient): :class:`Error ` in case of a Telegram RPC error. """ - if pinned_only: - r = self.send(functions.messages.GetPinnedDialogs()) - else: - r = self.send( - functions.messages.GetDialogs( - offset_date=offset_date, - offset_id=0, - offset_peer=types.InputPeerEmpty(), - limit=limit, - hash=0, - exclude_pinned=True - ) - ) + while True: + try: + if pinned_only: + r = self.send(functions.messages.GetPinnedDialogs()) + else: + r = self.send( + functions.messages.GetDialogs( + offset_date=offset_date, + offset_id=0, + offset_peer=types.InputPeerEmpty(), + limit=limit, + hash=0, + exclude_pinned=True + ) + ) + except FloodWait as e: + log.warning("Sleeping {}s".format(e.x)) + time.sleep(e.x) + else: + break return pyrogram.Dialogs._parse(self, r) From 948f2b44ed69116e2c84b644a9951353d0e54dec Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 16:10:34 +0100 Subject: [PATCH 14/96] Add iter_dialogs method. Reference #170 --- docs/source/pyrogram/Client.rst | 1 + pyrogram/client/ext/base_client.py | 3 + pyrogram/client/methods/chats/__init__.py | 4 +- pyrogram/client/methods/chats/iter_dialogs.py | 82 +++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 pyrogram/client/methods/chats/iter_dialogs.py diff --git a/docs/source/pyrogram/Client.rst b/docs/source/pyrogram/Client.rst index 78b5a32b..ff45fad1 100644 --- a/docs/source/pyrogram/Client.rst +++ b/docs/source/pyrogram/Client.rst @@ -93,6 +93,7 @@ Chats get_chat_members get_chat_members_count get_dialogs + iter_dialogs Users ----- diff --git a/pyrogram/client/ext/base_client.py b/pyrogram/client/ext/base_client.py index 93d4e57e..5e03e100 100644 --- a/pyrogram/client/ext/base_client.py +++ b/pyrogram/client/ext/base_client.py @@ -117,3 +117,6 @@ class BaseClient: def get_history(self, *args, **kwargs): pass + + def get_dialogs(self, *args, **kwargs): + pass diff --git a/pyrogram/client/methods/chats/__init__.py b/pyrogram/client/methods/chats/__init__.py index 745678cc..7f7761c5 100644 --- a/pyrogram/client/methods/chats/__init__.py +++ b/pyrogram/client/methods/chats/__init__.py @@ -24,6 +24,7 @@ from .get_chat_members import GetChatMembers from .get_chat_members_count import GetChatMembersCount from .get_chat_preview import GetChatPreview from .get_dialogs import GetDialogs +from .iter_dialogs import IterDialogs from .join_chat import JoinChat from .kick_chat_member import KickChatMember from .leave_chat import LeaveChat @@ -56,6 +57,7 @@ class Chats( UnpinChatMessage, GetDialogs, GetChatMembersCount, - GetChatPreview + GetChatPreview, + IterDialogs ): pass diff --git a/pyrogram/client/methods/chats/iter_dialogs.py b/pyrogram/client/methods/chats/iter_dialogs.py new file mode 100644 index 00000000..6058cd17 --- /dev/null +++ b/pyrogram/client/methods/chats/iter_dialogs.py @@ -0,0 +1,82 @@ +# 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 . + +from typing import Generator + +import pyrogram +from ...ext import BaseClient + + +class IterDialogs(BaseClient): + def iter_dialogs(self, + offset_date: int = 0, + limit: int = 0) -> Generator["pyrogram.Dialog", None, None]: + """Use this method to iterate through a user's dialogs sequentially. + + This convenience method does the same as repeatedly calling :meth:`get_dialogs` in a loop, thus saving you from + the hassle of setting up boilerplate code. It is useful for getting the whole dialogs list with a single call. + + Args: + offset_date (``int``): + The offset date in Unix time taken from the top message of a :obj:`Dialog`. + Defaults to 0 (most recent dialog). + + limit (``str``, *optional*): + Limits the number of dialogs to be retrieved. + By default, no limit is applied and all dialogs are returned. + + Returns: + A generator yielding :obj:`Dialog ` objects. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + """ + current = 0 + total = limit or (1 << 31) - 1 + limit = min(100, total) + + pinned_dialogs = self.get_dialogs( + pinned_only=True + ).dialogs + + for dialog in pinned_dialogs: + yield dialog + + current += 1 + + if current >= total: + return + + while True: + dialogs = self.get_dialogs( + offset_date=offset_date, + limit=limit + ).dialogs + + if not dialogs: + return + + offset_date = dialogs[-1].top_message.date + + for dialog in dialogs: + yield dialog + + current += 1 + + if current >= total: + return From e1cac13f0d35b5a24546000d08108c512733a5d7 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 16:58:57 +0100 Subject: [PATCH 15/96] Fix get_dialogs docstrings --- pyrogram/client/methods/chats/get_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/client/methods/chats/get_dialogs.py b/pyrogram/client/methods/chats/get_dialogs.py index 35fc7752..c5fe6cfb 100644 --- a/pyrogram/client/methods/chats/get_dialogs.py +++ b/pyrogram/client/methods/chats/get_dialogs.py @@ -32,7 +32,7 @@ class GetDialogs(BaseClient): offset_date: int = 0, limit: int = 100, pinned_only: bool = False) -> "pyrogram.Dialogs": - """Use this method to get the user's dialogs + """Use this method to get a chunk of the user's dialogs You can get up to 100 dialogs at once. For a more convenient way of getting a user's dialogs see :meth:`iter_dialogs`. From d7e3397050c9f0d718b550b8b8cababc918688eb Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 16:59:36 +0100 Subject: [PATCH 16/96] Update get_chat_members docstrings --- pyrogram/client/methods/chats/get_chat_members.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/methods/chats/get_chat_members.py b/pyrogram/client/methods/chats/get_chat_members.py index 1d99ec4b..382d7f0f 100644 --- a/pyrogram/client/methods/chats/get_chat_members.py +++ b/pyrogram/client/methods/chats/get_chat_members.py @@ -39,10 +39,12 @@ class GetChatMembers(BaseClient): limit: int = 200, query: str = "", filter: str = Filters.ALL) -> "pyrogram.ChatMembers": - """Use this method to get the members list of a chat. + """Use this method to get a chunk of the members list of a chat. + You can get up to 200 chat members at once. A chat can be either a basic group, a supergroup or a channel. You must be admin to retrieve the members list of a channel (also known as "subscribers"). + For a more convenient way of getting chat members see :meth:`iter_chat_members`. Args: chat_id (``int`` | ``str``): From 153439ac88e5e130deee6186d8dd946b79152a17 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 17:13:44 +0100 Subject: [PATCH 17/96] Add iter_chat_members method. Reference #170 --- docs/source/pyrogram/Client.rst | 1 + pyrogram/client/ext/base_client.py | 3 + pyrogram/client/methods/chats/__init__.py | 4 +- .../client/methods/chats/iter_chat_members.py | 124 ++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 pyrogram/client/methods/chats/iter_chat_members.py diff --git a/docs/source/pyrogram/Client.rst b/docs/source/pyrogram/Client.rst index ff45fad1..0d49068a 100644 --- a/docs/source/pyrogram/Client.rst +++ b/docs/source/pyrogram/Client.rst @@ -92,6 +92,7 @@ Chats get_chat_member get_chat_members get_chat_members_count + iter_chat_members get_dialogs iter_dialogs diff --git a/pyrogram/client/ext/base_client.py b/pyrogram/client/ext/base_client.py index 5e03e100..aaed5786 100644 --- a/pyrogram/client/ext/base_client.py +++ b/pyrogram/client/ext/base_client.py @@ -120,3 +120,6 @@ class BaseClient: def get_dialogs(self, *args, **kwargs): pass + + def get_chat_members(self, *args, **kwargs): + pass diff --git a/pyrogram/client/methods/chats/__init__.py b/pyrogram/client/methods/chats/__init__.py index 7f7761c5..6cc034e4 100644 --- a/pyrogram/client/methods/chats/__init__.py +++ b/pyrogram/client/methods/chats/__init__.py @@ -24,6 +24,7 @@ from .get_chat_members import GetChatMembers from .get_chat_members_count import GetChatMembersCount from .get_chat_preview import GetChatPreview from .get_dialogs import GetDialogs +from .iter_chat_members import IterChatMembers from .iter_dialogs import IterDialogs from .join_chat import JoinChat from .kick_chat_member import KickChatMember @@ -58,6 +59,7 @@ class Chats( GetDialogs, GetChatMembersCount, GetChatPreview, - IterDialogs + IterDialogs, + IterChatMembers ): pass diff --git a/pyrogram/client/methods/chats/iter_chat_members.py b/pyrogram/client/methods/chats/iter_chat_members.py new file mode 100644 index 00000000..bdd8d117 --- /dev/null +++ b/pyrogram/client/methods/chats/iter_chat_members.py @@ -0,0 +1,124 @@ +# 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 . + +from string import ascii_lowercase +from typing import Union, Generator + +import pyrogram +from ...ext import BaseClient + + +class Filters: + ALL = "all" + KICKED = "kicked" + RESTRICTED = "restricted" + BOTS = "bots" + RECENT = "recent" + ADMINISTRATORS = "administrators" + + +QUERIES = [""] + [str(i) for i in range(10)] + list(ascii_lowercase) +QUERYABLE_FILTERS = (Filters.ALL, Filters.KICKED, Filters.RESTRICTED) + + +class IterChatMembers(BaseClient): + def iter_chat_members(self, + chat_id: Union[int, str], + limit: int = 0, + query: str = "", + filter: str = Filters.ALL) -> Generator["pyrogram.ChatMember", None, None]: + """Use this method to iterate through the members of a chat sequentially. + + This convenience method does the same as repeatedly calling :meth:`get_chat_members` in a loop, thus saving you + from the hassle of setting up boilerplate code. It is useful for getting the whole members list of a chat with + a single call. + + Args: + chat_id (``int`` | ``str``): + Unique identifier (int) or username (str) of the target chat. + + limit (``int``, *optional*): + Limits the number of members to be retrieved. + By default, no limit is applied and all members are returned. + + query (``str``, *optional*): + Query string to filter members based on their display names and usernames. + Defaults to "" (empty string) [2]_. + + filter (``str``, *optional*): + Filter used to select the kind of members you want to retrieve. Only applicable for supergroups + and channels. It can be any of the followings: + *"all"* - all kind of members, + *"kicked"* - kicked (banned) members only, + *"restricted"* - restricted members only, + *"bots"* - bots only, + *"recent"* - recent members only, + *"administrators"* - chat administrators only. + Defaults to *"all"*. + + .. [1] Server limit: on supergroups, you can get up to 10,000 members for a single query and up to 200 members + on channels. + + .. [2] A query string is applicable only for *"all"*, *"kicked"* and *"restricted"* filters only. + + Returns: + A generator yielding :obj:`ChatMember ` objects. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + """ + current = 0 + yielded = set() + queries = [query] if query else QUERIES + total = limit or (1 << 31) - 1 + limit = min(200, total) + + if filter not in QUERYABLE_FILTERS: + queries = [""] + + for q in queries: + offset = 0 + + while True: + chat_members = self.get_chat_members( + chat_id=chat_id, + offset=offset, + limit=limit, + query=q, + filter=filter + ).chat_members + + if not chat_members: + break + + offset += len(chat_members) + + for chat_member in chat_members: + user_id = chat_member.user.id + + if user_id in yielded: + continue + + yield chat_member + + yielded.add(chat_member.user.id) + + current += 1 + + if current >= total: + return From b8a3d02eef230b7a52d8a9dc785914b1e2bdf6ab Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 23:12:06 +0100 Subject: [PATCH 18/96] Fix get_messages return type --- pyrogram/client/methods/messages/get_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/client/methods/messages/get_messages.py b/pyrogram/client/methods/messages/get_messages.py index f24e996f..b1c90d10 100644 --- a/pyrogram/client/methods/messages/get_messages.py +++ b/pyrogram/client/methods/messages/get_messages.py @@ -33,7 +33,7 @@ class GetMessages(BaseClient): chat_id: Union[int, str], message_ids: Union[int, Iterable[int]] = None, reply_to_message_ids: Union[int, Iterable[int]] = None, - replies: int = 1) -> "pyrogram.Messages": + replies: int = 1) -> Union["pyrogram.Message", "pyrogram.Messages"]: """Use this method to get one or more messages that belong to a specific chat. You can retrieve up to 200 messages at once. From 7e354b12bfac977a9c4e5d1abc3f637b9c1dc117 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 4 Jan 2019 23:15:57 +0100 Subject: [PATCH 19/96] Fix kick_chat_member docstrings and return type --- pyrogram/client/methods/chats/kick_chat_member.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyrogram/client/methods/chats/kick_chat_member.py b/pyrogram/client/methods/chats/kick_chat_member.py index b02e02cc..4cd66ec4 100644 --- a/pyrogram/client/methods/chats/kick_chat_member.py +++ b/pyrogram/client/methods/chats/kick_chat_member.py @@ -27,7 +27,7 @@ class KickChatMember(BaseClient): def kick_chat_member(self, chat_id: Union[int, str], user_id: Union[int, str], - until_date: int = 0) -> "pyrogram.Message": + until_date: int = 0) -> Union["pyrogram.Message", bool]: """Use this method to kick a user from a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the group on their own using invite links, etc., unless unbanned first. You must be an administrator in the chat for this to work and must @@ -52,7 +52,7 @@ class KickChatMember(BaseClient): considered to be banned forever. Defaults to 0 (ban forever). Returns: - True on success. + On success, either True or a service :obj:`Message ` will be returned (when applicable). Raises: :class:`Error ` in case of a Telegram RPC error. @@ -93,3 +93,5 @@ class KickChatMember(BaseClient): {i.id: i for i in r.users}, {i.id: i for i in r.chats} ) + else: + return True From 999b9ce667e5723fba63b2685fd281e0485111d3 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 5 Jan 2019 12:06:54 +0100 Subject: [PATCH 20/96] Add MESSAGE_POLL_CLOSED error --- compiler/error/source/400_BAD_REQUEST.tsv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compiler/error/source/400_BAD_REQUEST.tsv b/compiler/error/source/400_BAD_REQUEST.tsv index 1837d8df..f0e68e52 100644 --- a/compiler/error/source/400_BAD_REQUEST.tsv +++ b/compiler/error/source/400_BAD_REQUEST.tsv @@ -82,4 +82,5 @@ PASSWORD_RECOVERY_NA The password recovery e-mail is not available PASSWORD_EMPTY The password entered is empty PHONE_NUMBER_FLOOD This number has tried to login too many times TAKEOUT_INVALID The takeout id is invalid -TAKEOUT_REQUIRED The method must be invoked inside a takeout session \ No newline at end of file +TAKEOUT_REQUIRED The method must be invoked inside a takeout session +MESSAGE_POLL_CLOSED You can't interact with a closed poll \ No newline at end of file From 0716380737e4818569cc86a1e19ce25e477e5223 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 5 Jan 2019 12:26:05 +0100 Subject: [PATCH 21/96] Add MEDIA_INVALID error --- compiler/error/source/400_BAD_REQUEST.tsv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compiler/error/source/400_BAD_REQUEST.tsv b/compiler/error/source/400_BAD_REQUEST.tsv index f0e68e52..f01439f5 100644 --- a/compiler/error/source/400_BAD_REQUEST.tsv +++ b/compiler/error/source/400_BAD_REQUEST.tsv @@ -83,4 +83,5 @@ PASSWORD_EMPTY The password entered is empty PHONE_NUMBER_FLOOD This number has tried to login too many times TAKEOUT_INVALID The takeout id is invalid TAKEOUT_REQUIRED The method must be invoked inside a takeout session -MESSAGE_POLL_CLOSED You can't interact with a closed poll \ No newline at end of file +MESSAGE_POLL_CLOSED You can't interact with a closed poll +MEDIA_INVALID The media is invalid \ No newline at end of file From a50dba2b4cb7bfb22f9a42be37ed4c9c3773bf84 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 5 Jan 2019 14:44:10 +0100 Subject: [PATCH 22/96] Add close_poll method --- pyrogram/client/methods/messages/__init__.py | 2 + .../client/methods/messages/close_poll.py | 65 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 pyrogram/client/methods/messages/close_poll.py diff --git a/pyrogram/client/methods/messages/__init__.py b/pyrogram/client/methods/messages/__init__.py index d5d81044..f76d0a22 100644 --- a/pyrogram/client/methods/messages/__init__.py +++ b/pyrogram/client/methods/messages/__init__.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with Pyrogram. If not, see . +from .close_poll import ClosePoll from .delete_messages import DeleteMessages from .download_media import DownloadMedia from .edit_message_caption import EditMessageCaption @@ -70,6 +71,7 @@ class Messages( SendVoice, SendPoll, VotePoll, + ClosePoll, RetractVote, DownloadMedia, IterHistory diff --git a/pyrogram/client/methods/messages/close_poll.py b/pyrogram/client/methods/messages/close_poll.py new file mode 100644 index 00000000..c2d2706b --- /dev/null +++ b/pyrogram/client/methods/messages/close_poll.py @@ -0,0 +1,65 @@ +# 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 . + +from typing import Union + +from pyrogram.api import functions, types +from pyrogram.client.ext import BaseClient + + +class ClosePoll(BaseClient): + def close_poll(self, + chat_id: Union[int, str], + message_id: id) -> bool: + """Use this method to close (stop) a poll. + + Closed polls can't be reopened and nobody will be able to vote in it anymore. + + Args: + chat_id (``int`` | ``str``): + Unique identifier (int) or username (str) of the target chat. + For your personal cloud (Saved Messages) you can simply use "me" or "self". + For a contact that exists in your Telegram address book you can use his phone number (str). + + message_id (``int``): + Unique poll message identifier inside this chat. + + Returns: + On success, True is returned. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + """ + poll = self.get_messages(chat_id, message_id).poll + + self.send( + functions.messages.EditMessage( + peer=self.resolve_peer(chat_id), + id=message_id, + media=types.InputMediaPoll( + poll=types.Poll( + id=poll.id, + closed=True, + question="", + answers=[] + ) + ) + ) + ) + + return True From c7b1d6f70a97674fc59afc21b1334cd10f024b87 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 5 Jan 2019 15:26:40 +0100 Subject: [PATCH 23/96] Take into account that flags:# could be not always the first argument For instance, in Layer 91, Poll's flags:# is at the second position. This mess also happened in the past (thanks tg devs) and eventually will be fixed again with the next Layer update, but from now on Pyrogram will be able to correctly generate code even in such cases. --- compiler/api/compiler.py | 52 +++++++++++++++++-------------- compiler/api/template/mtproto.txt | 2 -- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/compiler/api/compiler.py b/compiler/api/compiler.py index 57e4d22b..9e671e80 100644 --- a/compiler/api/compiler.py +++ b/compiler/api/compiler.py @@ -26,7 +26,7 @@ NOTICE_PATH = "NOTICE" SECTION_RE = re.compile(r"---(\w+)---") LAYER_RE = re.compile(r"//\sLAYER\s(\d+)") COMBINATOR_RE = re.compile(r"^([\w.]+)#([0-9a-f]+)\s(?:.*)=\s([\w<>.]+);(?: // Docs: (.+))?$", re.MULTILINE) -ARGS_RE = re.compile("[^{](\w+):([\w?!.<>]+)") +ARGS_RE = re.compile("[^{](\w+):([\w?!.<>#]+)") FLAGS_RE = re.compile(r"flags\.(\d+)\?") FLAGS_RE_2 = re.compile(r"flags\.(\d+)\?([\w<>.]+)") FLAGS_RE_3 = re.compile(r"flags:#") @@ -288,17 +288,20 @@ def start(): sorted_args = sort_args(c.args) arguments = ", " + ", ".join( - [get_argument_type(i) for i in sorted_args] + [get_argument_type(i) for i in sorted_args if i != ("flags", "#")] ) if c.args else "" fields = "\n ".join( - ["self.{0} = {0} # {1}".format(i[0], i[1]) for i in c.args] + ["self.{0} = {0} # {1}".format(i[0], i[1]) for i in c.args if i != ("flags", "#")] ) if c.args else "pass" docstring_args = [] docs = c.docs.split("|")[1:] if c.docs else None for i, arg in enumerate(sorted_args): + if arg == ("flags", "#"): + continue + arg_name, arg_type = arg is_optional = FLAGS_RE.match(arg_type) flag_number = is_optional.group(1) if is_optional else -1 @@ -338,28 +341,31 @@ def start(): if references: docstring_args += "\n\n See Also:\n This object can be returned by " + references + "." - if c.has_flags: - write_flags = [] - for i in c.args: - flag = FLAGS_RE.match(i[1]) - if flag: - write_flags.append("flags |= (1 << {}) if self.{} is not None else 0".format(flag.group(1), i[0])) - - write_flags = "\n ".join([ - "flags = 0", - "\n ".join(write_flags), - "b.write(Int(flags))" - ]) - else: - write_flags = "# No flags" - - read_flags = "flags = Int.read(b)" if c.has_flags else "# No flags" - - write_types = read_types = "" + write_types = read_types = "" if c.has_flags else "# No flags\n " for arg_name, arg_type in c.args: flag = FLAGS_RE_2.findall(arg_type) + if arg_name == "flags" and arg_type == "#": + write_flags = [] + + for i in c.args: + flag = FLAGS_RE.match(i[1]) + if flag: + write_flags.append( + "flags |= (1 << {}) if self.{} is not None else 0".format(flag.group(1), i[0])) + + write_flags = "\n ".join([ + "flags = 0", + "\n ".join(write_flags), + "b.write(Int(flags))\n " + ]) + + write_types += write_flags + read_types += "flags = Int.read(b)\n " + + continue + if flag: index, flag_type = flag[0] @@ -448,11 +454,9 @@ def start(): object_id=c.id, arguments=arguments, fields=fields, - read_flags=read_flags, read_types=read_types, - write_flags=write_flags, write_types=write_types, - return_arguments=", ".join([i[0] for i in sorted_args]) + return_arguments=", ".join([i[0] for i in sorted_args if i != ("flags", "#")]) ) ) diff --git a/compiler/api/template/mtproto.txt b/compiler/api/template/mtproto.txt index 368d4712..9a65b52d 100644 --- a/compiler/api/template/mtproto.txt +++ b/compiler/api/template/mtproto.txt @@ -16,7 +16,6 @@ class {class_name}(Object): @staticmethod def read(b: BytesIO, *args) -> "{class_name}": - {read_flags} {read_types} return {class_name}({return_arguments}) @@ -24,6 +23,5 @@ class {class_name}(Object): b = BytesIO() b.write(Int(self.ID, False)) - {write_flags} {write_types} return b.getvalue() From 7cb1c99e286ddae9d4c604e3952e7d43e8d0b7f9 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 5 Jan 2019 18:29:48 +0100 Subject: [PATCH 24/96] Update copyright year --- pyrogram/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/__init__.py b/pyrogram/__init__.py index abeb8b43..abc81b3d 100644 --- a/pyrogram/__init__.py +++ b/pyrogram/__init__.py @@ -18,7 +18,7 @@ import sys -__copyright__ = "Copyright (C) 2017-2018 Dan Tès ".replace( +__copyright__ = "Copyright (C) 2017-2019 Dan Tès ".replace( "\xe8", "e" if sys.getfilesystemencoding() != "utf-8" else "\xe8" ) From 7d061a1b5c0d7155ac27bb8e567509d25085da7c Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 5 Jan 2019 23:11:39 +0100 Subject: [PATCH 25/96] Add Game type --- pyrogram/__init__.py | 2 +- pyrogram/client/types/__init__.py | 2 +- .../types/messages_and_media/__init__.py | 1 + .../client/types/messages_and_media/game.py | 98 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 pyrogram/client/types/messages_and_media/game.py diff --git a/pyrogram/__init__.py b/pyrogram/__init__.py index abc81b3d..281da114 100644 --- a/pyrogram/__init__.py +++ b/pyrogram/__init__.py @@ -32,7 +32,7 @@ from .client.types import ( Location, Message, MessageEntity, Dialog, Dialogs, Photo, PhotoSize, Sticker, User, UserStatus, UserProfilePhotos, Venue, Animation, Video, VideoNote, Voice, CallbackQuery, Messages, ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove, - Poll, PollOption, ChatPreview, StopPropagation + Poll, PollOption, ChatPreview, StopPropagation, Game ) from .client import ( Client, ChatAction, ParseMode, Emoji, diff --git a/pyrogram/client/types/__init__.py b/pyrogram/client/types/__init__.py index 0f4628ce..65a9165a 100644 --- a/pyrogram/client/types/__init__.py +++ b/pyrogram/client/types/__init__.py @@ -31,7 +31,7 @@ from .input_media import ( from .messages_and_media import ( Audio, Contact, Document, Animation, Location, Photo, PhotoSize, Sticker, Venue, Video, VideoNote, Voice, UserProfilePhotos, - Message, Messages, MessageEntity, Poll, PollOption + Message, Messages, MessageEntity, Poll, PollOption, Game ) from .user_and_chats import ( Chat, ChatMember, ChatMembers, ChatPhoto, diff --git a/pyrogram/client/types/messages_and_media/__init__.py b/pyrogram/client/types/messages_and_media/__init__.py index b6847bdb..3c0b3c98 100644 --- a/pyrogram/client/types/messages_and_media/__init__.py +++ b/pyrogram/client/types/messages_and_media/__init__.py @@ -34,3 +34,4 @@ from .venue import Venue from .video import Video from .video_note import VideoNote from .voice import Voice +from .game import Game diff --git a/pyrogram/client/types/messages_and_media/game.py b/pyrogram/client/types/messages_and_media/game.py new file mode 100644 index 00000000..01af7226 --- /dev/null +++ b/pyrogram/client/types/messages_and_media/game.py @@ -0,0 +1,98 @@ +# 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 pyrogram +from pyrogram.api import types +from .animation import Animation +from .photo import Photo +from ..pyrogram_type import PyrogramType + + +class Game(PyrogramType): + """This object represents a game. + Use BotFather to create and edit games, their short names will act as unique identifiers. + + Args: + id (``int``): + Unique identifier of the game. + + title (``str``): + Title of the game. + + short_name (``str``): + Unique short name of the game. + + description (``str``): + Description of the game. + + photo (:obj:`Photo `): + Photo that will be displayed in the game message in chats. + + animation (:obj:`Animation `, *optional*): + Animation that will be displayed in the game message in chats. + Upload via BotFather. + """ + + def __init__(self, + *, + client: "pyrogram.client.ext.BaseClient", + id: int, + title: str, + short_name: str, + description: str, + photo: Photo, + animation: Animation = None): + super().__init__(client) + + self.id = id + self.title = title + self.short_name = short_name + self.description = description + self.photo = photo + self.animation = animation + + @staticmethod + def _parse(client, message: types.Message) -> "Game": + game = message.media.game # type: types.Game + animation = None + + if game.document: + attributes = {type(i): i for i in game.document.attributes} + + file_name = getattr( + attributes.get( + types.DocumentAttributeFilename, None + ), "file_name", None + ) + + animation = Animation._parse( + client, + game.document, + attributes.get(types.DocumentAttributeVideo, None), + file_name + ) + + return Game( + id=game.id, + title=game.title, + short_name=game.short_name, + description=game.description, + photo=Photo._parse(client, game.photo), + animation=animation, + client=client + ) From d5303285d66a7693728200dcb6a372937e7adb5c Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 5 Jan 2019 23:12:29 +0100 Subject: [PATCH 26/96] Add support for Game inline buttons. Add CallbackGame type --- pyrogram/client/types/bots/__init__.py | 1 + pyrogram/client/types/bots/callback_game.py | 29 +++++++++++++++++++ .../types/bots/inline_keyboard_button.py | 17 +++++++++-- 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 pyrogram/client/types/bots/callback_game.py diff --git a/pyrogram/client/types/bots/__init__.py b/pyrogram/client/types/bots/__init__.py index 28cfe724..804701dd 100644 --- a/pyrogram/client/types/bots/__init__.py +++ b/pyrogram/client/types/bots/__init__.py @@ -23,3 +23,4 @@ from .inline_keyboard_markup import InlineKeyboardMarkup from .keyboard_button import KeyboardButton from .reply_keyboard_markup import ReplyKeyboardMarkup from .reply_keyboard_remove import ReplyKeyboardRemove +from .callback_game import CallbackGame \ No newline at end of file diff --git a/pyrogram/client/types/bots/callback_game.py b/pyrogram/client/types/bots/callback_game.py new file mode 100644 index 00000000..be026360 --- /dev/null +++ b/pyrogram/client/types/bots/callback_game.py @@ -0,0 +1,29 @@ +# 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 . + +from ..pyrogram_type import PyrogramType + + +class CallbackGame(PyrogramType): + """A placeholder, currently holds no information. + + Use BotFather to set up your game. + """ + + def __init__(self): + super().__init__(None) diff --git a/pyrogram/client/types/bots/inline_keyboard_button.py b/pyrogram/client/types/bots/inline_keyboard_button.py index f9c1267a..cd30f373 100644 --- a/pyrogram/client/types/bots/inline_keyboard_button.py +++ b/pyrogram/client/types/bots/inline_keyboard_button.py @@ -18,8 +18,9 @@ from pyrogram.api.types import ( KeyboardButtonUrl, KeyboardButtonCallback, - KeyboardButtonSwitchInline + KeyboardButtonSwitchInline, KeyboardButtonGame ) +from .callback_game import CallbackGame from ..pyrogram_type import PyrogramType @@ -58,7 +59,8 @@ class InlineKeyboardButton(PyrogramType): callback_data: bytes = None, url: str = None, switch_inline_query: str = None, - switch_inline_query_current_chat: str = None): + switch_inline_query_current_chat: str = None, + callback_game: CallbackGame = None): super().__init__(None) self.text = text @@ -66,7 +68,7 @@ class InlineKeyboardButton(PyrogramType): self.callback_data = callback_data self.switch_inline_query = switch_inline_query self.switch_inline_query_current_chat = switch_inline_query_current_chat - # self.callback_game = callback_game + self.callback_game = callback_game # self.pay = pay @staticmethod @@ -95,6 +97,12 @@ class InlineKeyboardButton(PyrogramType): switch_inline_query=o.query ) + if isinstance(o, KeyboardButtonGame): + return InlineKeyboardButton( + text=o.text, + callback_game=CallbackGame() + ) + def write(self): if self.callback_data: return KeyboardButtonCallback(self.text, self.callback_data) @@ -107,3 +115,6 @@ class InlineKeyboardButton(PyrogramType): if self.switch_inline_query_current_chat: return KeyboardButtonSwitchInline(self.text, self.switch_inline_query_current_chat, same_peer=True) + + if self.callback_game: + return KeyboardButtonGame(self.text) From 50e5692eae1ad7708ea5c20d6c74d2f2bdf417ae Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 5 Jan 2019 23:12:59 +0100 Subject: [PATCH 27/96] Add Filters.game and Filters.game_score. Also fix Filters.animation name --- pyrogram/client/filters/filters.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pyrogram/client/filters/filters.py b/pyrogram/client/filters/filters.py index ea6d7202..01ffe434 100644 --- a/pyrogram/client/filters/filters.py +++ b/pyrogram/client/filters/filters.py @@ -100,9 +100,12 @@ class Filters: sticker = create("Sticker", lambda _, m: bool(m.sticker)) """Filter messages that contain :obj:`Sticker ` objects.""" - animation = create("GIF", lambda _, m: bool(m.animation)) + animation = create("Animation", lambda _, m: bool(m.animation)) """Filter messages that contain :obj:`Animation ` objects.""" + game = create("Game", lambda _, m: bool(m.game)) + """Filter messages that contain :obj:`Game ` objects.""" + video = create("Video", lambda _, m: bool(m.video)) """Filter messages that contain :obj:`Video ` objects.""" @@ -169,6 +172,9 @@ class Filters: pinned_message = create("PinnedMessage", lambda _, m: bool(m.pinned_message)) """Filter service messages for pinned messages.""" + game_score = create("GameScore", lambda _, m: bool(m.game_score)) + """Filter service messages for game scores.""" + reply_keyboard = create("ReplyKeyboard", lambda _, m: isinstance(m.reply_markup, ReplyKeyboardMarkup)) """Filter messages containing reply keyboard markups""" @@ -193,7 +199,8 @@ class Filters: - channel_chat_created - migrate_to_chat_id - migrate_from_chat_id - - pinned_message""" + - pinned_message + - game_score""" media = create("Media", lambda _, m: bool(m.media)) """Filter media messages. A media message contains any of the following fields set @@ -208,7 +215,8 @@ class Filters: - video_note - contact - location - - venue""" + - venue + - poll""" @staticmethod def command(command: str or list, From bb27633da6d831b71723a37953dd00edd927e37c Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 5 Jan 2019 23:13:47 +0100 Subject: [PATCH 28/96] Add game and game_score in Message --- .../types/messages_and_media/message.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pyrogram/client/types/messages_and_media/message.py b/pyrogram/client/types/messages_and_media/message.py index 830a24de..fddc4d0b 100644 --- a/pyrogram/client/types/messages_and_media/message.py +++ b/pyrogram/client/types/messages_and_media/message.py @@ -121,6 +121,9 @@ class Message(PyrogramType, Update): animation (:obj:`Animation `, *optional*): Message is an animation, information about the animation. + game (:obj:`Game `, *optional*): + Message is a game, information about the game. + video (:obj:`Video `, *optional*): Message is a video, information about the video. @@ -199,6 +202,10 @@ class Message(PyrogramType, Update): Note that the Message object in this field will not contain further reply_to_message fields even if it is itself a reply. + game_score (``int``, *optional*): + The game score for a user. + The reply_to_message field will contain the game Message. + views (``int``, *optional*): Channel post views. @@ -255,6 +262,7 @@ class Message(PyrogramType, Update): photo: "pyrogram.Photo" = None, sticker: "pyrogram.Sticker" = None, animation: "pyrogram.Animation" = None, + game: "pyrogram.Game" = None, video: "pyrogram.Video" = None, voice: "pyrogram.Voice" = None, video_note: "pyrogram.VideoNote" = None, @@ -275,6 +283,7 @@ class Message(PyrogramType, Update): migrate_to_chat_id: int = None, migrate_from_chat_id: int = None, pinned_message: "Message" = None, + game_score: int = None, views: int = None, via_bot: User = None, outgoing: bool = None, @@ -311,6 +320,7 @@ class Message(PyrogramType, Update): self.photo = photo self.sticker = sticker self.animation = animation + self.game = game self.video = video self.voice = voice self.video_note = video_note @@ -331,6 +341,7 @@ class Message(PyrogramType, Update): self.migrate_to_chat_id = migrate_to_chat_id self.migrate_from_chat_id = migrate_from_chat_id self.pinned_message = pinned_message + self.game_score = game_score self.views = views self.via_bot = via_bot self.outgoing = outgoing @@ -407,6 +418,19 @@ class Message(PyrogramType, Update): except MessageIdsEmpty: pass + if isinstance(action, types.MessageActionGameScore): + parsed_message.game_score = action.score + + if message.reply_to_msg_id and replies: + try: + parsed_message.reply_to_message = client.get_messages( + parsed_message.chat.id, + reply_to_message_ids=message.id, + replies=0 + ) + except MessageIdsEmpty: + pass + return parsed_message if isinstance(message, types.Message): @@ -435,6 +459,7 @@ class Message(PyrogramType, Update): location = None contact = None venue = None + game = None audio = None voice = None animation = None @@ -456,6 +481,8 @@ class Message(PyrogramType, Update): contact = Contact._parse(client, media) elif isinstance(media, types.MessageMediaVenue): venue = pyrogram.Venue._parse(client, media) + elif isinstance(media, types.MessageMediaGame): + game = pyrogram.Game._parse(client, message) elif isinstance(media, types.MessageMediaDocument): doc = media.document @@ -543,6 +570,7 @@ class Message(PyrogramType, Update): audio=audio, voice=voice, animation=animation, + game=game, video=video, video_note=video_note, sticker=sticker, From 6451d599b264ce0ea39779a2fbb4618d571a34c5 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 08:30:40 +0100 Subject: [PATCH 29/96] Fix typo --- pyrogram/client/types/input_media/input_media_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/client/types/input_media/input_media_audio.py b/pyrogram/client/types/input_media/input_media_audio.py index 5034ed06..a2dc18db 100644 --- a/pyrogram/client/types/input_media/input_media_audio.py +++ b/pyrogram/client/types/input_media/input_media_audio.py @@ -20,7 +20,7 @@ from . import InputMedia class InputMediaAudio(InputMedia): - """This object represents a video to be sent inside an album. + """This object represents an audio to be sent inside an album. It is intended to be used with :obj:`send_media_group() `. Args: From 491b96c9f671405fb3b2f9ce3600d3de5603bae4 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 10:00:42 +0100 Subject: [PATCH 30/96] Use "recent" filter for up to 10k members --- pyrogram/client/methods/chats/iter_chat_members.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyrogram/client/methods/chats/iter_chat_members.py b/pyrogram/client/methods/chats/iter_chat_members.py index bdd8d117..cd93fc46 100644 --- a/pyrogram/client/methods/chats/iter_chat_members.py +++ b/pyrogram/client/methods/chats/iter_chat_members.py @@ -86,6 +86,7 @@ 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) if filter not in QUERYABLE_FILTERS: From 7ae9a065b81d7e5af1d1964ea7ea1acd40a8bc68 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 10:33:09 +0100 Subject: [PATCH 31/96] Update examples --- examples/README.md | 24 ++++---- ...k_query_handler.py => callback_queries.py} | 0 examples/chat_members.py | 10 ++++ examples/dialogs.py | 9 +++ examples/{echo_bot.py => echo.py} | 2 +- examples/get_chat_members.py | 31 ---------- examples/get_chat_members2.py | 50 ---------------- examples/get_history.py | 31 ---------- examples/hello.py | 16 +++++ examples/hello_world.py | 18 ------ examples/history.py | 10 ++++ .../{query_inline_bots.py => inline_bots.py} | 0 examples/keyboards.py | 59 +++++++++++++++++++ .../{raw_update_handler.py => raw_updates.py} | 0 examples/send_bot_keyboards.py | 51 ---------------- examples/welcome.py | 27 +++++++++ examples/welcome_bot.py | 45 -------------- .../types/bots/reply_keyboard_markup.py | 4 +- 18 files changed, 146 insertions(+), 241 deletions(-) rename examples/{callback_query_handler.py => callback_queries.py} (100%) create mode 100644 examples/chat_members.py create mode 100644 examples/dialogs.py rename examples/{echo_bot.py => echo.py} (90%) delete mode 100644 examples/get_chat_members.py delete mode 100644 examples/get_chat_members2.py delete mode 100644 examples/get_history.py create mode 100644 examples/hello.py delete mode 100644 examples/hello_world.py create mode 100644 examples/history.py rename examples/{query_inline_bots.py => inline_bots.py} (100%) create mode 100644 examples/keyboards.py rename examples/{raw_update_handler.py => raw_updates.py} (100%) delete mode 100644 examples/send_bot_keyboards.py create mode 100644 examples/welcome.py delete mode 100644 examples/welcome_bot.py diff --git a/examples/README.md b/examples/README.md index 763db699..6f56ab89 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,21 +2,21 @@ This folder contains example scripts to show you how **Pyrogram** looks like. -Every script is working right away (provided you correctly set up your credentials), meaning -you can simply copy-paste and run. The only things you have to change are session names and target chats. +Every script is working right away (provided you correctly set up your credentials), meaning you can simply copy-paste +and run. The only things you have to change are session names and target chats. All the examples listed in this directory are licensed under the terms of the [CC0 1.0 Universal](LICENSE) license and can be freely used as basic building blocks for your own applications without worrying about copyrights. Example | Description ---: | :--- -[**hello_world**](hello_world.py) | Demonstration of basic API usages -[**echo_bot**](echo_bot.py) | Echo bot that replies to every private text message -[**welcome_bot**](welcome_bot.py) | The Welcome Bot source code in [@PyrogramChat](https://t.me/pyrogramchat) -[**get_history**](get_history.py) | How to retrieve the full message history of a chat -[**get_chat_members**](get_chat_members.py) | How to get the first 10.000 members of a supergroup/channel -[**get_chat_members2**](get_chat_members2.py) | Improved version to get more than 10.000 members -[**query_inline_bots**](query_inline_bots.py) | How to query an inline bot and send a result to a chat -[**send_bot_keyboards**](send_bot_keyboards.py) | How to send normal and inline keyboards using regular bots -[**callback_query_handler**](callback_query_handler.py) | How to handle queries coming from inline button presses -[**raw_update_handler**](raw_update_handler.py) | How to handle raw updates (old, should be avoided) +[**hello**](hello.py) | Demonstration of basic API usage +[**echo**](echo.py) | Reply to every private text message +[**welcome**](welcome.py) | The Welcome Bot in [@PyrogramChat](https://t.me/pyrogramchat) +[**history**](history.py) | Get the full message history of a chat +[**chat_members**](chat_members.py) | Get all the members of a chat +[**dialogs**](dialogs.py) | Get all of your dialog chats +[**inline_bots**](inline_bots.py) | Query an inline bot and send a result to a chat +[**keyboards**](keyboards.py) | Send normal and inline keyboards using regular bots +[**callback_queries**](callback_queries.py) | Handle queries coming from inline button presses +[**raw_updates**](raw_updates.py) | Handle raw updates (old, should be avoided) diff --git a/examples/callback_query_handler.py b/examples/callback_queries.py similarity index 100% rename from examples/callback_query_handler.py rename to examples/callback_queries.py diff --git a/examples/chat_members.py b/examples/chat_members.py new file mode 100644 index 00000000..87f8613d --- /dev/null +++ b/examples/chat_members.py @@ -0,0 +1,10 @@ +"""This example shows how to get all the members of a chat.""" + +from pyrogram import Client + +app = Client("my_count") +target = "pyrogramchat" # Target channel/supergroup + +with app: + for member in app.iter_chat_members(target): + print(member.user.first_name) diff --git a/examples/dialogs.py b/examples/dialogs.py new file mode 100644 index 00000000..08c769e2 --- /dev/null +++ b/examples/dialogs.py @@ -0,0 +1,9 @@ +"""This example shows how to get the full dialogs list of a user.""" + +from pyrogram import Client + +app = Client("my_account") + +with app: + for dialog in app.iter_dialogs(): + print(dialog.chat.title or dialog.chat.first_name) diff --git a/examples/echo_bot.py b/examples/echo.py similarity index 90% rename from examples/echo_bot.py rename to examples/echo.py index 7a2b0aa7..c60ae291 100644 --- a/examples/echo_bot.py +++ b/examples/echo.py @@ -11,7 +11,7 @@ app = Client("my_account") @app.on_message(Filters.text & Filters.private) def echo(client, message): - message.reply(message.text, quote=True) + message.reply(message.text) app.run() # Automatically start() and idle() diff --git a/examples/get_chat_members.py b/examples/get_chat_members.py deleted file mode 100644 index e0f8c3fa..00000000 --- a/examples/get_chat_members.py +++ /dev/null @@ -1,31 +0,0 @@ -"""This example shows you how to get the first 10.000 members of a chat. -Refer to get_chat_members2.py for more than 10.000 members. -""" - -import time - -from pyrogram import Client -from pyrogram.api.errors import FloodWait - -app = Client("my_account") - -target = "pyrogramchat" # Target channel/supergroup -members = [] # List that will contain all the members of the target chat -offset = 0 # Offset starts at 0 -limit = 200 # Amount of users to retrieve for each API call (max 200) - -with app: - while True: - try: - chunk = app.get_chat_members(target, offset) - except FloodWait as e: # Very large chats could trigger FloodWait - time.sleep(e.x) # When it happens, wait X seconds and try again - continue - - if not chunk.chat_members: - break # No more members left - - members.extend(chunk.chat_members) - offset += len(chunk.chat_members) - -# Now the "members" list contains all the members of the target chat diff --git a/examples/get_chat_members2.py b/examples/get_chat_members2.py deleted file mode 100644 index a4fa9daa..00000000 --- a/examples/get_chat_members2.py +++ /dev/null @@ -1,50 +0,0 @@ -"""This is an improved version of get_chat_members.py - -Since Telegram will return at most 10.000 members for a single query, this script -repeats the search using numbers ("0" to "9") and all the available ascii letters ("a" to "z"). - -This can be further improved by also searching for non-ascii characters (e.g.: Japanese script), -as some user names may not contain ascii letters at all. -""" - -import time -from string import ascii_lowercase - -from pyrogram import Client -from pyrogram.api.errors import FloodWait - -app = Client("my_account") - -target = "pyrogramchat" # Target channel/supergroup -members = {} # List that will contain all the members of the target chat -limit = 200 # Amount of users to retrieve for each API call (max 200) - -# "" + "0123456789" + "abcdefghijklmnopqrstuvwxyz" (as list) -queries = [""] + [str(i) for i in range(10)] + list(ascii_lowercase) - -with app: - for q in queries: - print('Searching for "{}"'.format(q)) - offset = 0 # For each query, offset restarts from 0 - - while True: - try: - chunk = app.get_chat_members(target, offset, query=q) - except FloodWait as e: # Very large chats could trigger FloodWait - print("Flood wait: {} seconds".format(e.x)) - time.sleep(e.x) # When it happens, wait X seconds and try again - continue - - if not chunk.chat_members: - print('Done searching for "{}"'.format(q)) - print() - break # No more members left - - members.update({i.user.id: i for i in chunk.chat_members}) - offset += len(chunk.chat_members) - - print("Total members: {}".format(len(members))) - - print("Grand total: {}".format(len(members))) - -# Now the "members" list contains all the members of the target chat diff --git a/examples/get_history.py b/examples/get_history.py deleted file mode 100644 index 628b5692..00000000 --- a/examples/get_history.py +++ /dev/null @@ -1,31 +0,0 @@ -"""This example shows how to retrieve the full message history of a chat""" - -import time - -from pyrogram import Client -from pyrogram.api.errors import FloodWait - -app = Client("my_account") -target = "me" # "me" refers to your own chat (Saved Messages) -messages = [] # List that will contain all the messages of the target chat -offset_id = 0 # ID of the last message of the chunk - -with app: - while True: - try: - m = app.get_history(target, offset_id=offset_id) - except FloodWait as e: # For very large chats the method call can raise a FloodWait - print("waiting {}".format(e.x)) - time.sleep(e.x) # Sleep X seconds before continuing - continue - - if not m.messages: - break - - messages += m.messages - offset_id = m.messages[-1].message_id - - print("Messages: {}".format(len(messages))) - -# Now the "messages" list contains all the messages sorted by date in -# descending order (from the most recent to the oldest one) diff --git a/examples/hello.py b/examples/hello.py new file mode 100644 index 00000000..54e86812 --- /dev/null +++ b/examples/hello.py @@ -0,0 +1,16 @@ +"""This example demonstrates a basic API usage""" + +from pyrogram import Client + +# Create a new Client instance +app = Client("my_account") + +with app: + # Send a message, Markdown is enabled by default + app.send_message("me", "Hi there! I'm using **Pyrogram**") + + # Send a location + app.send_location("me", 51.500729, -0.124583) + + # Send a sticker + app.send_sticker("me", "CAADBAADhw4AAvLQYAHICbZ5SUs_jwI") diff --git a/examples/hello_world.py b/examples/hello_world.py deleted file mode 100644 index 010725ef..00000000 --- a/examples/hello_world.py +++ /dev/null @@ -1,18 +0,0 @@ -"""This example demonstrates a basic API usage""" - -from pyrogram import Client - -# Create a new Client instance -app = Client("my_account") - -# Start the Client before calling any API method -app.start() - -# Send a message to yourself, Markdown is enabled by default -app.send_message("me", "Hi there! I'm using **Pyrogram**") - -# Send a location to yourself -app.send_location("me", 51.500729, -0.124583) - -# Stop the client when you're done -app.stop() diff --git a/examples/history.py b/examples/history.py new file mode 100644 index 00000000..e8bb14e3 --- /dev/null +++ b/examples/history.py @@ -0,0 +1,10 @@ +"""This example shows how to get the full message history of a chat, starting from the latest message""" + +from pyrogram import Client + +app = Client("my_account") +target = "me" # "me" refers to your own chat (Saved Messages) + +with app: + for message in app.iter_history(target): + print(message.text) diff --git a/examples/query_inline_bots.py b/examples/inline_bots.py similarity index 100% rename from examples/query_inline_bots.py rename to examples/inline_bots.py diff --git a/examples/keyboards.py b/examples/keyboards.py new file mode 100644 index 00000000..147154a3 --- /dev/null +++ b/examples/keyboards.py @@ -0,0 +1,59 @@ +"""This example will show you how to send normal and inline keyboards. + +You must log-in as a regular bot in order to send keyboards (use the token from @BotFather). +Any attempt in sending keyboards with a user account will be simply ignored by the server. + +send_message() is used as example, but a keyboard can be sent with any other send_* methods, +like send_audio(), send_document(), send_location(), etc... +""" + +from pyrogram import Client, ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton + +# Create a client using your bot token +app = Client("123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11") + +with app: + app.send_message( + "haskell", # Edit this + "This is a ReplyKeyboardMarkup example", + reply_markup=ReplyKeyboardMarkup( + [ + ["A", "B", "C", "D"], # First row + ["E", "F", "G"], # Second row + ["H", "I"], # Third row + ["J"] # Fourth row + ], + resize_keyboard=True # Make the keyboard smaller + ) + ) + + app.send_message( + "haskell", # Edit this + "This is a InlineKeyboardMarkup example", + reply_markup=InlineKeyboardMarkup( + [ + [ # First row + + InlineKeyboardButton( # Generates a callback query when pressed + "Button", + callback_data=b"data" + ), # Note how callback_data must be bytes + InlineKeyboardButton( # Opens a web URL + "URL", + url="https://docs.pyrogram.ml" + ), + ], + [ # Second row + # Opens the inline interface + InlineKeyboardButton( + "Choose chat", + switch_inline_query="pyrogram" + ), + InlineKeyboardButton( # Opens the inline interface in the current chat + "Inline here", + switch_inline_query_current_chat="pyrogram" + ) + ] + ] + ) + ) diff --git a/examples/raw_update_handler.py b/examples/raw_updates.py similarity index 100% rename from examples/raw_update_handler.py rename to examples/raw_updates.py diff --git a/examples/send_bot_keyboards.py b/examples/send_bot_keyboards.py deleted file mode 100644 index 3a15a23a..00000000 --- a/examples/send_bot_keyboards.py +++ /dev/null @@ -1,51 +0,0 @@ -"""This example will show you how to send normal and inline keyboards. - -You must log-in as a regular bot in order to send keyboards (use the token from @BotFather). -Any attempt in sending keyboards with a user account will be simply ignored by the server. - -send_message() is used as example, but a keyboard can be sent with any other send_* methods, -like send_audio(), send_document(), send_location(), etc... -""" - -from pyrogram import Client, ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton - -# Create a client using your bot token -app = Client("123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11") -app.start() - -app.send_message( - "haskell", # Edit this - "This is a ReplyKeyboardMarkup example", - reply_markup=ReplyKeyboardMarkup( - [ - ["A", "B", "C", "D"], # First row - ["E", "F", "G"], # Second row - ["H", "I"], # Third row - ["J"] # Fourth row - ], - resize_keyboard=True # Make the keyboard smaller - ) -) - -app.send_message( - "haskell", # Edit this - "This is a InlineKeyboardMarkup example", - reply_markup=InlineKeyboardMarkup( - [ - [ # First row - # Generates a callback query when pressed - InlineKeyboardButton("Button", callback_data="data"), - # Opens a web URL - InlineKeyboardButton("URL", url="https://docs.pyrogram.ml"), - ], - [ # Second row - # Opens the inline interface of a bot in another chat with a pre-defined query - InlineKeyboardButton("Choose chat", switch_inline_query="pyrogram"), - # Same as the button above, but the inline interface is opened in the current chat - InlineKeyboardButton("Inline here", switch_inline_query_current_chat="pyrogram"), - ] - ] - ) -) - -app.stop() diff --git a/examples/welcome.py b/examples/welcome.py new file mode 100644 index 00000000..06a38cb7 --- /dev/null +++ b/examples/welcome.py @@ -0,0 +1,27 @@ +"""This is the Welcome Bot in @PyrogramChat. + +It uses the Emoji module to easily add emojis in your text messages and Filters +to make it only work for specific messages in a specific chat. +""" + +from pyrogram import Client, Emoji, Filters + +MENTION = "[{}](tg://user?id={})" +MESSAGE = "{} Welcome to [Pyrogram](https://docs.pyrogram.ml/)'s group chat {}!" + +app = Client("my_account") + + +@app.on_message(Filters.chat("PyrogramChat") & Filters.new_chat_members) +def welcome(client, message): + # Build the new members list (with mentions) by using their first_name + new_members = [MENTION.format(i.first_name, i.id) for i in message.new_chat_members] + + # Build the welcome message by using an emoji and the list we built above + text = MESSAGE.format(Emoji.SPARKLES, ", ".join(new_members)) + + # Send the welcome message, without the web page preview + message.reply(text, disable_web_page_preview=True) + + +app.run() # Automatically start() and idle() diff --git a/examples/welcome_bot.py b/examples/welcome_bot.py deleted file mode 100644 index 4326ed6c..00000000 --- a/examples/welcome_bot.py +++ /dev/null @@ -1,45 +0,0 @@ -"""This is the Welcome Bot in @PyrogramChat. - -It uses the Emoji module to easily add emojis in your text messages and Filters -to make it only work for specific messages in a specific chat. -""" - -from pyrogram import Client, Emoji, Filters - -USER = "**{}**" -MESSAGE = "{} Welcome to [Pyrogram](https://docs.pyrogram.ml/)'s group chat {{}}!".format(Emoji.SPARKLES) - -enabled_groups = Filters.chat("PyrogramChat") -last_welcomes = {} - -app = Client("my_account") - - -@app.on_message(enabled_groups & Filters.new_chat_members) -def welcome(client, message): - chat_id = message.chat.id - - # Get the previous welcome message and members, if any - previous_welcome, previous_members = last_welcomes.pop(chat_id, (None, [])) - - # Delete the previous message, if exists - if previous_welcome: - previous_welcome.delete() - - # Build the new members list by using their first_name. Also append the previous members, if any - new_members = [USER.format(i.first_name) for i in message.new_chat_members] + previous_members - - # Build the welcome message by using an emoji and the list we created above - text = MESSAGE.format(", ".join(new_members)) - - # Actually send the welcome and save the new message and the new members list - last_welcomes[message.chat.id] = message.reply(text, disable_web_page_preview=True), new_members - - -@app.on_message(enabled_groups) -def reset(client, message): - # Don't make the bot delete the previous welcome in case someone talks in the middle - last_welcomes.pop(message.chat.id, None) - - -app.run() # Automatically start() and idle() diff --git a/pyrogram/client/types/bots/reply_keyboard_markup.py b/pyrogram/client/types/bots/reply_keyboard_markup.py index 7840dbe5..afae236d 100644 --- a/pyrogram/client/types/bots/reply_keyboard_markup.py +++ b/pyrogram/client/types/bots/reply_keyboard_markup.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with Pyrogram. If not, see . -from typing import List +from typing import List, Union from pyrogram.api.types import KeyboardButtonRow from pyrogram.api.types import ReplyKeyboardMarkup as RawReplyKeyboardMarkup @@ -50,7 +50,7 @@ class ReplyKeyboardMarkup(PyrogramType): """ def __init__(self, - keyboard: List[List[KeyboardButton]], + keyboard: List[List[Union[KeyboardButton, str]]], resize_keyboard: bool = None, one_time_keyboard: bool = None, selective: bool = None): From ebb2ad5aabda184a5112ee800a25ff0bf2fd02c9 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 11:47:59 +0100 Subject: [PATCH 32/96] Add BOT_SCORE_NOT_MODIFIED error --- compiler/error/source/400_BAD_REQUEST.tsv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compiler/error/source/400_BAD_REQUEST.tsv b/compiler/error/source/400_BAD_REQUEST.tsv index f01439f5..1caec874 100644 --- a/compiler/error/source/400_BAD_REQUEST.tsv +++ b/compiler/error/source/400_BAD_REQUEST.tsv @@ -84,4 +84,5 @@ PHONE_NUMBER_FLOOD This number has tried to login too many times TAKEOUT_INVALID The takeout id is invalid 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 \ No newline at end of file +MEDIA_INVALID The media is invalid +BOT_SCORE_NOT_MODIFIED The bot score was not modified \ No newline at end of file From 1bbf048b7a027fe125350684ad25b375adf68bfb Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 21:49:25 +0100 Subject: [PATCH 33/96] Remove duplicated references --- pyrogram/client/methods/chats/iter_chat_members.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pyrogram/client/methods/chats/iter_chat_members.py b/pyrogram/client/methods/chats/iter_chat_members.py index cd93fc46..963081f8 100644 --- a/pyrogram/client/methods/chats/iter_chat_members.py +++ b/pyrogram/client/methods/chats/iter_chat_members.py @@ -58,7 +58,7 @@ class IterChatMembers(BaseClient): query (``str``, *optional*): Query string to filter members based on their display names and usernames. - Defaults to "" (empty string) [2]_. + Defaults to "" (empty string). filter (``str``, *optional*): Filter used to select the kind of members you want to retrieve. Only applicable for supergroups @@ -71,11 +71,6 @@ class IterChatMembers(BaseClient): *"administrators"* - chat administrators only. Defaults to *"all"*. - .. [1] Server limit: on supergroups, you can get up to 10,000 members for a single query and up to 200 members - on channels. - - .. [2] A query string is applicable only for *"all"*, *"kicked"* and *"restricted"* filters only. - Returns: A generator yielding :obj:`ChatMember ` objects. From 9771be9c2aa970cb8fdec5c1bb1c347c0ca7c0ee Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 21:49:58 +0100 Subject: [PATCH 34/96] Add send_game and set_game_score methods --- docs/source/pyrogram/Client.rst | 2 + docs/source/pyrogram/Types.rst | 4 + pyrogram/client/methods/messages/__init__.py | 6 +- pyrogram/client/methods/messages/send_game.py | 87 ++++++++++++++++++ .../client/methods/messages/set_game_score.py | 90 +++++++++++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 pyrogram/client/methods/messages/send_game.py create mode 100644 pyrogram/client/methods/messages/set_game_score.py diff --git a/docs/source/pyrogram/Client.rst b/docs/source/pyrogram/Client.rst index 0d49068a..795d1cef 100644 --- a/docs/source/pyrogram/Client.rst +++ b/docs/source/pyrogram/Client.rst @@ -67,6 +67,8 @@ Messages vote_poll retract_vote download_media + send_game + set_game_score Chats ----- diff --git a/docs/source/pyrogram/Types.rst b/docs/source/pyrogram/Types.rst index bf1bf937..68659aa2 100644 --- a/docs/source/pyrogram/Types.rst +++ b/docs/source/pyrogram/Types.rst @@ -57,6 +57,7 @@ Bots InlineKeyboardButton ForceReply CallbackQuery + Game Input Media ----------- @@ -182,6 +183,9 @@ Input Media .. autoclass:: CallbackQuery :members: +.. autoclass:: Game + :members: + .. Input Media ----------- diff --git a/pyrogram/client/methods/messages/__init__.py b/pyrogram/client/methods/messages/__init__.py index f76d0a22..252bf1db 100644 --- a/pyrogram/client/methods/messages/__init__.py +++ b/pyrogram/client/methods/messages/__init__.py @@ -33,6 +33,7 @@ from .send_audio import SendAudio from .send_chat_action import SendChatAction from .send_contact import SendContact from .send_document import SendDocument +from .send_game import SendGame from .send_location import SendLocation from .send_media_group import SendMediaGroup from .send_message import SendMessage @@ -44,6 +45,7 @@ from .send_video import SendVideo from .send_video_note import SendVideoNote from .send_voice import SendVoice from .vote_poll import VotePoll +from .set_game_score import SetGameScore class Messages( @@ -74,6 +76,8 @@ class Messages( ClosePoll, RetractVote, DownloadMedia, - IterHistory + IterHistory, + SendGame, + SetGameScore ): pass diff --git a/pyrogram/client/methods/messages/send_game.py b/pyrogram/client/methods/messages/send_game.py new file mode 100644 index 00000000..7cfa33a0 --- /dev/null +++ b/pyrogram/client/methods/messages/send_game.py @@ -0,0 +1,87 @@ +# 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 . + +from typing import Union + +import pyrogram +from pyrogram.api import functions, types +from ...ext import BaseClient + + +class SendGame(BaseClient): + def send_game(self, + chat_id: Union[int, str], + game_short_name: str, + disable_notification: bool = None, + reply_to_message_id: int = None, + reply_markup: Union["pyrogram.InlineKeyboardMarkup", + "pyrogram.ReplyKeyboardMarkup", + "pyrogram.ReplyKeyboardRemove", + "pyrogram.ForceReply"] = None) -> "pyrogram.Message": + """Use this method to send a game. + + Args: + chat_id (``int`` | ``str``): + Unique identifier (int) or username (str) of the target chat. + For your personal cloud (Saved Messages) you can simply use "me" or "self". + For a contact that exists in your Telegram address book you can use his phone number (str). + + game_short_name (``str``): + Short name of the game, serves as the unique identifier for the game. Set up your games via Botfather. + + disable_notification (``bool``, *optional*): + Sends the message silently. + Users will receive a notification with no sound. + + reply_to_message_id (``int``, *optional*): + If the message is a reply, ID of the original message. + + reply_markup (:obj:`InlineKeyboardMarkup`, *optional*): + An object for an inline keyboard. If empty, one ‘Play game_title’ button will be shown automatically. + If not empty, the first button must launch the game. + + Returns: + On success, the sent :obj:`Message` is returned. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + """ + r = self.send( + functions.messages.SendMedia( + peer=self.resolve_peer(chat_id), + media=types.InputMediaGame( + id=types.InputGameShortName( + bot_id=types.InputUserSelf(), + short_name=game_short_name + ), + ), + message="", + silent=disable_notification or None, + reply_to_msg_id=reply_to_message_id, + random_id=self.rnd_id(), + reply_markup=reply_markup.write() if reply_markup else None + ) + ) + + for i in r.updates: + if isinstance(i, (types.UpdateNewMessage, types.UpdateNewChannelMessage)): + return pyrogram.Message._parse( + self, i.message, + {i.id: i for i in r.users}, + {i.id: i for i in r.chats} + ) diff --git a/pyrogram/client/methods/messages/set_game_score.py b/pyrogram/client/methods/messages/set_game_score.py new file mode 100644 index 00000000..7f5f92eb --- /dev/null +++ b/pyrogram/client/methods/messages/set_game_score.py @@ -0,0 +1,90 @@ +# 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 . + +from typing import Union + +import pyrogram +from pyrogram.api import functions, types +from ...ext import BaseClient + + +class SetGameScore(BaseClient): + def set_game_score(self, + user_id: Union[int, str], + score: int, + force: bool = None, + disable_edit_message: bool = None, + chat_id: Union[int, str] = None, + message_id: int = None): + # inline_message_id: str = None): TODO Add inline_message_id + """Use this method to set the score of the specified user in a game. + + Args: + user_id (``int`` | ``str``): + Unique identifier (int) or username (str) of the target chat. + For your personal cloud (Saved Messages) you can simply use "me" or "self". + For a contact that exists in your Telegram address book you can use his phone number (str). + + score (``int``): + New score, must be non-negative. + + force (``bool``, *optional*): + Pass True, if the high score is allowed to decrease. + This can be useful when fixing mistakes or banning cheaters. + + disable_edit_message (``bool``, *optional*): + Pass True, if the game message should not be automatically edited to include the current scoreboard. + + chat_id (``int`` | ``str``): + Unique identifier (int) or username (str) of the target chat. + For your personal cloud (Saved Messages) you can simply use "me" or "self". + For a contact that exists in your Telegram address book you can use his phone number (str). + Required if inline_message_id is not specified. + + message_id (``int``, *optional*): + Identifier of the sent message. + Required if inline_message_id is not specified. + + Returns: + On success, if the message was sent by the bot, returns the edited :obj:`Message `, + otherwise returns True. + + Raises: + :class:`Error ` in case of a Telegram RPC error. + :class:`BotScoreNotModified` if the new score is not greater than the user's current score in the chat and force is False. + """ + r = self.send( + functions.messages.SetGameScore( + peer=self.resolve_peer(chat_id), + score=score, + id=message_id, + user_id=self.resolve_peer(user_id), + force=force or None, + edit_message=not disable_edit_message or None + ) + ) + + for i in r.updates: + if isinstance(i, (types.UpdateEditMessage, types.UpdateEditChannelMessage)): + return pyrogram.Message._parse( + self, i.message, + {i.id: i for i in r.users}, + {i.id: i for i in r.chats} + ) + + return True From 33e83bf6351b8c40f8fe570b3faaf4897e269e9f Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 22:19:21 +0100 Subject: [PATCH 35/96] Update set_game_score docstrings --- pyrogram/client/methods/messages/set_game_score.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/client/methods/messages/set_game_score.py b/pyrogram/client/methods/messages/set_game_score.py index 7f5f92eb..7352aea9 100644 --- a/pyrogram/client/methods/messages/set_game_score.py +++ b/pyrogram/client/methods/messages/set_game_score.py @@ -50,7 +50,7 @@ class SetGameScore(BaseClient): disable_edit_message (``bool``, *optional*): Pass True, if the game message should not be automatically edited to include the current scoreboard. - chat_id (``int`` | ``str``): + chat_id (``int`` | ``str``, *optional*): Unique identifier (int) or username (str) of the target chat. For your personal cloud (Saved Messages) you can simply use "me" or "self". For a contact that exists in your Telegram address book you can use his phone number (str). From 65bdf31ce19af79d4b8f1e0037cfe195cf8129c4 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 22:26:52 +0100 Subject: [PATCH 36/96] Move send_game and set_game_score into bots folder --- docs/source/pyrogram/Client.rst | 4 ++-- pyrogram/client/methods/bots/__init__.py | 6 +++++- pyrogram/client/methods/{messages => bots}/send_game.py | 2 +- .../client/methods/{messages => bots}/set_game_score.py | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) rename pyrogram/client/methods/{messages => bots}/send_game.py (98%) rename pyrogram/client/methods/{messages => bots}/set_game_score.py (98%) diff --git a/docs/source/pyrogram/Client.rst b/docs/source/pyrogram/Client.rst index 795d1cef..2e2e1d96 100644 --- a/docs/source/pyrogram/Client.rst +++ b/docs/source/pyrogram/Client.rst @@ -67,8 +67,6 @@ Messages vote_poll retract_vote download_media - send_game - set_game_score Chats ----- @@ -140,6 +138,8 @@ Bots send_inline_bot_result answer_callback_query request_callback_answer + send_game + set_game_score .. autoclass:: pyrogram.Client diff --git a/pyrogram/client/methods/bots/__init__.py b/pyrogram/client/methods/bots/__init__.py index b0430efe..28ba49d8 100644 --- a/pyrogram/client/methods/bots/__init__.py +++ b/pyrogram/client/methods/bots/__init__.py @@ -19,13 +19,17 @@ from .answer_callback_query import AnswerCallbackQuery from .get_inline_bot_results import GetInlineBotResults from .request_callback_answer import RequestCallbackAnswer +from .send_game import SendGame from .send_inline_bot_result import SendInlineBotResult +from .set_game_score import SetGameScore class Bots( AnswerCallbackQuery, GetInlineBotResults, RequestCallbackAnswer, - SendInlineBotResult + SendInlineBotResult, + SendGame, + SetGameScore ): pass diff --git a/pyrogram/client/methods/messages/send_game.py b/pyrogram/client/methods/bots/send_game.py similarity index 98% rename from pyrogram/client/methods/messages/send_game.py rename to pyrogram/client/methods/bots/send_game.py index 7cfa33a0..401a5aa6 100644 --- a/pyrogram/client/methods/messages/send_game.py +++ b/pyrogram/client/methods/bots/send_game.py @@ -20,7 +20,7 @@ from typing import Union import pyrogram from pyrogram.api import functions, types -from ...ext import BaseClient +from pyrogram.client.ext import BaseClient class SendGame(BaseClient): diff --git a/pyrogram/client/methods/messages/set_game_score.py b/pyrogram/client/methods/bots/set_game_score.py similarity index 98% rename from pyrogram/client/methods/messages/set_game_score.py rename to pyrogram/client/methods/bots/set_game_score.py index 7352aea9..e9d20844 100644 --- a/pyrogram/client/methods/messages/set_game_score.py +++ b/pyrogram/client/methods/bots/set_game_score.py @@ -20,7 +20,7 @@ from typing import Union import pyrogram from pyrogram.api import functions, types -from ...ext import BaseClient +from pyrogram.client.ext import BaseClient class SetGameScore(BaseClient): From 633fefe1785f5b50a664462e766ac6f3e80a52ef Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 22:28:41 +0100 Subject: [PATCH 37/96] Add get_game_high_scores method --- docs/source/pyrogram/Client.rst | 1 + pyrogram/client/methods/messages/__init__.py | 8 ++- .../methods/messages/get_game_high_scores.py | 60 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 pyrogram/client/methods/messages/get_game_high_scores.py diff --git a/docs/source/pyrogram/Client.rst b/docs/source/pyrogram/Client.rst index 2e2e1d96..9a1b0509 100644 --- a/docs/source/pyrogram/Client.rst +++ b/docs/source/pyrogram/Client.rst @@ -140,6 +140,7 @@ Bots request_callback_answer send_game set_game_score + get_game_high_scores .. autoclass:: pyrogram.Client diff --git a/pyrogram/client/methods/messages/__init__.py b/pyrogram/client/methods/messages/__init__.py index 252bf1db..a46b3c0c 100644 --- a/pyrogram/client/methods/messages/__init__.py +++ b/pyrogram/client/methods/messages/__init__.py @@ -24,6 +24,7 @@ from .edit_message_media import EditMessageMedia from .edit_message_reply_markup import EditMessageReplyMarkup from .edit_message_text import EditMessageText from .forward_messages import ForwardMessages +from .get_game_high_scores import GetGameHighScores from .get_history import GetHistory from .get_messages import GetMessages from .iter_history import IterHistory @@ -33,7 +34,7 @@ from .send_audio import SendAudio from .send_chat_action import SendChatAction from .send_contact import SendContact from .send_document import SendDocument -from .send_game import SendGame +from pyrogram.client.methods.bots.send_game import SendGame from .send_location import SendLocation from .send_media_group import SendMediaGroup from .send_message import SendMessage @@ -44,8 +45,8 @@ from .send_venue import SendVenue from .send_video import SendVideo from .send_video_note import SendVideoNote from .send_voice import SendVoice +from pyrogram.client.methods.bots.set_game_score import SetGameScore from .vote_poll import VotePoll -from .set_game_score import SetGameScore class Messages( @@ -78,6 +79,7 @@ class Messages( DownloadMedia, IterHistory, SendGame, - SetGameScore + SetGameScore, + GetGameHighScores ): pass diff --git a/pyrogram/client/methods/messages/get_game_high_scores.py b/pyrogram/client/methods/messages/get_game_high_scores.py new file mode 100644 index 00000000..9816c38e --- /dev/null +++ b/pyrogram/client/methods/messages/get_game_high_scores.py @@ -0,0 +1,60 @@ +# 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 . + +from typing import Union + +import pyrogram +from pyrogram.api import functions +from ...ext import BaseClient + + +class GetGameHighScores(BaseClient): + def get_game_high_scores(self, + user_id: Union[int, str], + chat_id: Union[int, str], + message_id: int = None): + """Use this method to get data for high score tables. + + Args: + user_id (``int`` | ``str``): + Unique identifier (int) or username (str) of the target chat. + For your personal cloud (Saved Messages) you can simply use "me" or "self". + For a contact that exists in your Telegram address book you can use his phone number (str). + + chat_id (``int`` | ``str``, *optional*): + Unique identifier (int) or username (str) of the target chat. + For your personal cloud (Saved Messages) you can simply use "me" or "self". + For a contact that exists in your Telegram address book you can use his phone number (str). + Required if inline_message_id is not specified. + + message_id (``int``, *optional*): + Identifier of the sent message. + Required if inline_message_id is not specified. + """ + # TODO: inline_message_id + + return pyrogram.GameHighScores._parse( + self, + self.send( + functions.messages.GetGameHighScores( + peer=self.resolve_peer(chat_id), + id=message_id, + user_id=self.resolve_peer(user_id) + ) + ) + ) From dc737ab7bb633b08c3b1a84203fff5109479e653 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 22:30:33 +0100 Subject: [PATCH 38/96] Add GameHighScore and GameHighScores types --- docs/source/pyrogram/Types.rst | 6 ++ pyrogram/__init__.py | 2 +- pyrogram/client/types/__init__.py | 9 +-- pyrogram/client/types/bots/__init__.py | 4 +- pyrogram/client/types/bots/game_high_score.py | 61 +++++++++++++++++++ .../client/types/bots/game_high_scores.py | 56 +++++++++++++++++ .../types/messages_and_media/__init__.py | 2 +- 7 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 pyrogram/client/types/bots/game_high_score.py create mode 100644 pyrogram/client/types/bots/game_high_scores.py diff --git a/docs/source/pyrogram/Types.rst b/docs/source/pyrogram/Types.rst index 68659aa2..6e0a14db 100644 --- a/docs/source/pyrogram/Types.rst +++ b/docs/source/pyrogram/Types.rst @@ -186,6 +186,12 @@ Input Media .. autoclass:: Game :members: +.. autoclass:: GameHighScore + :members: + +.. autoclass:: GameHighScores + :members: + .. Input Media ----------- diff --git a/pyrogram/__init__.py b/pyrogram/__init__.py index 281da114..7330001d 100644 --- a/pyrogram/__init__.py +++ b/pyrogram/__init__.py @@ -32,7 +32,7 @@ from .client.types import ( Location, Message, MessageEntity, Dialog, Dialogs, Photo, PhotoSize, Sticker, User, UserStatus, UserProfilePhotos, Venue, Animation, Video, VideoNote, Voice, CallbackQuery, Messages, ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove, - Poll, PollOption, ChatPreview, StopPropagation, Game + Poll, PollOption, ChatPreview, StopPropagation, Game, CallbackGame, GameHighScore, GameHighScores ) from .client import ( Client, ChatAction, ParseMode, Emoji, diff --git a/pyrogram/client/types/__init__.py b/pyrogram/client/types/__init__.py index 65a9165a..ca332a22 100644 --- a/pyrogram/client/types/__init__.py +++ b/pyrogram/client/types/__init__.py @@ -18,11 +18,8 @@ from .bots import ( CallbackQuery, ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, - KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove -) -from .bots import ( - ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, - KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove + KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove, CallbackGame, + GameHighScore, GameHighScores ) from .input_media import ( InputMediaAudio, InputPhoneContact, InputMediaVideo, InputMediaPhoto, @@ -33,8 +30,8 @@ from .messages_and_media import ( Sticker, Venue, Video, VideoNote, Voice, UserProfilePhotos, Message, Messages, MessageEntity, Poll, PollOption, Game ) +from .update import StopPropagation from .user_and_chats import ( Chat, ChatMember, ChatMembers, ChatPhoto, Dialog, Dialogs, User, UserStatus, ChatPreview ) -from .update import StopPropagation diff --git a/pyrogram/client/types/bots/__init__.py b/pyrogram/client/types/bots/__init__.py index 804701dd..81767945 100644 --- a/pyrogram/client/types/bots/__init__.py +++ b/pyrogram/client/types/bots/__init__.py @@ -16,11 +16,13 @@ # You should have received a copy of the GNU Lesser General Public License # along with Pyrogram. If not, see . +from .callback_game import CallbackGame from .callback_query import CallbackQuery from .force_reply import ForceReply +from .game_high_score import GameHighScore +from .game_high_scores import GameHighScores from .inline_keyboard_button import InlineKeyboardButton from .inline_keyboard_markup import InlineKeyboardMarkup from .keyboard_button import KeyboardButton from .reply_keyboard_markup import ReplyKeyboardMarkup from .reply_keyboard_remove import ReplyKeyboardRemove -from .callback_game import CallbackGame \ No newline at end of file diff --git a/pyrogram/client/types/bots/game_high_score.py b/pyrogram/client/types/bots/game_high_score.py new file mode 100644 index 00000000..1297d8a9 --- /dev/null +++ b/pyrogram/client/types/bots/game_high_score.py @@ -0,0 +1,61 @@ +# 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 pyrogram + +from pyrogram.api import types +from pyrogram.client.types.pyrogram_type import PyrogramType +from pyrogram.client.types.user_and_chats import User + + +class GameHighScore(PyrogramType): + """This object represents one row of the high scores table for a game. + + Args: + user (:obj:`User`): + User. + + score (``int``): + Score. + + position (``position``): + Position in high score table for the game. + """ + + def __init__(self, + *, + client: "pyrogram.client.ext.BaseClient", + user: User, + score: int, + position: int): + super().__init__(client) + + self.user = user + self.score = score + self.position = position + + @staticmethod + def _parse(client, game_high_score: types.HighScore, users: dict) -> "GameHighScore": + users = {i.id: i for i in users} + + return GameHighScore( + user=User._parse(client, users[game_high_score.user_id]), + score=game_high_score.score, + position=game_high_score.pos, + client=client + ) diff --git a/pyrogram/client/types/bots/game_high_scores.py b/pyrogram/client/types/bots/game_high_scores.py new file mode 100644 index 00000000..1717effa --- /dev/null +++ b/pyrogram/client/types/bots/game_high_scores.py @@ -0,0 +1,56 @@ +# 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 . + +from typing import List + +import pyrogram +from pyrogram.api import types +from pyrogram.client.types.pyrogram_type import PyrogramType +from .game_high_score import GameHighScore + + +class GameHighScores(PyrogramType): + """This object represents the high scores table for a game. + + Args: + total_count (``int``): + Total number of scores the target game has. + + game_high_scores (List of :obj:`GameHighScore `): + Game scores. + """ + + def __init__(self, + *, + client: "pyrogram.client.ext.BaseClient", + total_count: int, + game_high_scores: List[GameHighScore]): + super().__init__(client) + + self.total_count = total_count + self.game_high_scores = game_high_scores + + @staticmethod + def _parse(client, game_high_scores: types.messages.HighScores) -> "GameHighScores": + return GameHighScores( + total_count=len(game_high_scores.scores), + game_high_scores=[ + GameHighScore._parse(client, score, game_high_scores.users) + for score in game_high_scores.scores], + client=client + ) diff --git a/pyrogram/client/types/messages_and_media/__init__.py b/pyrogram/client/types/messages_and_media/__init__.py index 3c0b3c98..604b68b9 100644 --- a/pyrogram/client/types/messages_and_media/__init__.py +++ b/pyrogram/client/types/messages_and_media/__init__.py @@ -20,6 +20,7 @@ from .animation import Animation from .audio import Audio from .contact import Contact from .document import Document +from .game import Game from .location import Location from .message import Message from .message_entity import MessageEntity @@ -34,4 +35,3 @@ from .venue import Venue from .video import Video from .video_note import VideoNote from .voice import Voice -from .game import Game From ee472329a20fbc503e0a59639abb6cf776398696 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 22:35:17 +0100 Subject: [PATCH 39/96] Move get_game_high_scores method into bots folder --- pyrogram/client/methods/bots/__init__.py | 4 +++- .../methods/{messages => bots}/get_game_high_scores.py | 8 +++++++- pyrogram/client/methods/messages/__init__.py | 8 +------- 3 files changed, 11 insertions(+), 9 deletions(-) rename pyrogram/client/methods/{messages => bots}/get_game_high_scores.py (90%) diff --git a/pyrogram/client/methods/bots/__init__.py b/pyrogram/client/methods/bots/__init__.py index 28ba49d8..65d132a0 100644 --- a/pyrogram/client/methods/bots/__init__.py +++ b/pyrogram/client/methods/bots/__init__.py @@ -17,6 +17,7 @@ # along with Pyrogram. If not, see . from .answer_callback_query import AnswerCallbackQuery +from .get_game_high_scores import GetGameHighScores from .get_inline_bot_results import GetInlineBotResults from .request_callback_answer import RequestCallbackAnswer from .send_game import SendGame @@ -30,6 +31,7 @@ class Bots( RequestCallbackAnswer, SendInlineBotResult, SendGame, - SetGameScore + SetGameScore, + GetGameHighScores ): pass diff --git a/pyrogram/client/methods/messages/get_game_high_scores.py b/pyrogram/client/methods/bots/get_game_high_scores.py similarity index 90% rename from pyrogram/client/methods/messages/get_game_high_scores.py rename to pyrogram/client/methods/bots/get_game_high_scores.py index 9816c38e..ad4f8b4a 100644 --- a/pyrogram/client/methods/messages/get_game_high_scores.py +++ b/pyrogram/client/methods/bots/get_game_high_scores.py @@ -20,7 +20,7 @@ from typing import Union import pyrogram from pyrogram.api import functions -from ...ext import BaseClient +from pyrogram.client.ext import BaseClient class GetGameHighScores(BaseClient): @@ -45,6 +45,12 @@ class GetGameHighScores(BaseClient): message_id (``int``, *optional*): Identifier of the sent message. Required if inline_message_id is not specified. + + Returns: + On success, a :obj:`GameHighScores ` object is returned. + + Raises: + :class:`Error ` in case of a Telegram RPC error. """ # TODO: inline_message_id diff --git a/pyrogram/client/methods/messages/__init__.py b/pyrogram/client/methods/messages/__init__.py index a46b3c0c..f76d0a22 100644 --- a/pyrogram/client/methods/messages/__init__.py +++ b/pyrogram/client/methods/messages/__init__.py @@ -24,7 +24,6 @@ from .edit_message_media import EditMessageMedia from .edit_message_reply_markup import EditMessageReplyMarkup from .edit_message_text import EditMessageText from .forward_messages import ForwardMessages -from .get_game_high_scores import GetGameHighScores from .get_history import GetHistory from .get_messages import GetMessages from .iter_history import IterHistory @@ -34,7 +33,6 @@ from .send_audio import SendAudio from .send_chat_action import SendChatAction from .send_contact import SendContact from .send_document import SendDocument -from pyrogram.client.methods.bots.send_game import SendGame from .send_location import SendLocation from .send_media_group import SendMediaGroup from .send_message import SendMessage @@ -45,7 +43,6 @@ from .send_venue import SendVenue from .send_video import SendVideo from .send_video_note import SendVideoNote from .send_voice import SendVoice -from pyrogram.client.methods.bots.set_game_score import SetGameScore from .vote_poll import VotePoll @@ -77,9 +74,6 @@ class Messages( ClosePoll, RetractVote, DownloadMedia, - IterHistory, - SendGame, - SetGameScore, - GetGameHighScores + IterHistory ): pass From 8928ca34336a1ca5e1c8f5172e1a5dd3c62d9db5 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 22:50:54 +0100 Subject: [PATCH 40/96] Rename game_score to game_high_score --- pyrogram/client/filters/filters.py | 4 ++-- pyrogram/client/types/bots/game_high_score.py | 12 ++++++++++-- pyrogram/client/types/messages_and_media/message.py | 8 ++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/pyrogram/client/filters/filters.py b/pyrogram/client/filters/filters.py index 01ffe434..77492c6a 100644 --- a/pyrogram/client/filters/filters.py +++ b/pyrogram/client/filters/filters.py @@ -172,8 +172,8 @@ class Filters: pinned_message = create("PinnedMessage", lambda _, m: bool(m.pinned_message)) """Filter service messages for pinned messages.""" - game_score = create("GameScore", lambda _, m: bool(m.game_score)) - """Filter service messages for game scores.""" + game_high_score = create("GameHighScore", lambda _, m: bool(m.game_high_score)) + """Filter service messages for game high scores.""" reply_keyboard = create("ReplyKeyboard", lambda _, m: isinstance(m.reply_markup, ReplyKeyboardMarkup)) """Filter messages containing reply keyboard markups""" diff --git a/pyrogram/client/types/bots/game_high_score.py b/pyrogram/client/types/bots/game_high_score.py index 1297d8a9..0541c18c 100644 --- a/pyrogram/client/types/bots/game_high_score.py +++ b/pyrogram/client/types/bots/game_high_score.py @@ -33,7 +33,7 @@ class GameHighScore(PyrogramType): score (``int``): Score. - position (``position``): + position (``position``, *optional*): Position in high score table for the game. """ @@ -42,7 +42,7 @@ class GameHighScore(PyrogramType): client: "pyrogram.client.ext.BaseClient", user: User, score: int, - position: int): + position: int = None): super().__init__(client) self.user = user @@ -59,3 +59,11 @@ class GameHighScore(PyrogramType): position=game_high_score.pos, client=client ) + + @staticmethod + def _parse_action(client, service: types.MessageService, users: dict): + return GameHighScore( + user=User._parse(client, users[service.from_id]), + score=service.action.score, + client=client + ) diff --git a/pyrogram/client/types/messages_and_media/message.py b/pyrogram/client/types/messages_and_media/message.py index fddc4d0b..a323898c 100644 --- a/pyrogram/client/types/messages_and_media/message.py +++ b/pyrogram/client/types/messages_and_media/message.py @@ -202,7 +202,7 @@ class Message(PyrogramType, Update): Note that the Message object in this field will not contain further reply_to_message fields even if it is itself a reply. - game_score (``int``, *optional*): + game_high_score (:obj:`GameHighScore `, *optional*): The game score for a user. The reply_to_message field will contain the game Message. @@ -283,7 +283,7 @@ class Message(PyrogramType, Update): migrate_to_chat_id: int = None, migrate_from_chat_id: int = None, pinned_message: "Message" = None, - game_score: int = None, + game_high_score: int = None, views: int = None, via_bot: User = None, outgoing: bool = None, @@ -341,7 +341,7 @@ class Message(PyrogramType, Update): self.migrate_to_chat_id = migrate_to_chat_id self.migrate_from_chat_id = migrate_from_chat_id self.pinned_message = pinned_message - self.game_score = game_score + self.game_high_score = game_high_score self.views = views self.via_bot = via_bot self.outgoing = outgoing @@ -419,7 +419,7 @@ class Message(PyrogramType, Update): pass if isinstance(action, types.MessageActionGameScore): - parsed_message.game_score = action.score + parsed_message.game_high_score = pyrogram.GameHighScore._parse_action(client, message, users) if message.reply_to_msg_id and replies: try: From 4db826615b5521e5db193f8168b93017f89fd48c Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 22:58:14 +0100 Subject: [PATCH 41/96] Add USER_BOT_REQUIRED error --- compiler/error/source/400_BAD_REQUEST.tsv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compiler/error/source/400_BAD_REQUEST.tsv b/compiler/error/source/400_BAD_REQUEST.tsv index 1caec874..0f174c66 100644 --- a/compiler/error/source/400_BAD_REQUEST.tsv +++ b/compiler/error/source/400_BAD_REQUEST.tsv @@ -85,4 +85,5 @@ TAKEOUT_INVALID The takeout id is invalid 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 \ No newline at end of file +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 From 36681c8c5b8135144fc6546a33396e4781d2f5d4 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 7 Jan 2019 22:59:22 +0100 Subject: [PATCH 42/96] Update dev version --- pyrogram/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/__init__.py b/pyrogram/__init__.py index 7330001d..db3d8674 100644 --- a/pyrogram/__init__.py +++ b/pyrogram/__init__.py @@ -23,7 +23,7 @@ __copyright__ = "Copyright (C) 2017-2019 Dan Tès Date: Tue, 8 Jan 2019 14:28:52 +0100 Subject: [PATCH 43/96] Allow phone_number, phone_code and password to also be functions Also add recovery_code References #163 --- pyrogram/client/client.py | 161 ++++++++++++++++++++++---------------- 1 file changed, 95 insertions(+), 66 deletions(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index bd72c582..7b5c1aea 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -111,18 +111,28 @@ class Client(Methods, BaseClient): Only applicable for new sessions and will be ignored in case previously created sessions are loaded. - phone_number (``str``, *optional*): - Pass your phone number (with your Country Code prefix included) to avoid - entering it manually. Only applicable for new sessions. + 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), or pass a callback function which accepts - a single positional argument *(phone_number)* and must return the correct phone code (e.g., "12345"). + 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 (if you have one) to avoid entering it - manually. Only applicable for new sessions. + 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. @@ -180,6 +190,7 @@ class Client(Methods, BaseClient): 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, @@ -205,6 +216,7 @@ class Client(Methods, BaseClient): 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 @@ -470,20 +482,25 @@ class Client(Methods, BaseClient): 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_hash_invalid_raises = self.password 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: - if self.phone_number is None: - self.phone_number = input("Enter phone number: ") - - while True: - confirm = input("Is \"{}\" correct? (y/n): ".format(self.phone_number)) - - if confirm in ("y", "1"): - break - elif confirm in ("n", "2"): - self.phone_number = input("Enter phone number: ") + 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("+") @@ -499,23 +516,21 @@ class Client(Methods, BaseClient): self.session.stop() self.dc_id = e.x - self.auth_key = Auth(self.dc_id, self.test_mode, self.ipv6, self._proxy).create() + + 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() - r = self.send( - functions.auth.SendCode( - self.phone_number, - self.api_id, - self.api_hash - ) - ) - break + self.session.start() except (PhoneNumberInvalid, PhoneNumberBanned) as e: if phone_number_invalid_raises: raise @@ -530,6 +545,7 @@ class Client(Methods, BaseClient): time.sleep(e.x) except Exception as e: log.error(e, exc_info=True) + raise else: break @@ -549,10 +565,23 @@ class Client(Methods, BaseClient): ) 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 self.phone_code if type(self.phone_code) is str - else str(self.phone_code(self.phone_number)) + else str(self.phone_code(self.phone_number)) if callable(self.phone_code) + else str(self.phone_code) ) try: @@ -570,9 +599,6 @@ class Client(Methods, BaseClient): phone_registered = False continue else: - self.first_name = self.first_name if self.first_name is not None else input("First name: ") - self.last_name = self.last_name if self.last_name is not None else input("Last name: ") - try: r = self.send( functions.auth.SignUp( @@ -602,60 +628,62 @@ class Client(Methods, BaseClient): 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()) - if self.password is None: - print("Hint: {}".format(r.hint)) + 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) + ) - self.password = input("Enter password (empty to recover): ") + if self.password == "": + r = self.send(functions.auth.RequestPasswordRecovery()) - 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) + ) - print("An e-mail containing the recovery code has been sent to {}".format( - r.email_pattern - )) - - r = self.send( - functions.auth.RecoverPassword( - code=input("Enter password 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) - ) + ) + else: + r = self.send( + functions.auth.CheckPassword( + password=compute_check(r, self.password) ) - except PasswordEmpty as e: - if password_hash_invalid_raises: - raise - else: - print(e.MESSAGE) - self.password = None - except PasswordRecoveryNa as e: - if password_hash_invalid_raises: - raise - else: - print(e.MESSAGE) - self.password = None - except PasswordHashInvalid as e: - if password_hash_invalid_raises: + ) + 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_hash_invalid_raises: + 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 @@ -667,6 +695,7 @@ class Client(Methods, BaseClient): time.sleep(e.x) except Exception as e: log.error(e, exc_info=True) + raise else: break From 19b8f648d2a7b18de7bf6446367e6001b53a7681 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Thu, 10 Jan 2019 18:22:37 +0100 Subject: [PATCH 44/96] Fix bad behaviours for Python <3.6 Pyrogram was relying on dict keys being "ordered" (keys keeping insertion order). --- pyrogram/client/methods/messages/send_message.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/methods/messages/send_message.py b/pyrogram/client/methods/messages/send_message.py index c25ec570..6589fcd6 100644 --- a/pyrogram/client/methods/messages/send_message.py +++ b/pyrogram/client/methods/messages/send_message.py @@ -88,10 +88,18 @@ class SendMessage(BaseClient): ) if isinstance(r, types.UpdateShortSentMessage): + peer = self.resolve_peer(chat_id) + + peer_id = ( + peer.user_id + if isinstance(peer, types.InputPeerUser) + else -peer.chat_id + ) + return pyrogram.Message( message_id=r.id, chat=pyrogram.Chat( - id=list(self.resolve_peer(chat_id).__dict__.values())[0], + id=peer_id, type="private", client=self ), From 07276e31b96a029d8789dd98ede1407adc52f771 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 12:36:37 +0100 Subject: [PATCH 45/96] Add restart method --- docs/source/pyrogram/Client.rst | 1 + pyrogram/client/client.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/docs/source/pyrogram/Client.rst b/docs/source/pyrogram/Client.rst index 9a1b0509..f9e50d3e 100644 --- a/docs/source/pyrogram/Client.rst +++ b/docs/source/pyrogram/Client.rst @@ -13,6 +13,7 @@ Utilities start stop + restart idle run add_handler diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index 7b5c1aea..61a3775b 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -374,6 +374,16 @@ class Client(Methods, BaseClient): 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. From 1d8fd0b836184204d040f19ba5cb8d9b9674a58b Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 12:46:41 +0100 Subject: [PATCH 46/96] Make Filters.regex work on message captions too --- pyrogram/client/filters/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/client/filters/filters.py b/pyrogram/client/filters/filters.py index 77492c6a..c54960d3 100644 --- a/pyrogram/client/filters/filters.py +++ b/pyrogram/client/filters/filters.py @@ -287,7 +287,7 @@ class Filters: """ def f(_, m): - m.matches = [i for i in _.p.finditer(m.text or "")] + m.matches = [i for i in _.p.finditer(m.text or m.caption or "")] return bool(m.matches) return create("Regex", f, p=re.compile(pattern, flags)) From 161ab79eb356256b8041d78073f5ac47ebdf488c Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 12:51:01 +0100 Subject: [PATCH 47/96] Add Filters.media_group for photos or videos being part of an album. --- pyrogram/client/filters/filters.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyrogram/client/filters/filters.py b/pyrogram/client/filters/filters.py index c54960d3..57a21045 100644 --- a/pyrogram/client/filters/filters.py +++ b/pyrogram/client/filters/filters.py @@ -109,6 +109,9 @@ class Filters: video = create("Video", lambda _, m: bool(m.video)) """Filter messages that contain :obj:`Video ` objects.""" + media_group = create("MediaGroup", lambda _, m: bool(m.media_group_id)) + """Filter messages containing photos or videos being part of an album.""" + voice = create("Voice", lambda _, m: bool(m.voice)) """Filter messages that contain :obj:`Voice ` note objects.""" From 200ed844fe24333a4ac21f2b98fa0a845a103d04 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 13:02:19 +0100 Subject: [PATCH 48/96] Fix first_name and last_name not being called if they are callable --- pyrogram/client/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index 61a3775b..2a3a420d 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -578,13 +578,13 @@ class Client(Methods, BaseClient): 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()) 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()) if callable(self.last_name) else str(self.last_name) ) From d5ed47f4e963700bc2a9d425e31a86ecce57a1b7 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 13:59:18 +0100 Subject: [PATCH 49/96] Fix Message.download() not working when using the progress callback --- pyrogram/client/types/messages_and_media/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/client/types/messages_and_media/message.py b/pyrogram/client/types/messages_and_media/message.py index a323898c..badd3689 100644 --- a/pyrogram/client/types/messages_and_media/message.py +++ b/pyrogram/client/types/messages_and_media/message.py @@ -912,7 +912,7 @@ class Message(PyrogramType, Update): else: raise ValueError("The message doesn't contain any keyboard") - def download(self, file_name: str = "", block: bool = True, progress: callable = None, progress_args: tuple = None): + def download(self, file_name: str = "", block: bool = True, progress: callable = None, progress_args: tuple = ()): """Bound method *download* of :obj:`Message `. Use as a shortcut for: From c28b9f9a2cbb9db8602e798bc68cd725f0b9cfd7 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 14:00:03 +0100 Subject: [PATCH 50/96] Add StopTransmission custom exception Useful for stopping up/downloads after they started --- pyrogram/client/ext/base_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyrogram/client/ext/base_client.py b/pyrogram/client/ext/base_client.py index aaed5786..7b94ae6e 100644 --- a/pyrogram/client/ext/base_client.py +++ b/pyrogram/client/ext/base_client.py @@ -27,6 +27,9 @@ from ...session.internals import MsgId class BaseClient: + class StopTransmission(StopIteration): + pass + APP_VERSION = "Pyrogram \U0001f525 {}".format(__version__) DEVICE_MODEL = "{} {}".format( From 6b63e88de7e42ebfc1aa20433154750d82533ce5 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 14:02:40 +0100 Subject: [PATCH 51/96] Add Client.stop_transmission() method As a wrapper for raise StopTransmission --- pyrogram/client/client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index 2a3a420d..b8e310a5 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -460,6 +460,12 @@ class Client(Methods, BaseClient): 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( From b37d4dc7ec78d0010c6ab35900244d40649a7299 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 14:03:16 +0100 Subject: [PATCH 52/96] Make get_file and save_file handle StopTransmission errors --- pyrogram/client/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index b8e310a5..2912072d 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -1368,6 +1368,8 @@ class Client(Methods, BaseClient): 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: @@ -1569,7 +1571,8 @@ class Client(Methods, BaseClient): except Exception as e: raise e except Exception as e: - log.error(e, exc_info=True) + if not isinstance(e, Client.StopTransmission): + log.error(e, exc_info=True) try: os.remove(file_name) From 2791600926285043128729c9357473b780fab47e Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 14:12:53 +0100 Subject: [PATCH 53/96] Hint about the returned value in case of stopped downloads --- pyrogram/client/methods/messages/download_media.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyrogram/client/methods/messages/download_media.py b/pyrogram/client/methods/messages/download_media.py index cfdcfce7..181daa14 100644 --- a/pyrogram/client/methods/messages/download_media.py +++ b/pyrogram/client/methods/messages/download_media.py @@ -72,6 +72,7 @@ class DownloadMedia(BaseClient): Returns: On success, the absolute path of the downloaded file as string is returned, None otherwise. + In case the download is deliberately stopped with :meth:`stop_transmission`, None is returned as well. Raises: :class:`Error ` in case of a Telegram RPC error. From 4e02cd23a8564cb6ac8a112684561feaafc8a5ed Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Fri, 11 Jan 2019 14:13:23 +0100 Subject: [PATCH 54/96] Make all send_* methods dealing with files aware of StopTransmission --- .../client/methods/messages/send_animation.py | 136 +++++++++--------- .../client/methods/messages/send_audio.py | 132 ++++++++--------- .../client/methods/messages/send_document.py | 122 ++++++++-------- .../client/methods/messages/send_photo.py | 114 ++++++++------- .../client/methods/messages/send_sticker.py | 118 +++++++-------- .../client/methods/messages/send_video.py | 134 ++++++++--------- .../methods/messages/send_video_note.py | 130 +++++++++-------- .../client/methods/messages/send_voice.py | 130 +++++++++-------- 8 files changed, 524 insertions(+), 492 deletions(-) diff --git a/pyrogram/client/methods/messages/send_animation.py b/pyrogram/client/methods/messages/send_animation.py index 08b69c17..5b27c914 100644 --- a/pyrogram/client/methods/messages/send_animation.py +++ b/pyrogram/client/methods/messages/send_animation.py @@ -45,7 +45,7 @@ class SendAnimation(BaseClient): "pyrogram.ReplyKeyboardRemove", "pyrogram.ForceReply"] = None, progress: callable = None, - progress_args: tuple = ()) -> "pyrogram.Message": + progress_args: tuple = ()) -> Union["pyrogram.Message", None]: """Use this method to send animation files (animation or H.264/MPEG-4 AVC video without sound). Args: @@ -119,6 +119,7 @@ class SendAnimation(BaseClient): Returns: On success, the sent :obj:`Message ` is returned. + In case the upload is deliberately stopped with :meth:`stop_transmission`, None is returned instead. Raises: :class:`Error ` in case of a Telegram RPC error. @@ -126,72 +127,75 @@ class SendAnimation(BaseClient): file = None style = self.html if parse_mode.lower() == "html" else self.markdown - if os.path.exists(animation): - thumb = None if thumb is None else self.save_file(thumb) - file = self.save_file(animation, progress=progress, progress_args=progress_args) - media = types.InputMediaUploadedDocument( - mime_type=mimetypes.types_map[".mp4"], - file=file, - thumb=thumb, - attributes=[ - types.DocumentAttributeVideo( - supports_streaming=True, - duration=duration, - w=width, - h=height - ), - types.DocumentAttributeFilename(os.path.basename(animation)), - types.DocumentAttributeAnimated() - ] - ) - elif animation.startswith("http"): - media = types.InputMediaDocumentExternal( - url=animation - ) - else: - try: - decoded = utils.decode(animation) - fmt = " 24 else " 24 else " "pyrogram.Message": + progress_args: tuple = ()) -> Union["pyrogram.Message", None]: """Use this method to send audio files. For sending voice messages, use the :obj:`send_voice()` method instead. @@ -121,6 +121,7 @@ class SendAudio(BaseClient): Returns: On success, the sent :obj:`Message ` is returned. + In case the upload is deliberately stopped with :meth:`stop_transmission`, None is returned instead. Raises: :class:`Error ` in case of a Telegram RPC error. @@ -128,70 +129,73 @@ class SendAudio(BaseClient): file = None style = self.html if parse_mode.lower() == "html" else self.markdown - if os.path.exists(audio): - thumb = None if thumb is None else self.save_file(thumb) - file = self.save_file(audio, progress=progress, progress_args=progress_args) - media = types.InputMediaUploadedDocument( - mime_type=mimetypes.types_map.get("." + audio.split(".")[-1], "audio/mpeg"), - file=file, - thumb=thumb, - attributes=[ - types.DocumentAttributeAudio( - duration=duration, - performer=performer, - title=title - ), - types.DocumentAttributeFilename(os.path.basename(audio)) - ] - ) - elif audio.startswith("http"): - media = types.InputMediaDocumentExternal( - url=audio - ) - else: - try: - decoded = utils.decode(audio) - fmt = " 24 else " 24 else " "pyrogram.Message": + progress_args: tuple = ()) -> Union["pyrogram.Message", None]: """Use this method to send general files. Args: @@ -107,6 +107,7 @@ class SendDocument(BaseClient): Returns: On success, the sent :obj:`Message ` is returned. + In case the upload is deliberately stopped with :meth:`stop_transmission`, None is returned instead. Raises: :class:`Error ` in case of a Telegram RPC error. @@ -114,65 +115,68 @@ class SendDocument(BaseClient): file = None style = self.html if parse_mode.lower() == "html" else self.markdown - if os.path.exists(document): - thumb = None if thumb is None else self.save_file(thumb) - file = self.save_file(document, progress=progress, progress_args=progress_args) - media = types.InputMediaUploadedDocument( - mime_type=mimetypes.types_map.get("." + document.split(".")[-1], "text/plain"), - file=file, - thumb=thumb, - attributes=[ - types.DocumentAttributeFilename(os.path.basename(document)) - ] - ) - elif document.startswith("http"): - media = types.InputMediaDocumentExternal( - url=document - ) - else: - try: - decoded = utils.decode(document) - fmt = " 24 else " 24 else " "pyrogram.Message": + progress_args: tuple = ()) -> Union["pyrogram.Message", None]: """Use this method to send photos. Args: @@ -105,6 +105,7 @@ class SendPhoto(BaseClient): Returns: On success, the sent :obj:`Message ` is returned. + In case the upload is deliberately stopped with :meth:`stop_transmission`, None is returned instead. Raises: :class:`Error ` in case of a Telegram RPC error. @@ -112,62 +113,65 @@ class SendPhoto(BaseClient): file = None style = self.html if parse_mode.lower() == "html" else self.markdown - if os.path.exists(photo): - file = self.save_file(photo, progress=progress, progress_args=progress_args) - media = types.InputMediaUploadedPhoto( - file=file, - ttl_seconds=ttl_seconds - ) - elif photo.startswith("http"): - media = types.InputMediaPhotoExternal( - url=photo, - ttl_seconds=ttl_seconds - ) - else: - try: - decoded = utils.decode(photo) - fmt = " 24 else " 24 else " "pyrogram.Message": + progress_args: tuple = ()) -> Union["pyrogram.Message", None]: """Use this method to send .webp stickers. Args: @@ -89,69 +89,73 @@ class SendSticker(BaseClient): Returns: On success, the sent :obj:`Message ` is returned. + In case the upload is deliberately stopped with :meth:`stop_transmission`, None is returned instead. Raises: :class:`Error ` in case of a Telegram RPC error. """ file = None - if os.path.exists(sticker): - file = self.save_file(sticker, progress=progress, progress_args=progress_args) - media = types.InputMediaUploadedDocument( - mime_type="image/webp", - file=file, - attributes=[ - types.DocumentAttributeFilename(os.path.basename(sticker)) - ] - ) - elif sticker.startswith("http"): - media = types.InputMediaDocumentExternal( - url=sticker - ) - else: - try: - decoded = utils.decode(sticker) - fmt = " 24 else " 24 else " "pyrogram.Message": + progress_args: tuple = ()) -> Union["pyrogram.Message", None]: """Use this method to send video files. Args: @@ -123,6 +123,7 @@ class SendVideo(BaseClient): Returns: On success, the sent :obj:`Message ` is returned. + In case the upload is deliberately stopped with :meth:`stop_transmission`, None is returned instead. Raises: :class:`Error ` in case of a Telegram RPC error. @@ -130,71 +131,74 @@ class SendVideo(BaseClient): file = None style = self.html if parse_mode.lower() == "html" else self.markdown - if os.path.exists(video): - thumb = None if thumb is None else self.save_file(thumb) - file = self.save_file(video, progress=progress, progress_args=progress_args) - media = types.InputMediaUploadedDocument( - mime_type=mimetypes.types_map[".mp4"], - file=file, - thumb=thumb, - attributes=[ - types.DocumentAttributeVideo( - supports_streaming=supports_streaming or None, - duration=duration, - w=width, - h=height - ), - types.DocumentAttributeFilename(os.path.basename(video)) - ] - ) - elif video.startswith("http"): - media = types.InputMediaDocumentExternal( - url=video - ) - else: - try: - decoded = utils.decode(video) - fmt = " 24 else " 24 else " "pyrogram.Message": + progress_args: tuple = ()) -> Union["pyrogram.Message", None]: """Use this method to send video messages. Args: @@ -105,72 +105,76 @@ class SendVideoNote(BaseClient): Returns: On success, the sent :obj:`Message ` is returned. + In case the upload is deliberately stopped with :meth:`stop_transmission`, None is returned instead. Raises: :class:`Error ` in case of a Telegram RPC error. """ file = None - if os.path.exists(video_note): - thumb = None if thumb is None else self.save_file(thumb) - file = self.save_file(video_note, progress=progress, progress_args=progress_args) - media = types.InputMediaUploadedDocument( - mime_type=mimetypes.types_map[".mp4"], - file=file, - thumb=thumb, - attributes=[ - types.DocumentAttributeVideo( - round_message=True, - duration=duration, - w=length, - h=length - ) - ] - ) - else: - try: - decoded = utils.decode(video_note) - fmt = " 24 else " 24 else " "pyrogram.Message": + progress_args: tuple = ()) -> Union["pyrogram.Message", None]: """Use this method to send audio files. Args: @@ -104,6 +104,7 @@ class SendVoice(BaseClient): Returns: On success, the sent :obj:`Message ` is returned. + In case the upload is deliberately stopped with :meth:`stop_transmission`, None is returned instead. Raises: :class:`Error ` in case of a Telegram RPC error. @@ -111,66 +112,69 @@ class SendVoice(BaseClient): file = None style = self.html if parse_mode.lower() == "html" else self.markdown - if os.path.exists(voice): - file = self.save_file(voice, progress=progress, progress_args=progress_args) - media = types.InputMediaUploadedDocument( - mime_type=mimetypes.types_map.get("." + voice.split(".")[-1], "audio/mpeg"), - file=file, - attributes=[ - types.DocumentAttributeAudio( - voice=True, - duration=duration - ) - ] - ) - elif voice.startswith("http"): - media = types.InputMediaDocumentExternal( - url=voice - ) - else: - try: - decoded = utils.decode(voice) - fmt = " 24 else " 24 else " Date: Fri, 11 Jan 2019 14:20:01 +0100 Subject: [PATCH 55/96] Add stop_transmission to docs --- docs/source/pyrogram/Client.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/pyrogram/Client.rst b/docs/source/pyrogram/Client.rst index f9e50d3e..548f1c5b 100644 --- a/docs/source/pyrogram/Client.rst +++ b/docs/source/pyrogram/Client.rst @@ -21,6 +21,7 @@ Utilities send resolve_peer save_file + stop_transmission Decorators ---------- 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 56/96] 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 57/96] 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 58/96] 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 59/96] 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 60/96] 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 61/96] 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 62/96] 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 63/96] 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 64/96] 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 65/96] 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 66/96] 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 67/96] 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 68/96] 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 69/96] 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 70/96] 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 71/96] 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 72/96] 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 73/96] 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 74/96] 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 75/96] 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 76/96] 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 77/96] 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 -------- From a8a6f53e2da871ad8d461661210f1922fcd9b450 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 26 Jan 2019 13:01:44 +0100 Subject: [PATCH 78/96] Fix the configuration load process breaking in case of no plugins --- pyrogram/client/client.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index eb205bc2..32612821 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -1087,14 +1087,15 @@ class Client(Methods, BaseClient): "exclude": section.get("exclude") or None } except KeyError: - pass + 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") - ] + if 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: From f376efd496997c697a936b7a1be081a6f4f02676 Mon Sep 17 00:00:00 2001 From: bakatrouble Date: Sun, 27 Jan 2019 12:07:19 +0300 Subject: [PATCH 79/96] Run generate for editable installs --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index cc2a3880..6062c987 100644 --- a/setup.py +++ b/setup.py @@ -127,7 +127,7 @@ class Generate(Command): docs_compiler.start() -if len(argv) > 1 and argv[1] in ["bdist_wheel", "install"]: +if len(argv) > 1 and argv[1] in ["bdist_wheel", "install", "develop"]: error_compiler.start() api_compiler.start() docs_compiler.start() From 52f2a2b17d96a9fc8b9d143330ff9fdf6151d06c Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sun, 27 Jan 2019 10:41:14 +0100 Subject: [PATCH 80/96] Add TARGET to hold the enabled chats and more comments --- examples/welcome.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/welcome.py b/examples/welcome.py index 06a38cb7..ab252672 100644 --- a/examples/welcome.py +++ b/examples/welcome.py @@ -6,13 +6,15 @@ to make it only work for specific messages in a specific chat. from pyrogram import Client, Emoji, Filters -MENTION = "[{}](tg://user?id={})" -MESSAGE = "{} Welcome to [Pyrogram](https://docs.pyrogram.ml/)'s group chat {}!" +TARGET = "PyrogramChat" # Target chat. Can also be a list of multiple chat ids/usernames +MENTION = "[{}](tg://user?id={})" # User mention markup +MESSAGE = "{} Welcome to [Pyrogram](https://docs.pyrogram.ml/)'s group chat {}!" # Welcome message app = Client("my_account") -@app.on_message(Filters.chat("PyrogramChat") & Filters.new_chat_members) +# Filter in only new_chat_members updates generated in TARGET chat +@app.on_message(Filters.chat(TARGET) & Filters.new_chat_members) def welcome(client, message): # Build the new members list (with mentions) by using their first_name new_members = [MENTION.format(i.first_name, i.id) for i in message.new_chat_members] From 67a35f8c7e9fe6bbb81078d4a5fa492723d2a185 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sun, 27 Jan 2019 11:13:10 +0100 Subject: [PATCH 81/96] Handle get_history flood waits It's likely to get triggered when using iter_history (every ~3k msgs) --- .../client/methods/messages/get_history.py | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/pyrogram/client/methods/messages/get_history.py b/pyrogram/client/methods/messages/get_history.py index d45623f4..73923b44 100644 --- a/pyrogram/client/methods/messages/get_history.py +++ b/pyrogram/client/methods/messages/get_history.py @@ -16,12 +16,17 @@ # You should have received a copy of the GNU Lesser General Public License # along with Pyrogram. If not, see . +import logging +import time from typing import Union import pyrogram from pyrogram.api import functions +from pyrogram.api.errors import FloodWait from ...ext import BaseClient +log = logging.getLogger(__name__) + class GetHistory(BaseClient): def get_history(self, @@ -66,21 +71,28 @@ class GetHistory(BaseClient): :class:`Error ` in case of a Telegram RPC error. """ - messages = pyrogram.Messages._parse( - self, - self.send( - functions.messages.GetHistory( - peer=self.resolve_peer(chat_id), - offset_id=offset_id, - offset_date=offset_date, - add_offset=offset * (-1 if reverse else 1) - (limit if reverse else 0), - limit=limit, - max_id=0, - min_id=0, - hash=0 + while True: + try: + messages = pyrogram.Messages._parse( + self, + self.send( + functions.messages.GetHistory( + peer=self.resolve_peer(chat_id), + offset_id=offset_id, + offset_date=offset_date, + add_offset=offset * (-1 if reverse else 1) - (limit if reverse else 0), + limit=limit, + max_id=0, + min_id=0, + hash=0 + ) + ) ) - ) - ) + except FloodWait as e: + log.warning("Sleeping for {}s".format(e.x)) + time.sleep(e.x) + else: + break if reverse: messages.messages.reverse() From 628ddd4a25c67e22dc3698820bb8364f29ec2c85 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sun, 27 Jan 2019 11:24:23 +0100 Subject: [PATCH 82/96] Update Client's docstrings --- pyrogram/client/client.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pyrogram/client/client.py b/pyrogram/client/client.py index 32612821..1bbc2267 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -139,8 +139,9 @@ class Client(Methods, BaseClient): 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. + Pass a First Name as string to avoid entering it manually. Or pass a callback function which accepts no + arguments and must return the correct name as string (e.g., "Dan"). 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*): @@ -158,7 +159,8 @@ class Client(Methods, BaseClient): Path of the configuration file. Defaults to ./config.ini plugins (``dict``, *optional*): - TODO: doctrings + Your Smart Plugins settings as dict, e.g.: *dict(root="plugins")*. + This is an alternative way to setup plugins if you don't want to use the *config.ini* file. no_updates (``bool``, *optional*): Pass True to completely disable incoming updates for the current session. From adfba5ffdf40163cbb1eae447bc35968845bf083 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sun, 27 Jan 2019 11:41:46 +0100 Subject: [PATCH 83/96] Add IMAGE_PROCESS_FAILED error --- compiler/error/source/400_BAD_REQUEST.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/compiler/error/source/400_BAD_REQUEST.tsv b/compiler/error/source/400_BAD_REQUEST.tsv index 82474096..9250c566 100644 --- a/compiler/error/source/400_BAD_REQUEST.tsv +++ b/compiler/error/source/400_BAD_REQUEST.tsv @@ -87,3 +87,4 @@ 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 +IMAGE_PROCESS_FAILED The server failed to process your image \ No newline at end of file From 0f0e19eb1a866ff9c0afbcd9c27a913edb0ad032 Mon Sep 17 00:00:00 2001 From: bakatrouble Date: Mon, 28 Jan 2019 01:45:36 +0300 Subject: [PATCH 84/96] Add TAKEOUT_INIT_DELAY_X to error sources --- compiler/error/source/420_FLOOD.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/compiler/error/source/420_FLOOD.tsv b/compiler/error/source/420_FLOOD.tsv index bf404156..fd4d09e6 100644 --- a/compiler/error/source/420_FLOOD.tsv +++ b/compiler/error/source/420_FLOOD.tsv @@ -1,2 +1,3 @@ id message FLOOD_WAIT_X A wait of {x} seconds is required +TAKEOUT_INIT_DELAY_X Either confirm data export request on another device or wait {x} seconds From 9079fbc9326493a377481d703b8ef3d07d928af2 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Wed, 30 Jan 2019 15:52:29 +0100 Subject: [PATCH 85/96] Slightly reword TAKEOUT_INIT_DELAY_X error message --- compiler/error/source/420_FLOOD.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/error/source/420_FLOOD.tsv b/compiler/error/source/420_FLOOD.tsv index fd4d09e6..3d5ceabd 100644 --- a/compiler/error/source/420_FLOOD.tsv +++ b/compiler/error/source/420_FLOOD.tsv @@ -1,3 +1,3 @@ id message FLOOD_WAIT_X A wait of {x} seconds is required -TAKEOUT_INIT_DELAY_X Either confirm data export request on another device or wait {x} seconds +TAKEOUT_INIT_DELAY_X You have to confirm the data export request using one of your mobile devices or wait {x} seconds From f0d059da07b0261357106a1260053ad3ce9c7a25 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Wed, 30 Jan 2019 17:16:50 +0100 Subject: [PATCH 86/96] First working (and ugly) way for fixing raw updates being swallowed --- pyrogram/client/dispatcher/dispatcher.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pyrogram/client/dispatcher/dispatcher.py b/pyrogram/client/dispatcher/dispatcher.py index 47999bc6..b9acf519 100644 --- a/pyrogram/client/dispatcher/dispatcher.py +++ b/pyrogram/client/dispatcher/dispatcher.py @@ -128,10 +128,11 @@ class Dispatcher: parser = self.update_parsers.get(type(update), None) - if parser is None: - continue - - parsed_update, handler_type = parser(update, users, chats) + parsed_update, handler_type = ( + parser(update, users, chats) + if parser + else (None, type) + ) for group in self.groups.values(): try: From c40f061d9acfb3c598b4a4c1712844dcd3f4cdaf Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Sat, 2 Feb 2019 19:01:35 +0100 Subject: [PATCH 87/96] Fix CallbackQuery docstrings --- pyrogram/client/types/bots/callback_query.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyrogram/client/types/bots/callback_query.py b/pyrogram/client/types/bots/callback_query.py index 62f651d0..c2558844 100644 --- a/pyrogram/client/types/bots/callback_query.py +++ b/pyrogram/client/types/bots/callback_query.py @@ -40,15 +40,15 @@ class CallbackQuery(PyrogramType, Update): Sender. chat_instance (``str``, *optional*): + Global identifier, uniquely corresponding to the chat to which the message with the callback button was + sent. Useful for high scores in games. + + message (:obj:`Message `, *optional*): Message with the callback button that originated the query. Note that message content and message date will not be available if the message is too old. - message (:obj:`Message `, *optional*): - Identifier of the message sent via the bot in inline mode, that originated the query. - inline_message_id (``str``): - Global identifier, uniquely corresponding to the chat to which the message with the callback button was - sent. Useful for high scores in games. + Identifier of the message sent via the bot in inline mode, that originated the query. data (``bytes``, *optional*): Data associated with the callback button. Be aware that a bad client can send arbitrary data in this field. @@ -72,9 +72,9 @@ class CallbackQuery(PyrogramType, Update): self.id = id self.from_user = from_user + self.chat_instance = chat_instance self.message = message self.inline_message_id = inline_message_id - self.chat_instance = chat_instance self.data = data self.game_short_name = game_short_name From 429cfd0882edca4bded39ba5b615b5f469310e06 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 4 Feb 2019 10:35:00 +0100 Subject: [PATCH 88/96] Move the check method into Handler superclass --- pyrogram/client/handlers/callback_query_handler.py | 7 ------- pyrogram/client/handlers/deleted_messages_handler.py | 6 +----- pyrogram/client/handlers/handler.py | 7 +++++++ pyrogram/client/handlers/message_handler.py | 7 ------- pyrogram/client/handlers/user_status_handler.py | 7 ------- 5 files changed, 8 insertions(+), 26 deletions(-) diff --git a/pyrogram/client/handlers/callback_query_handler.py b/pyrogram/client/handlers/callback_query_handler.py index e991c019..88ddd5a0 100644 --- a/pyrogram/client/handlers/callback_query_handler.py +++ b/pyrogram/client/handlers/callback_query_handler.py @@ -45,10 +45,3 @@ class CallbackQueryHandler(Handler): def __init__(self, callback: callable, filters=None): super().__init__(callback, filters) - - def check(self, callback_query): - return ( - self.filters(callback_query) - if callable(self.filters) - else True - ) diff --git a/pyrogram/client/handlers/deleted_messages_handler.py b/pyrogram/client/handlers/deleted_messages_handler.py index c084f353..52177dcc 100644 --- a/pyrogram/client/handlers/deleted_messages_handler.py +++ b/pyrogram/client/handlers/deleted_messages_handler.py @@ -48,8 +48,4 @@ class DeletedMessagesHandler(Handler): super().__init__(callback, filters) def check(self, messages): - return ( - self.filters(messages.messages[0]) - if callable(self.filters) - else True - ) + return super().check(messages.messages[0]) diff --git a/pyrogram/client/handlers/handler.py b/pyrogram/client/handlers/handler.py index 9fd0e206..36963280 100644 --- a/pyrogram/client/handlers/handler.py +++ b/pyrogram/client/handlers/handler.py @@ -21,3 +21,10 @@ class Handler: def __init__(self, callback: callable, filters=None): self.callback = callback self.filters = filters + + def check(self, update): + return ( + self.filters(update) + if callable(self.filters) + else True + ) diff --git a/pyrogram/client/handlers/message_handler.py b/pyrogram/client/handlers/message_handler.py index 8a52a0b5..67b4587e 100644 --- a/pyrogram/client/handlers/message_handler.py +++ b/pyrogram/client/handlers/message_handler.py @@ -46,10 +46,3 @@ class MessageHandler(Handler): def __init__(self, callback: callable, filters=None): super().__init__(callback, filters) - - def check(self, message): - return ( - self.filters(message) - if callable(self.filters) - else True - ) diff --git a/pyrogram/client/handlers/user_status_handler.py b/pyrogram/client/handlers/user_status_handler.py index 643a064d..856ef81d 100644 --- a/pyrogram/client/handlers/user_status_handler.py +++ b/pyrogram/client/handlers/user_status_handler.py @@ -45,10 +45,3 @@ class UserStatusHandler(Handler): def __init__(self, callback: callable, filters=None): super().__init__(callback, filters) - - def check(self, user_status): - return ( - self.filters(user_status) - if callable(self.filters) - else True - ) From 392fea6e325d2dd956c7e8214971b3e978f563f7 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 4 Feb 2019 11:46:57 +0100 Subject: [PATCH 89/96] Refactor Dispatcher's worker Closes #211 --- pyrogram/client/dispatcher/dispatcher.py | 39 ++++++++++++------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/pyrogram/client/dispatcher/dispatcher.py b/pyrogram/client/dispatcher/dispatcher.py index b9acf519..cb22a48c 100644 --- a/pyrogram/client/dispatcher/dispatcher.py +++ b/pyrogram/client/dispatcher/dispatcher.py @@ -130,34 +130,33 @@ class Dispatcher: parsed_update, handler_type = ( parser(update, users, chats) - if parser - else (None, type) + if parser is not None + else (None, type(None)) ) for group in self.groups.values(): - try: - for handler in group: - args = None + for handler in group: + args = None - if isinstance(handler, RawUpdateHandler): - args = (update, users, chats) - elif isinstance(handler, handler_type): - if handler.check(parsed_update): - args = (parsed_update,) + if isinstance(handler, handler_type): + if handler.check(parsed_update): + args = (parsed_update,) + elif isinstance(handler, RawUpdateHandler): + args = (update, users, chats) - if args is None: - continue + if args is None: + continue - try: - handler.callback(self.client, *args) - except StopIteration: - raise - except Exception as e: - log.error(e, exc_info=True) + try: + handler.callback(self.client, *args) + except pyrogram.StopPropagation: + raise + except Exception as e: + log.error(e, exc_info=True) - break - except StopIteration: break + except pyrogram.StopPropagation: + pass except Exception as e: log.error(e, exc_info=True) From a6dbed6dfb07989f064bae5938fb3b67eb144a69 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 4 Feb 2019 12:33:54 +0100 Subject: [PATCH 90/96] Add a way to continue the update propagation within a group Add continue_propagation() method and ContinuePropagation exception Closes #212 --- docs/source/resources/MoreOnUpdates.rst | 85 ++++++++++++++++++++++-- pyrogram/__init__.py | 3 +- pyrogram/client/dispatcher/dispatcher.py | 2 + pyrogram/client/types/__init__.py | 2 +- pyrogram/client/types/update.py | 7 ++ 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/docs/source/resources/MoreOnUpdates.rst b/docs/source/resources/MoreOnUpdates.rst index 44295f35..9712a5d2 100644 --- a/docs/source/resources/MoreOnUpdates.rst +++ b/docs/source/resources/MoreOnUpdates.rst @@ -91,13 +91,14 @@ Stop Propagation In order to prevent further propagation of an update in the dispatching phase, you can do *one* of the following: - Call the update's bound-method ``.stop_propagation()`` (preferred way). -- Manually ``raise StopPropagation`` error (more suitable for raw updates only). +- Manually ``raise StopPropagation`` exception (more suitable for raw updates only). .. note:: - Note that ``.stop_propagation()`` is just an elegant and intuitive way to raise a ``StopPropagation`` error; - this means that any code coming *after* calling it won't be executed as your function just raised a custom exception - to signal the dispatcher not to propagate the update anymore. + Internally, the propagation is stopped by handling a custom exception. ``.stop_propagation()`` is just an elegant + and intuitive way to ``raise StopPropagation``; this also means that any code coming *after* calling the method + won't be executed as your function just raised an exception to signal the dispatcher not to propagate the + update anymore. Example with ``stop_propagation()``: @@ -139,10 +140,82 @@ Example with ``raise StopPropagation``: def _(client, message): print(2) -The handler in group number 2 will never be executed because the propagation was stopped before. The output of both -examples will be: +Each handler is registered in a different group, but the handler in group number 2 will never be executed because the +propagation was stopped earlier. The output of both (equivalent) examples will be: .. code-block:: text 0 1 + +Continue Propagation +^^^^^^^^^^^^^^^^^^^^ + +As opposed to `stopping the update propagation <#stop-propagation>`_ and also as an alternative to the +`handler groups <#handler-groups>`_, you can signal the internal dispatcher to continue the update propagation within +the group regardless of the next handler's filters. This allows you to register multiple handlers with overlapping +filters in the same group; to let the dispatcher process the next handler you can do *one* of the following in each +handler you want to grant permission to continue: + +- Call the update's bound-method ``.continue_propagation()`` (preferred way). +- Manually ``raise ContinuePropagation`` exception (more suitable for raw updates only). + +.. note:: + + Internally, the propagation is continued by handling a custom exception. ``.continue_propagation()`` is just an + elegant and intuitive way to ``raise ContinuePropagation``; this also means that any code coming *after* calling the + method won't be executed as your function just raised an exception to signal the dispatcher to continue with the + next available handler. + + +Example with ``continue_propagation()``: + +.. code-block:: python + + @app.on_message(Filters.private) + def _(client, message): + print(0) + message.continue_propagation() + + + @app.on_message(Filters.private) + def _(client, message): + print(1) + message.continue_propagation() + + + @app.on_message(Filters.private) + def _(client, message): + print(2) + +Example with ``raise ContinuePropagation``: + +.. code-block:: python + + from pyrogram import ContinuePropagation + + @app.on_message(Filters.private) + def _(client, message): + print(0) + raise ContinuePropagation + + + @app.on_message(Filters.private) + def _(client, message): + print(1) + raise ContinuePropagation + + + @app.on_message(Filters.private) + def _(client, message): + print(2) + +Three handlers are registered in the same group, and all of them will be executed because the propagation was continued +in each handler (except in the last one, where is useless to do so since there is no more handlers after). +The output of both (equivalent) examples will be: + +.. code-block:: text + + 0 + 1 + 2 \ No newline at end of file diff --git a/pyrogram/__init__.py b/pyrogram/__init__.py index db3d8674..62f74ddf 100644 --- a/pyrogram/__init__.py +++ b/pyrogram/__init__.py @@ -32,7 +32,8 @@ from .client.types import ( Location, Message, MessageEntity, Dialog, Dialogs, Photo, PhotoSize, Sticker, User, UserStatus, UserProfilePhotos, Venue, Animation, Video, VideoNote, Voice, CallbackQuery, Messages, ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove, - Poll, PollOption, ChatPreview, StopPropagation, Game, CallbackGame, GameHighScore, GameHighScores + Poll, PollOption, ChatPreview, StopPropagation, ContinuePropagation, Game, CallbackGame, GameHighScore, + GameHighScores ) from .client import ( Client, ChatAction, ParseMode, Emoji, diff --git a/pyrogram/client/dispatcher/dispatcher.py b/pyrogram/client/dispatcher/dispatcher.py index cb22a48c..5a463077 100644 --- a/pyrogram/client/dispatcher/dispatcher.py +++ b/pyrogram/client/dispatcher/dispatcher.py @@ -151,6 +151,8 @@ class Dispatcher: handler.callback(self.client, *args) except pyrogram.StopPropagation: raise + except pyrogram.ContinuePropagation: + continue except Exception as e: log.error(e, exc_info=True) diff --git a/pyrogram/client/types/__init__.py b/pyrogram/client/types/__init__.py index ca332a22..a29b0816 100644 --- a/pyrogram/client/types/__init__.py +++ b/pyrogram/client/types/__init__.py @@ -30,7 +30,7 @@ from .messages_and_media import ( Sticker, Venue, Video, VideoNote, Voice, UserProfilePhotos, Message, Messages, MessageEntity, Poll, PollOption, Game ) -from .update import StopPropagation +from .update import StopPropagation, ContinuePropagation from .user_and_chats import ( Chat, ChatMember, ChatMembers, ChatPhoto, Dialog, Dialogs, User, UserStatus, ChatPreview diff --git a/pyrogram/client/types/update.py b/pyrogram/client/types/update.py index 80c233c0..2ec22f5a 100644 --- a/pyrogram/client/types/update.py +++ b/pyrogram/client/types/update.py @@ -21,6 +21,13 @@ class StopPropagation(StopIteration): pass +class ContinuePropagation(StopIteration): + pass + + class Update: def stop_propagation(self): raise StopPropagation + + def continue_propagation(self): + raise ContinuePropagation From f1a4e9f64b5b4a709d3871b500aeffda7a405275 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 4 Feb 2019 12:53:33 +0100 Subject: [PATCH 91/96] Add VoiceCalls.rst --- docs/source/index.rst | 1 + docs/source/resources/VoiceCalls.rst | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 docs/source/resources/VoiceCalls.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index 067e6fbf..1d77c02f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -99,6 +99,7 @@ To get started, press the Next button. resources/ErrorHandling resources/TestServers resources/AdvancedUsage + resources/VoiceCalls resources/Changelog .. toctree:: diff --git a/docs/source/resources/VoiceCalls.rst b/docs/source/resources/VoiceCalls.rst new file mode 100644 index 00000000..c1a8cc53 --- /dev/null +++ b/docs/source/resources/VoiceCalls.rst @@ -0,0 +1,10 @@ +Voice Calls +=========== + +A working proof-of-concept of Telegram voice calls using Pyrogram can be found here: +https://github.com/bakatrouble/pylibtgvoip. Thanks to `@bakatrouble `_. + +.. note:: + + This page will be updated with more information once voice calls become eventually more usable and more integrated + in Pyrogram itself. From c213118a746c036f678f6e4007506e31d2d52562 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 4 Feb 2019 13:00:09 +0100 Subject: [PATCH 92/96] Update develop version --- pyrogram/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/__init__.py b/pyrogram/__init__.py index 62f74ddf..152c95af 100644 --- a/pyrogram/__init__.py +++ b/pyrogram/__init__.py @@ -23,7 +23,7 @@ __copyright__ = "Copyright (C) 2017-2019 Dan Tès Date: Mon, 4 Feb 2019 13:04:57 +0100 Subject: [PATCH 93/96] Update README.rst --- README.rst | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index fcc6407a..8b39f4b6 100644 --- a/README.rst +++ b/README.rst @@ -3,8 +3,6 @@ Pyrogram ======== - `A fully asynchronous variant is also available! `_ - .. code-block:: python from pyrogram import Client, Filters @@ -20,17 +18,19 @@ Pyrogram app.run() **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. +It enables you to easily create custom apps using both user and bot identities (bot API alternative) via the `MTProto API`_. + + `A fully-asynchronous variant is also available » `_ Features -------- -- **Easy**: You can install Pyrogram with pip and start building your app right away. +- **Easy**: You can install Pyrogram with pip and start building your applications 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. - **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. +- **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. @@ -47,8 +47,8 @@ Installing pip3 install pyrogram -Getting Started ---------------- +Resources +--------- - 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 Pyrogram works. @@ -69,6 +69,7 @@ Copyright & License - Licensed under the terms of the `GNU Lesser General Public License v3 or later (LGPLv3+)`_ .. _`Telegram`: https://telegram.org/ +.. _`MTProto API`: https://core.telegram.org/api#telegram-api .. _`Telegram API key`: https://docs.pyrogram.ml/start/ProjectSetup#api-keys .. _`Community`: https://t.me/PyrogramChat .. _`Examples in this repository`: https://github.com/pyrogram/pyrogram/tree/master/examples @@ -76,6 +77,7 @@ Copyright & License .. _`Email`: admin@pyrogram.ml .. _`Message`: https://t.me/haskell .. _TgCrypto: https://github.com/pyrogram/tgcrypto +.. _`MTProto 2.0`: https://core.telegram.org/mtproto .. _`GNU Lesser General Public License v3 or later (LGPLv3+)`: COPYING.lesser .. |header| raw:: html From 6f44cb52143add1e1ce2b8b8cb8d95ff487bcc99 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 4 Feb 2019 13:06:23 +0100 Subject: [PATCH 94/96] Update docs index page --- docs/source/index.rst | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 1d77c02f..4d913d23 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -56,18 +56,18 @@ using the Next button at the end of each page. But first, here's a brief overvie 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 create custom apps using both user and bot identities (bot API alternative) via the `MTProto API`_. Features -------- -- **Easy**: You can install Pyrogram with pip and start building your app right away. +- **Easy**: You can install Pyrogram with pip and start building your applications 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. - **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. +- **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. @@ -116,4 +116,6 @@ To get started, press the Next button. types/index .. _`Telegram`: https://telegram.org/ -.. _TgCrypto: https://docs.pyrogram.ml/resources/TgCrypto/ \ No newline at end of file +.. _TgCrypto: https://docs.pyrogram.ml/resources/TgCrypto/ +.. _`MTProto API`: https://core.telegram.org/api#telegram-api +.. _`MTProto 2.0`: https://core.telegram.org/mtproto \ No newline at end of file From 9f7884b0ed956634853f82d706e38f97d8279d5d Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 4 Feb 2019 16:29:13 +0100 Subject: [PATCH 95/96] Fix PyPI readme render --- README.rst | 4 ++-- setup.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 8b39f4b6..baf64845 100644 --- a/README.rst +++ b/README.rst @@ -120,10 +120,10 @@ Copyright & License .. |description| replace:: **Telegram MTProto API Framework for Python** -.. |schema| 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: Schema Layer -.. |tgcrypto| image:: "https://img.shields.io/badge/tgcrypto-v1.1.1-eda738.svg?longCache=true&colorA=262b30" +.. |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 Version diff --git a/setup.py b/setup.py index 6062c987..cba41b78 100644 --- a/setup.py +++ b/setup.py @@ -39,10 +39,10 @@ def get_version(): def get_readme(): - # PyPI doesn"t like raw html + # PyPI doesn't like raw html with open("README.rst", encoding="utf-8") as f: readme = re.sub(r"\.\. \|.+\| raw:: html(?:\s{4}.+)+\n\n", "", f.read()) - return re.sub(r"\|header\|", "|logo|\n\n|description|\n\n|scheme| |tgcrypto|", readme) + return re.sub(r"\|header\|", "|logo|\n\n|description|\n\n|schema| |tgcrypto|", readme) class Clean(Command): From 5de2b67df5bb660ba4370fd27f0324318532d025 Mon Sep 17 00:00:00 2001 From: Dan <14043624+delivrance@users.noreply.github.com> Date: Mon, 4 Feb 2019 16:40:24 +0100 Subject: [PATCH 96/96] Update Pyrogram to v0.11.0 --- pyrogram/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrogram/__init__.py b/pyrogram/__init__.py index 152c95af..2bc1fed3 100644 --- a/pyrogram/__init__.py +++ b/pyrogram/__init__.py @@ -23,7 +23,7 @@ __copyright__ = "Copyright (C) 2017-2019 Dan Tès