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/ 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() diff --git a/compiler/error/source/400_BAD_REQUEST.tsv b/compiler/error/source/400_BAD_REQUEST.tsv index c0a5da73..f01439f5 100644 --- a/compiler/error/source/400_BAD_REQUEST.tsv +++ b/compiler/error/source/400_BAD_REQUEST.tsv @@ -80,4 +80,8 @@ 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 +MESSAGE_POLL_CLOSED You can't interact with a closed poll +MEDIA_INVALID The media is invalid \ No newline at end of file diff --git a/docs/source/pyrogram/Client.rst b/docs/source/pyrogram/Client.rst index 14796ef2..0d49068a 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 @@ -91,7 +92,9 @@ Chats get_chat_member get_chat_members get_chat_members_count + iter_chat_members get_dialogs + iter_dialogs Users ----- diff --git a/pyrogram/__init__.py b/pyrogram/__init__.py index 66b01950..06f784f7 100644 --- a/pyrogram/__init__.py +++ b/pyrogram/__init__.py @@ -26,7 +26,7 @@ except ImportError: else: asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -__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" ) @@ -40,7 +40,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/client.py b/pyrogram/client/client.py index 6e879cc8..fb4552b7 100644 --- a/pyrogram/client/client.py +++ b/pyrogram/client/client.py @@ -153,6 +153,19 @@ 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). + + 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, @@ -175,7 +188,9 @@ 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, + takeout: bool = None): super().__init__() self.session_name = session_name @@ -199,6 +214,8 @@ class Client(Methods, BaseClient): 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) @@ -255,6 +272,10 @@ class Client(Methods, BaseClient): self.save_session() if self.bot_token is None: + if self.takeout: + self.takeout_id = (await 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: @@ -299,6 +320,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)) + await Syncer.remove(self) await self.dispatcher.stop() @@ -944,6 +969,12 @@ class Client(Methods, BaseClient): 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 = await 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 35ab07c4..45bab738 100644 --- a/pyrogram/client/ext/base_client.py +++ b/pyrogram/client/ext/base_client.py @@ -22,8 +22,6 @@ import re from pyrogram import __version__ from ..style import Markdown, HTML -from ...api.core import Object -from ...session import Session from ...session.internals import MsgId @@ -89,6 +87,8 @@ class BaseClient: self.is_started = None self.is_idle = None + self.takeout_id = None + self.updates_queue = asyncio.Queue() self.updates_worker_task = None self.download_queue = asyncio.Queue() @@ -96,33 +96,29 @@ 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 + + def get_dialogs(self, *args, **kwargs): + pass + + def get_chat_members(self, *args, **kwargs): pass diff --git a/pyrogram/client/filters/filters.py b/pyrogram/client/filters/filters.py index c0ebfdcf..01ffe434 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""" @@ -97,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.""" @@ -166,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""" @@ -190,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 @@ -205,7 +215,8 @@ class Filters: - video_note - contact - location - - venue""" + - venue + - poll""" @staticmethod def command(command: str or list, diff --git a/pyrogram/client/methods/chats/__init__.py b/pyrogram/client/methods/chats/__init__.py index 745678cc..6cc034e4 100644 --- a/pyrogram/client/methods/chats/__init__.py +++ b/pyrogram/client/methods/chats/__init__.py @@ -24,6 +24,8 @@ 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 from .leave_chat import LeaveChat @@ -56,6 +58,8 @@ class Chats( UnpinChatMessage, GetDialogs, GetChatMembersCount, - GetChatPreview + GetChatPreview, + IterDialogs, + IterChatMembers ): pass diff --git a/pyrogram/client/methods/chats/get_chat_members.py b/pyrogram/client/methods/chats/get_chat_members.py index 149afa3e..b0273c81 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``): diff --git a/pyrogram/client/methods/chats/get_dialogs.py b/pyrogram/client/methods/chats/get_dialogs.py index dc3fa8a5..4d597425 100644 --- a/pyrogram/client/methods/chats/get_dialogs.py +++ b/pyrogram/client/methods/chats/get_dialogs.py @@ -16,19 +16,26 @@ # 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): async def get_dialogs(self, 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`. Args: offset_date (``int``): @@ -50,18 +57,25 @@ class GetDialogs(BaseClient): :class:`Error ` in case of a Telegram RPC error. """ - if pinned_only: - r = await self.send(functions.messages.GetPinnedDialogs()) - else: - r = await 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 = await self.send(functions.messages.GetPinnedDialogs()) + else: + r = await 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) 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 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 diff --git a/pyrogram/client/methods/chats/kick_chat_member.py b/pyrogram/client/methods/chats/kick_chat_member.py index 344ceed2..95d38d62 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): async 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 diff --git a/pyrogram/client/methods/messages/__init__.py b/pyrogram/client/methods/messages/__init__.py index 237b6493..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 @@ -25,6 +26,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 @@ -69,7 +71,9 @@ class Messages( SendVoice, SendPoll, VotePoll, + ClosePoll, RetractVote, - DownloadMedia + DownloadMedia, + IterHistory ): pass 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 diff --git a/pyrogram/client/methods/messages/get_history.py b/pyrogram/client/methods/messages/get_history.py index ce36dbe0..80dea33c 100644 --- a/pyrogram/client/methods/messages/get_history.py +++ b/pyrogram/client/methods/messages/get_history.py @@ -30,10 +30,11 @@ class GetHistory(BaseClient): offset: int = 0, offset_id: int = 0, offset_date: int = 0, - reversed: bool = False): - """Use this method to retrieve the history of a chat. + reverse: bool = False): + """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``): @@ -55,7 +56,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 +73,7 @@ class GetHistory(BaseClient): peer=await self.resolve_peer(chat_id), offset_id=offset_id, offset_date=offset_date, - add_offset=offset - (limit if reversed else 0), + add_offset=offset * (-1 if reverse else 1) - (limit if reverse else 0), limit=limit, max_id=0, min_id=0, @@ -81,7 +82,7 @@ class GetHistory(BaseClient): ) ) - if reversed: + if reverse: messages.messages.reverse() return messages diff --git a/pyrogram/client/methods/messages/get_messages.py b/pyrogram/client/methods/messages/get_messages.py index 460f6b59..09a132b6 100644 --- a/pyrogram/client/methods/messages/get_messages.py +++ b/pyrogram/client/methods/messages/get_messages.py @@ -16,19 +16,24 @@ # 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): async def get_messages(self, 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. @@ -78,6 +83,15 @@ class GetMessages(BaseClient): else: rpc = functions.messages.GetMessages(id=ids) - messages = await pyrogram.Messages._parse(self, await self.send(rpc), replies) + while True: + try: + r = await self.send(rpc) + except FloodWait as e: + log.warning("Sleeping for {}s".format(e.x)) + time.sleep(e.x) + else: + break + + messages = await pyrogram.Messages._parse(self, r, replies) return messages if is_iterable else messages.messages[0] 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 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 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/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) 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: 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 + ) diff --git a/pyrogram/client/types/messages_and_media/message.py b/pyrogram/client/types/messages_and_media/message.py index 1b657963..ec057503 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, diff --git a/pyrogram/client/types/messages_and_media/messages.py b/pyrogram/client/types/messages_and_media/messages.py index eac9b8b9..5b12da45 100644 --- a/pyrogram/client/types/messages_and_media/messages.py +++ b/pyrogram/client/types/messages_and_media/messages.py @@ -52,14 +52,41 @@ 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 + ) + # TODO: WTF! Py 3.5 doesn't support await inside comprehensions parsed_messages = [] for message in messages.messages: - parsed_messages.append(await Message._parse(client, message, users, chats, replies)) + parsed_messages.appen(await Message._parse(client, message, users, chats, replies=0)) + + 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 = (await 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)), + total_count=total_count, messages=parsed_messages, client=client )