diff --git a/README.rst b/README.rst index 41ce1c86d..ee8ebc941 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ We have a vibrant community of developers helping each other in our `Telegram gr :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-5.3-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-5.4-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API versions @@ -111,7 +111,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **5.3** are supported. +All types and methods of the Telegram Bot API **5.4** are supported. ========== Installing diff --git a/README_RAW.rst b/README_RAW.rst index 7a8c8fd5e..a76584e83 100644 --- a/README_RAW.rst +++ b/README_RAW.rst @@ -20,7 +20,7 @@ We have a vibrant community of developers helping each other in our `Telegram gr :target: https://pypi.org/project/python-telegram-bot-raw/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-5.3-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-5.4-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API versions @@ -105,7 +105,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **5.3** are supported. +All types and methods of the Telegram Bot API **5.4** are supported. ========== Installing diff --git a/setup.cfg b/setup.cfg index f01307511..ecfc17fad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -60,7 +60,7 @@ ignore_errors = True # Disable strict optional for telegram objects with class methods # We don't want to clutter the code with 'if self.bot is None: raise RuntimeError()' -[mypy-telegram.callbackquery,telegram.chat,telegram.message,telegram.user,telegram.files.*,telegram.inline.inlinequery,telegram.payment.precheckoutquery,telegram.payment.shippingquery,telegram.passport.passportdata,telegram.passport.credentials,telegram.passport.passportfile,telegram.ext.filters] +[mypy-telegram.callbackquery,telegram.chat,telegram.message,telegram.user,telegram.files.*,telegram.inline.inlinequery,telegram.payment.precheckoutquery,telegram.payment.shippingquery,telegram.passport.passportdata,telegram.passport.credentials,telegram.passport.passportfile,telegram.ext.filters,telegram.chatjoinrequest] strict_optional = False # type hinting for asyncio in webhookhandler is a bit tricky because it depends on the OS diff --git a/telegram/__init__.py b/telegram/__init__.py index 59179e8ae..4a9198f9f 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -25,6 +25,7 @@ from .files.chatphoto import ChatPhoto from .chat import Chat from .chatlocation import ChatLocation from .chatinvitelink import ChatInviteLink +from .chatjoinrequest import ChatJoinRequest from .chatmember import ( ChatMember, ChatMemberOwner, @@ -194,6 +195,7 @@ __all__ = ( # Keep this alphabetically ordered 'Chat', 'ChatAction', 'ChatInviteLink', + 'ChatJoinRequest', 'ChatLocation', 'ChatMember', 'ChatMemberOwner', diff --git a/telegram/bot.py b/telegram/bot.py index 63fbd7556..5051482a1 100644 --- a/telegram/bot.py +++ b/telegram/bot.py @@ -3985,6 +3985,8 @@ class Bot(TelegramObject): member_limit: int = None, timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + name: str = None, + creates_join_request: bool = None, ) -> ChatInviteLink: """ Use this method to create an additional invite link for a chat. The bot must be an @@ -4007,6 +4009,14 @@ class Bot(TelegramObject): the connection pool). api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the Telegram API. + name (:obj:`str`, optional): Invite link name; 0-32 characters. + + .. versionadded:: 13.8 + creates_join_request (:obj:`bool`, optional): :obj:`True`, if users joining the chat + via the link need to be approved by chat administrators. + If :obj:`True`, ``member_limit`` can't be specified. + + .. versionadded:: 13.8 Returns: :class:`telegram.ChatInviteLink` @@ -4015,6 +4025,11 @@ class Bot(TelegramObject): :class:`telegram.error.TelegramError` """ + if creates_join_request and member_limit: + raise ValueError( + "If `creates_join_request` is `True`, `member_limit` can't be specified." + ) + data: JSONDict = { 'chat_id': chat_id, } @@ -4029,6 +4044,12 @@ class Bot(TelegramObject): if member_limit is not None: data['member_limit'] = member_limit + if name is not None: + data['name'] = name + + if creates_join_request is not None: + data['creates_join_request'] = creates_join_request + result = self._post('createChatInviteLink', data, timeout=timeout, api_kwargs=api_kwargs) return ChatInviteLink.de_json(result, self) # type: ignore[return-value, arg-type] @@ -4042,11 +4063,19 @@ class Bot(TelegramObject): member_limit: int = None, timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + name: str = None, + creates_join_request: bool = None, ) -> ChatInviteLink: """ Use this method to edit a non-primary invite link created by the bot. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. + Note: + Though not stated explicitly in the official docs, Telegram changes not only the + optional parameters that are explicitly passed, but also replaces all other optional + parameters to the default values. However, since not documented, this behaviour may + change unbeknown to PTB. + .. versionadded:: 13.4 Args: @@ -4064,6 +4093,14 @@ class Bot(TelegramObject): the connection pool). api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the Telegram API. + name (:obj:`str`, optional): Invite link name; 0-32 characters. + + .. versionadded:: 13.8 + creates_join_request (:obj:`bool`, optional): :obj:`True`, if users joining the chat + via the link need to be approved by chat administrators. + If :obj:`True`, ``member_limit`` can't be specified. + + .. versionadded:: 13.8 Returns: :class:`telegram.ChatInviteLink` @@ -4072,6 +4109,11 @@ class Bot(TelegramObject): :class:`telegram.error.TelegramError` """ + if creates_join_request and member_limit: + raise ValueError( + "If `creates_join_request` is `True`, `member_limit` can't be specified." + ) + data: JSONDict = {'chat_id': chat_id, 'invite_link': invite_link} if expire_date is not None: @@ -4084,6 +4126,12 @@ class Bot(TelegramObject): if member_limit is not None: data['member_limit'] = member_limit + if name is not None: + data['name'] = name + + if creates_join_request is not None: + data['creates_join_request'] = creates_join_request + result = self._post('editChatInviteLink', data, timeout=timeout, api_kwargs=api_kwargs) return ChatInviteLink.de_json(result, self) # type: ignore[return-value, arg-type] @@ -4126,6 +4174,80 @@ class Bot(TelegramObject): return ChatInviteLink.de_json(result, self) # type: ignore[return-value, arg-type] + @log + def approve_chat_join_request( + self, + chat_id: Union[str, int], + user_id: int, + timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + ) -> bool: + """Use this method to approve a chat join request. + + The bot must be an administrator in the chat for this to work and must have the + :attr:`telegram.ChatPermissions.can_invite_users` administrator right. + + .. versionadded:: 13.8 + + Args: + chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target chat or username + of the target channel (in the format ``@channelusername``). + user_id (:obj:`int`): Unique identifier of the target user. + timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as + the read timeout from the server (instead of the one specified during creation of + the connection pool). + api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the + Telegram API. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {'chat_id': chat_id, 'user_id': user_id} + + result = self._post('approveChatJoinRequest', data, timeout=timeout, api_kwargs=api_kwargs) + + return result # type: ignore[return-value] + + @log + def decline_chat_join_request( + self, + chat_id: Union[str, int], + user_id: int, + timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + ) -> bool: + """Use this method to decline a chat join request. + + The bot must be an administrator in the chat for this to work and must have the + :attr:`telegram.ChatPermissions.can_invite_users` administrator right. + + .. versionadded:: 13.8 + + Args: + chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target chat or username + of the target channel (in the format ``@channelusername``). + user_id (:obj:`int`): Unique identifier of the target user. + timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as + the read timeout from the server (instead of the one specified during creation of + the connection pool). + api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the + Telegram API. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {'chat_id': chat_id, 'user_id': user_id} + + result = self._post('declineChatJoinRequest', data, timeout=timeout, api_kwargs=api_kwargs) + + return result # type: ignore[return-value] + @log def set_chat_photo( self, @@ -5436,11 +5558,15 @@ class Bot(TelegramObject): exportChatInviteLink = export_chat_invite_link """Alias for :meth:`export_chat_invite_link`""" createChatInviteLink = create_chat_invite_link - """Alias for :attr:`create_chat_invite_link`""" + """Alias for :meth:`create_chat_invite_link`""" editChatInviteLink = edit_chat_invite_link - """Alias for :attr:`edit_chat_invite_link`""" + """Alias for :meth:`edit_chat_invite_link`""" revokeChatInviteLink = revoke_chat_invite_link - """Alias for :attr:`revoke_chat_invite_link`""" + """Alias for :meth:`revoke_chat_invite_link`""" + approveChatJoinRequest = approve_chat_join_request + """Alias for :meth:`approve_chat_join_request`""" + declineChatJoinRequest = decline_chat_join_request + """Alias for :meth:`decline_chat_join_request`""" setChatPhoto = set_chat_photo """Alias for :meth:`set_chat_photo`""" deleteChatPhoto = delete_chat_photo diff --git a/telegram/chat.py b/telegram/chat.py index 4b5b6c844..33c6a03dd 100644 --- a/telegram/chat.py +++ b/telegram/chat.py @@ -1524,6 +1524,8 @@ class Chat(TelegramObject): member_limit: int = None, timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + name: str = None, + creates_join_request: bool = None, ) -> 'ChatInviteLink': """Shortcut for:: @@ -1534,6 +1536,10 @@ class Chat(TelegramObject): .. versionadded:: 13.4 + .. versionchanged:: 13.8 + Edited signature according to the changes of + :meth:`telegram.Bot.create_chat_invite_link`. + Returns: :class:`telegram.ChatInviteLink` @@ -1544,6 +1550,8 @@ class Chat(TelegramObject): member_limit=member_limit, timeout=timeout, api_kwargs=api_kwargs, + name=name, + creates_join_request=creates_join_request, ) def edit_invite_link( @@ -1553,6 +1561,8 @@ class Chat(TelegramObject): member_limit: int = None, timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + name: str = None, + creates_join_request: bool = None, ) -> 'ChatInviteLink': """Shortcut for:: @@ -1563,6 +1573,9 @@ class Chat(TelegramObject): .. versionadded:: 13.4 + .. versionchanged:: 13.8 + Edited signature according to the changes of :meth:`telegram.Bot.edit_chat_invite_link`. + Returns: :class:`telegram.ChatInviteLink` @@ -1574,6 +1587,8 @@ class Chat(TelegramObject): member_limit=member_limit, timeout=timeout, api_kwargs=api_kwargs, + name=name, + creates_join_request=creates_join_request, ) def revoke_invite_link( @@ -1598,3 +1613,49 @@ class Chat(TelegramObject): return self.bot.revoke_chat_invite_link( chat_id=self.id, invite_link=invite_link, timeout=timeout, api_kwargs=api_kwargs ) + + def approve_join_request( + self, + user_id: int, + timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + ) -> bool: + """Shortcut for:: + + bot.approve_chat_join_request(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.approve_chat_join_request`. + + .. versionadded:: 13.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return self.bot.approve_chat_join_request( + chat_id=self.id, user_id=user_id, timeout=timeout, api_kwargs=api_kwargs + ) + + def decline_join_request( + self, + user_id: int, + timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + ) -> bool: + """Shortcut for:: + + bot.decline_chat_join_request(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.decline_chat_join_request`. + + .. versionadded:: 13.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return self.bot.decline_chat_join_request( + chat_id=self.id, user_id=user_id, timeout=timeout, api_kwargs=api_kwargs + ) diff --git a/telegram/chataction.py b/telegram/chataction.py index c737b810f..0e30ce3f0 100644 --- a/telegram/chataction.py +++ b/telegram/chataction.py @@ -59,6 +59,10 @@ class ChatAction: """ UPLOAD_DOCUMENT: ClassVar[str] = constants.CHATACTION_UPLOAD_DOCUMENT """:const:`telegram.constants.CHATACTION_UPLOAD_DOCUMENT`""" + CHOOSE_STICKER: ClassVar[str] = constants.CHATACTION_CHOOSE_STICKER + """:const:`telegram.constants.CHOOSE_STICKER` + + .. versionadded:: 13.8""" UPLOAD_PHOTO: ClassVar[str] = constants.CHATACTION_UPLOAD_PHOTO """:const:`telegram.constants.CHATACTION_UPLOAD_PHOTO`""" UPLOAD_VIDEO: ClassVar[str] = constants.CHATACTION_UPLOAD_VIDEO diff --git a/telegram/chatinvitelink.py b/telegram/chatinvitelink.py index 0755853b0..b25f0876f 100644 --- a/telegram/chatinvitelink.py +++ b/telegram/chatinvitelink.py @@ -46,6 +46,17 @@ class ChatInviteLink(TelegramObject): has been expired. member_limit (:obj:`int`, optional): Maximum number of users that can be members of the chat simultaneously after joining the chat via this invite link; 1-99999. + name (:obj:`str`, optional): Invite link name. + + .. versionadded:: 13.8 + creates_join_request (:obj:`bool`, optional): :obj:`True`, if users joining the chat via + the link need to be approved by chat administrators. + + .. versionadded:: 13.8 + pending_join_request_count (:obj:`int`, optional): Number of pending join requests + created using this link. + + .. versionadded:: 13.8 Attributes: invite_link (:obj:`str`): The invite link. If the link was created by another chat @@ -57,6 +68,17 @@ class ChatInviteLink(TelegramObject): has been expired. member_limit (:obj:`int`): Optional. Maximum number of users that can be members of the chat simultaneously after joining the chat via this invite link; 1-99999. + name (:obj:`str`): Optional. Invite link name. + + .. versionadded:: 13.8 + creates_join_request (:obj:`bool`): Optional. :obj:`True`, if users joining the chat via + the link need to be approved by chat administrators. + + .. versionadded:: 13.8 + pending_join_request_count (:obj:`int`): Optional. Number of pending join requests + created using this link. + + .. versionadded:: 13.8 """ @@ -67,6 +89,9 @@ class ChatInviteLink(TelegramObject): 'is_revoked', 'expire_date', 'member_limit', + 'name', + 'creates_join_request', + 'pending_join_request_count', '_id_attrs', ) @@ -78,6 +103,9 @@ class ChatInviteLink(TelegramObject): is_revoked: bool, expire_date: datetime.datetime = None, member_limit: int = None, + name: str = None, + creates_join_request: bool = None, + pending_join_request_count: int = None, **_kwargs: Any, ): # Required @@ -89,7 +117,11 @@ class ChatInviteLink(TelegramObject): # Optionals self.expire_date = expire_date self.member_limit = int(member_limit) if member_limit is not None else None - + self.name = name + self.creates_join_request = creates_join_request + self.pending_join_request_count = ( + int(pending_join_request_count) if pending_join_request_count is not None else None + ) self._id_attrs = (self.invite_link, self.creator, self.is_primary, self.is_revoked) @classmethod diff --git a/telegram/chatjoinrequest.py b/telegram/chatjoinrequest.py new file mode 100644 index 000000000..81210c784 --- /dev/null +++ b/telegram/chatjoinrequest.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2021 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program 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 Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram ChatJoinRequest.""" +import datetime +from typing import TYPE_CHECKING, Any, Optional + +from telegram import TelegramObject, User, Chat, ChatInviteLink +from telegram.utils.helpers import from_timestamp, to_timestamp, DEFAULT_NONE +from telegram.utils.types import JSONDict, ODVInput + +if TYPE_CHECKING: + from telegram import Bot + + +class ChatJoinRequest(TelegramObject): + """This object represents a join request sent to a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat`, :attr:`from_user` and :attr:`date` are equal. + + .. versionadded:: 13.8 + + Args: + chat (:class:`telegram.Chat`): Chat to which the request was sent. + from_user (:class:`telegram.User`): User that sent the join request. + date (:class:`datetime.datetime`): Date the request was sent. + bio (:obj:`str`, optional): Bio of the user. + invite_link (:class:`telegram.ChatInviteLink`, optional): Chat invite link that was used + by the user to send the join request. + bot (:class:`telegram.Bot`, optional): The Bot to use for instance methods. + + Attributes: + chat (:class:`telegram.Chat`): Chat to which the request was sent. + from_user (:class:`telegram.User`): User that sent the join request. + date (:class:`datetime.datetime`): Date the request was sent. + bio (:obj:`str`): Optional. Bio of the user. + invite_link (:class:`telegram.ChatInviteLink`): Optional. Chat invite link that was used + by the user to send the join request. + + """ + + __slots__ = ( + 'chat', + 'from_user', + 'date', + 'bio', + 'invite_link', + 'bot', + '_id_attrs', + ) + + def __init__( + self, + chat: Chat, + from_user: User, + date: datetime.datetime, + bio: str = None, + invite_link: ChatInviteLink = None, + bot: 'Bot' = None, + **_kwargs: Any, + ): + # Required + self.chat = chat + self.from_user = from_user + self.date = date + + # Optionals + self.bio = bio + self.invite_link = invite_link + + self.bot = bot + self._id_attrs = (self.chat, self.from_user, self.date) + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: 'Bot') -> Optional['ChatJoinRequest']: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data['chat'] = Chat.de_json(data.get('chat'), bot) + data['from_user'] = User.de_json(data.get('from'), bot) + data['date'] = from_timestamp(data.get('date', None)) + data['invite_link'] = ChatInviteLink.de_json(data.get('invite_link'), bot) + + return cls(bot=bot, **data) + + def to_dict(self) -> JSONDict: + """See :meth:`telegram.TelegramObject.to_dict`.""" + data = super().to_dict() + + data['date'] = to_timestamp(self.date) + + return data + + def approve( + self, + timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + ) -> bool: + """Shortcut for:: + + bot.approve_chat_join_request(chat_id=update.effective_chat.id, + user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.approve_chat_join_request`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return self.bot.approve_chat_join_request( + chat_id=self.chat.id, user_id=self.from_user.id, timeout=timeout, api_kwargs=api_kwargs + ) + + def decline( + self, + timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + ) -> bool: + """Shortcut for:: + + bot.decline_chat_join_request(chat_id=update.effective_chat.id, + user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.decline_chat_join_request`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return self.bot.decline_chat_join_request( + chat_id=self.chat.id, user_id=self.from_user.id, timeout=timeout, api_kwargs=api_kwargs + ) diff --git a/telegram/constants.py b/telegram/constants.py index 795f37203..917907482 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -86,6 +86,9 @@ Attributes: .. versionadded:: 13.5 CHATACTION_UPLOAD_DOCUMENT (:obj:`str`): ``'upload_document'`` + CHATACTION_CHOOSE_STICKER (:obj:`str`): ``'choose_sticker'`` + + .. versionadded:: 13.8 CHATACTION_UPLOAD_PHOTO (:obj:`str`): ``'upload_photo'`` CHATACTION_UPLOAD_VIDEO (:obj:`str`): ``'upload_video'`` CHATACTION_UPLOAD_VIDEO_NOTE (:obj:`str`): ``'upload_video_note'`` @@ -201,9 +204,13 @@ Attributes: UPDATE_CHAT_MEMBER (:obj:`str`): ``'chat_member'`` .. versionadded:: 13.5 + UPDATE_CHAT_JOIN_REQUEST (:obj:`str`): ``'chat_join_request'`` + + .. versionadded:: 13.8 UPDATE_ALL_TYPES (List[:obj:`str`]): List of all update types. .. versionadded:: 13.5 + .. versionchanged:: 13.8 :class:`telegram.BotCommandScope`: @@ -233,7 +240,7 @@ Attributes: """ from typing import List -BOT_API_VERSION: str = '5.3' +BOT_API_VERSION: str = '5.4' MAX_MESSAGE_LENGTH: int = 4096 MAX_CAPTION_LENGTH: int = 1024 ANONYMOUS_ADMIN_ID: int = 1087968824 @@ -267,6 +274,7 @@ CHATACTION_TYPING: str = 'typing' CHATACTION_UPLOAD_AUDIO: str = 'upload_audio' CHATACTION_UPLOAD_VOICE: str = 'upload_voice' CHATACTION_UPLOAD_DOCUMENT: str = 'upload_document' +CHATACTION_CHOOSE_STICKER: str = 'choose_sticker' CHATACTION_UPLOAD_PHOTO: str = 'upload_photo' CHATACTION_UPLOAD_VIDEO: str = 'upload_video' CHATACTION_UPLOAD_VIDEO_NOTE: str = 'upload_video_note' @@ -353,6 +361,7 @@ UPDATE_POLL = 'poll' UPDATE_POLL_ANSWER = 'poll_answer' UPDATE_MY_CHAT_MEMBER = 'my_chat_member' UPDATE_CHAT_MEMBER = 'chat_member' +UPDATE_CHAT_JOIN_REQUEST = 'chat_join_request' UPDATE_ALL_TYPES = [ UPDATE_MESSAGE, UPDATE_EDITED_MESSAGE, @@ -367,6 +376,7 @@ UPDATE_ALL_TYPES = [ UPDATE_POLL_ANSWER, UPDATE_MY_CHAT_MEMBER, UPDATE_CHAT_MEMBER, + UPDATE_CHAT_JOIN_REQUEST, ] BOT_COMMAND_SCOPE_DEFAULT = 'default' diff --git a/telegram/ext/__init__.py b/telegram/ext/__init__.py index 731ad2c9e..da3c48941 100644 --- a/telegram/ext/__init__.py +++ b/telegram/ext/__init__.py @@ -59,6 +59,7 @@ from .messagequeue import DelayQueue from .pollanswerhandler import PollAnswerHandler from .pollhandler import PollHandler from .chatmemberhandler import ChatMemberHandler +from .chatjoinrequesthandler import ChatJoinRequestHandler from .defaults import Defaults from .callbackdatacache import CallbackDataCache, InvalidCallbackData @@ -68,6 +69,7 @@ __all__ = ( 'CallbackContext', 'CallbackDataCache', 'CallbackQueryHandler', + 'ChatJoinRequestHandler', 'ChatMemberHandler', 'ChosenInlineResultHandler', 'CommandHandler', diff --git a/telegram/ext/chatjoinrequesthandler.py b/telegram/ext/chatjoinrequesthandler.py new file mode 100644 index 000000000..693b73e47 --- /dev/null +++ b/telegram/ext/chatjoinrequesthandler.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2021 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program 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 Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains the ChatJoinRequestHandler class.""" + + +from telegram import Update + +from .handler import Handler +from .utils.types import CCT + + +class ChatJoinRequestHandler(Handler[Update, CCT]): + """Handler class to handle Telegram updates that contain a chat join request. + + Note: + :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you + can use to keep any data in will be sent to the :attr:`callback` function. Related to + either the user or the chat that the update was sent in. For each update from the same user + or in the same chat, it will be the same ``dict``. + + Note that this is DEPRECATED, and you should use context based callbacks. See + https://git.io/fxJuV for more info. + + Warning: + When setting ``run_async`` to :obj:`True`, you cannot rely on adding custom + attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info. + + .. versionadded:: 13.8 + + Args: + callback (:obj:`callable`): The callback function for this handler. Will be called when + :attr:`check_update` has determined that an update should be processed by this handler. + Callback signature for context based API: + + ``def callback(update: Update, context: CallbackContext)`` + + The return value of the callback is usually ignored except for the special case of + :class:`telegram.ext.ConversationHandler`. + pass_update_queue (:obj:`bool`, optional): If set to :obj:`True`, a keyword argument called + ``update_queue`` will be passed to the callback function. It will be the ``Queue`` + instance used by the :class:`telegram.ext.Updater` and :class:`telegram.ext.Dispatcher` + that contains new updates which can be used to insert updates. Default is :obj:`False`. + DEPRECATED: Please switch to context based callbacks. + pass_job_queue (:obj:`bool`, optional): If set to :obj:`True`, a keyword argument called + ``job_queue`` will be passed to the callback function. It will be a + :class:`telegram.ext.JobQueue` instance created by the :class:`telegram.ext.Updater` + which can be used to schedule new jobs. Default is :obj:`False`. + DEPRECATED: Please switch to context based callbacks. + pass_user_data (:obj:`bool`, optional): If set to :obj:`True`, a keyword argument called + ``user_data`` will be passed to the callback function. Default is :obj:`False`. + DEPRECATED: Please switch to context based callbacks. + pass_chat_data (:obj:`bool`, optional): If set to :obj:`True`, a keyword argument called + ``chat_data`` will be passed to the callback function. Default is :obj:`False`. + DEPRECATED: Please switch to context based callbacks. + run_async (:obj:`bool`): Determines whether the callback will run asynchronously. + Defaults to :obj:`False`. + + Attributes: + callback (:obj:`callable`): The callback function for this handler. + pass_update_queue (:obj:`bool`): Determines whether ``update_queue`` will be + passed to the callback function. + pass_job_queue (:obj:`bool`): Determines whether ``job_queue`` will be passed to + the callback function. + pass_user_data (:obj:`bool`): Determines whether ``user_data`` will be passed to + the callback function. + pass_chat_data (:obj:`bool`): Determines whether ``chat_data`` will be passed to + the callback function. + run_async (:obj:`bool`): Determines whether the callback will run asynchronously. + + """ + + __slots__ = () + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handlers :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + return isinstance(update, Update) and bool(update.chat_join_request) diff --git a/telegram/update.py b/telegram/update.py index 8497ee213..5518bc4aa 100644 --- a/telegram/update.py +++ b/telegram/update.py @@ -31,6 +31,7 @@ from telegram import ( TelegramObject, ChatMemberUpdated, constants, + ChatJoinRequest, ) from telegram.poll import PollAnswer from telegram.utils.types import JSONDict @@ -89,6 +90,12 @@ class Update(TelegramObject): :meth:`telegram.ext.Updater.start_webhook`). .. versionadded:: 13.4 + chat_join_request (:class:`telegram.ChatJoinRequest`, optional): A request to join the + chat has been sent. The bot must have the + :attr:`telegram.ChatPermissions.can_invite_users` administrator right in the chat to + receive these updates. + + .. versionadded:: 13.8 **kwargs (:obj:`dict`): Arbitrary keyword arguments. Attributes: @@ -122,6 +129,11 @@ class Update(TelegramObject): :meth:`telegram.ext.Updater.start_webhook`). .. versionadded:: 13.4 + chat_join_request (:class:`telegram.ChatJoinRequest`): Optional. A request to join the + chat has been sent. The bot must have the ``'can_invite_users'`` administrator + right in the chat to receive these updates. + + .. versionadded:: 13.8 """ @@ -143,6 +155,7 @@ class Update(TelegramObject): '_effective_message', 'my_chat_member', 'chat_member', + 'chat_join_request', '_id_attrs', ) @@ -198,6 +211,10 @@ class Update(TelegramObject): """:const:`telegram.constants.UPDATE_CHAT_MEMBER` .. versionadded:: 13.5""" + CHAT_JOIN_REQUEST = constants.UPDATE_CHAT_JOIN_REQUEST + """:const:`telegram.constants.UPDATE_CHAT_JOIN_REQUEST` + + .. versionadded:: 13.8""" ALL_TYPES = constants.UPDATE_ALL_TYPES """:const:`telegram.constants.UPDATE_ALL_TYPES` @@ -219,6 +236,7 @@ class Update(TelegramObject): poll_answer: PollAnswer = None, my_chat_member: ChatMemberUpdated = None, chat_member: ChatMemberUpdated = None, + chat_join_request: ChatJoinRequest = None, **_kwargs: Any, ): # Required @@ -237,6 +255,7 @@ class Update(TelegramObject): self.poll_answer = poll_answer self.my_chat_member = my_chat_member self.chat_member = chat_member + self.chat_join_request = chat_join_request self._effective_user: Optional['User'] = None self._effective_chat: Optional['Chat'] = None @@ -286,6 +305,9 @@ class Update(TelegramObject): elif self.chat_member: user = self.chat_member.from_user + elif self.chat_join_request: + user = self.chat_join_request.from_user + self._effective_user = user return user @@ -325,6 +347,9 @@ class Update(TelegramObject): elif self.chat_member: chat = self.chat_member.chat + elif self.chat_join_request: + chat = self.chat_join_request.chat + self._effective_chat = chat return chat @@ -335,7 +360,9 @@ class Update(TelegramObject): update this is. Will be :obj:`None` for :attr:`inline_query`, :attr:`chosen_inline_result`, :attr:`callback_query` from inline messages, :attr:`shipping_query`, :attr:`pre_checkout_query`, :attr:`poll`, - :attr:`poll_answer`, :attr:`my_chat_member` and :attr:`chat_member`. + :attr:`poll_answer`, :attr:`my_chat_member`, :attr:`chat_member` as well as + :attr:`chat_join_request` in case the bot is missing the + :attr:`telegram.ChatPermissions.can_invite_users` administrator right in the chat. """ if self._effective_message: @@ -384,5 +411,6 @@ class Update(TelegramObject): data['poll_answer'] = PollAnswer.de_json(data.get('poll_answer'), bot) data['my_chat_member'] = ChatMemberUpdated.de_json(data.get('my_chat_member'), bot) data['chat_member'] = ChatMemberUpdated.de_json(data.get('chat_member'), bot) + data['chat_join_request'] = ChatJoinRequest.de_json(data.get('chat_join_request'), bot) return cls(**data) diff --git a/telegram/user.py b/telegram/user.py index 7949e249e..2f7f962ac 100644 --- a/telegram/user.py +++ b/telegram/user.py @@ -1140,3 +1140,49 @@ class User(TelegramObject): timeout=timeout, api_kwargs=api_kwargs, ) + + def approve_join_request( + self, + chat_id: Union[int, str], + timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + ) -> bool: + """Shortcut for:: + + bot.approve_chat_join_request(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.approve_chat_join_request`. + + .. versionadded:: 13.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return self.bot.approve_chat_join_request( + user_id=self.id, chat_id=chat_id, timeout=timeout, api_kwargs=api_kwargs + ) + + def decline_join_request( + self, + chat_id: Union[int, str], + timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + ) -> bool: + """Shortcut for:: + + bot.decline_chat_join_request(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.decline_chat_join_request`. + + .. versionadded:: 13.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return self.bot.decline_chat_join_request( + user_id=self.id, chat_id=chat_id, timeout=timeout, api_kwargs=api_kwargs + ) diff --git a/tests/test_bot.py b/tests/test_bot.py index 002c49488..11eb44e91 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -718,6 +718,7 @@ class TestBot: ChatAction.UPLOAD_VIDEO, ChatAction.UPLOAD_VIDEO_NOTE, ChatAction.UPLOAD_VOICE, + ChatAction.CHOOSE_STICKER, ], ) def test_send_chat_action(self, bot, chat_id, chat_action): @@ -1696,6 +1697,37 @@ class TestBot: assert isinstance(invite_link, str) assert invite_link != '' + def test_create_edit_invite_link_mutually_exclusive_arguments(self, bot, channel_id): + data = {'chat_id': channel_id, 'member_limit': 17, 'creates_join_request': True} + + with pytest.raises(ValueError, match="`member_limit` can't be specified"): + bot.create_chat_invite_link(**data) + + data.update({'invite_link': 'https://invite.link'}) + with pytest.raises(ValueError, match="`member_limit` can't be specified"): + bot.edit_chat_invite_link(**data) + + @flaky(3, 1) + @pytest.mark.parametrize('creates_join_request', [True, False]) + @pytest.mark.parametrize('name', [None, 'name']) + def test_create_chat_invite_link_basics(self, bot, creates_join_request, name, channel_id): + data = {} + if creates_join_request: + data['creates_join_request'] = True + if name: + data['name'] = name + invite_link = bot.create_chat_invite_link(chat_id=channel_id, **data) + + assert invite_link.member_limit is None + assert invite_link.expire_date is None + assert invite_link.creates_join_request == creates_join_request + assert invite_link.name == name + + revoked_link = bot.revoke_chat_invite_link( + chat_id=channel_id, invite_link=invite_link.invite_link + ) + assert revoked_link.is_revoked + @flaky(3, 1) @pytest.mark.parametrize('datetime', argvalues=[True, False], ids=['datetime', 'integer']) def test_advanced_chat_invite_links(self, bot, channel_id, datetime): @@ -1720,12 +1752,29 @@ class TestBot: aware_time_in_future = pytz.UTC.localize(time_in_future) edited_invite_link = bot.edit_chat_invite_link( - channel_id, invite_link.invite_link, expire_date=expire_time, member_limit=20 + channel_id, + invite_link.invite_link, + expire_date=expire_time, + member_limit=20, + name='NewName', ) assert edited_invite_link.invite_link == invite_link.invite_link assert pytest.approx(edited_invite_link.expire_date == aware_time_in_future) + assert edited_invite_link.name == 'NewName' assert edited_invite_link.member_limit == 20 + edited_invite_link = bot.edit_chat_invite_link( + channel_id, + invite_link.invite_link, + name='EvenNewerName', + creates_join_request=True, + ) + assert edited_invite_link.invite_link == invite_link.invite_link + assert pytest.approx(edited_invite_link.expire_date == aware_time_in_future) + assert edited_invite_link.name == 'EvenNewerName' + assert edited_invite_link.creates_join_request is True + assert edited_invite_link.member_limit is None + revoked_invite_link = bot.revoke_chat_invite_link(channel_id, invite_link.invite_link) assert revoked_invite_link.invite_link == invite_link.invite_link assert revoked_invite_link.is_revoked is True @@ -1750,16 +1799,49 @@ class TestBot: time_in_future = aware_expire_date.replace(tzinfo=None) edited_invite_link = tz_bot.edit_chat_invite_link( - channel_id, invite_link.invite_link, expire_date=time_in_future, member_limit=20 + channel_id, + invite_link.invite_link, + expire_date=time_in_future, + member_limit=20, + name='NewName', ) assert edited_invite_link.invite_link == invite_link.invite_link assert pytest.approx(edited_invite_link.expire_date == aware_expire_date) + assert edited_invite_link.name == 'NewName' assert edited_invite_link.member_limit == 20 + edited_invite_link = tz_bot.edit_chat_invite_link( + channel_id, + invite_link.invite_link, + name='EvenNewerName', + creates_join_request=True, + ) + assert edited_invite_link.invite_link == invite_link.invite_link + assert pytest.approx(edited_invite_link.expire_date == aware_expire_date) + assert edited_invite_link.name == 'EvenNewerName' + assert edited_invite_link.creates_join_request is True + assert edited_invite_link.member_limit is None + revoked_invite_link = tz_bot.revoke_chat_invite_link(channel_id, invite_link.invite_link) assert revoked_invite_link.invite_link == invite_link.invite_link assert revoked_invite_link.is_revoked is True + @flaky(3, 1) + def test_approve_chat_join_request(self, bot, chat_id, channel_id): + # TODO: Need incoming join request to properly test + # Since we can't create join requests on the fly, we just tests the call to TG + # by checking that it complains about approving a user who is already in the chat + with pytest.raises(BadRequest, match='User_already_participant'): + bot.approve_chat_join_request(chat_id=channel_id, user_id=chat_id) + + @flaky(3, 1) + def test_decline_chat_join_request(self, bot, chat_id, channel_id): + # TODO: Need incoming join request to properly test + # Since we can't create join requests on the fly, we just tests the call to TG + # by checking that it complains about declining a user who is already in the chat + with pytest.raises(BadRequest, match='User_already_participant'): + bot.decline_chat_join_request(chat_id=channel_id, user_id=chat_id) + @flaky(3, 1) def test_set_chat_photo(self, bot, channel_id): def func(): diff --git a/tests/test_chat.py b/tests/test_chat.py index a60956c48..7ff7aa392 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -636,6 +636,36 @@ class TestChat: monkeypatch.setattr(chat.bot, 'revoke_chat_invite_link', make_assertion) assert chat.revoke_invite_link(invite_link=link) + def test_approve_join_request(self, monkeypatch, chat): + def make_assertion(*_, **kwargs): + return kwargs['chat_id'] == chat.id and kwargs['user_id'] == 42 + + assert check_shortcut_signature( + Chat.approve_join_request, Bot.approve_chat_join_request, ['chat_id'], [] + ) + assert check_shortcut_call( + chat.approve_join_request, chat.bot, 'approve_chat_join_request' + ) + assert check_defaults_handling(chat.approve_join_request, chat.bot) + + monkeypatch.setattr(chat.bot, 'approve_chat_join_request', make_assertion) + assert chat.approve_join_request(user_id=42) + + def test_decline_join_request(self, monkeypatch, chat): + def make_assertion(*_, **kwargs): + return kwargs['chat_id'] == chat.id and kwargs['user_id'] == 42 + + assert check_shortcut_signature( + Chat.decline_join_request, Bot.decline_chat_join_request, ['chat_id'], [] + ) + assert check_shortcut_call( + chat.decline_join_request, chat.bot, 'decline_chat_join_request' + ) + assert check_defaults_handling(chat.decline_join_request, chat.bot) + + monkeypatch.setattr(chat.bot, 'decline_chat_join_request', make_assertion) + assert chat.decline_join_request(user_id=42) + def test_equality(self): a = Chat(self.id_, self.title, self.type_) b = Chat(self.id_, self.title, self.type_) diff --git a/tests/test_chatinvitelink.py b/tests/test_chatinvitelink.py index 8b4fcadfd..6d18c2cb6 100644 --- a/tests/test_chatinvitelink.py +++ b/tests/test_chatinvitelink.py @@ -38,6 +38,8 @@ def invite_link(creator): TestChatInviteLink.revoked, expire_date=TestChatInviteLink.expire_date, member_limit=TestChatInviteLink.member_limit, + name=TestChatInviteLink.name, + pending_join_request_count=TestChatInviteLink.pending_join_request_count, ) @@ -48,6 +50,8 @@ class TestChatInviteLink: revoked = False expire_date = datetime.datetime.utcnow() member_limit = 42 + name = 'LinkName' + pending_join_request_count = 42 def test_slot_behaviour(self, recwarn, mro_slots, invite_link): for attr in invite_link.__slots__: @@ -79,7 +83,9 @@ class TestChatInviteLink: 'is_primary': self.primary, 'is_revoked': self.revoked, 'expire_date': to_timestamp(self.expire_date), - 'member_limit': self.member_limit, + 'member_limit': str(self.member_limit), + 'name': self.name, + 'pending_join_request_count': str(self.pending_join_request_count), } invite_link = ChatInviteLink.de_json(json_dict, bot) @@ -91,6 +97,8 @@ class TestChatInviteLink: assert pytest.approx(invite_link.expire_date == self.expire_date) assert to_timestamp(invite_link.expire_date) == to_timestamp(self.expire_date) assert invite_link.member_limit == self.member_limit + assert invite_link.name == self.name + assert invite_link.pending_join_request_count == self.pending_join_request_count def test_to_dict(self, invite_link): invite_link_dict = invite_link.to_dict() @@ -101,6 +109,8 @@ class TestChatInviteLink: assert invite_link_dict['is_revoked'] == self.revoked assert invite_link_dict['expire_date'] == to_timestamp(self.expire_date) assert invite_link_dict['member_limit'] == self.member_limit + assert invite_link_dict['name'] == self.name + assert invite_link_dict['pending_join_request_count'] == self.pending_join_request_count def test_equality(self): a = ChatInviteLink("link", User(1, '', False), True, True) diff --git a/tests/test_chatjoinrequest.py b/tests/test_chatjoinrequest.py new file mode 100644 index 000000000..a187b22ef --- /dev/null +++ b/tests/test_chatjoinrequest.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2021 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program 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 Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime + +import pytest +import pytz + +from telegram import ChatJoinRequest, User, Chat, ChatInviteLink, Bot +from telegram.utils.helpers import to_timestamp +from tests.conftest import check_shortcut_signature, check_shortcut_call, check_defaults_handling + + +@pytest.fixture(scope='class') +def time(): + return datetime.datetime.now(tz=pytz.utc) + + +@pytest.fixture(scope='class') +def chat_join_request(bot, time): + return ChatJoinRequest( + chat=TestChatJoinRequest.chat, + from_user=TestChatJoinRequest.from_user, + date=time, + bio=TestChatJoinRequest.bio, + invite_link=TestChatJoinRequest.invite_link, + bot=bot, + ) + + +class TestChatJoinRequest: + chat = Chat(1, Chat.SUPERGROUP) + from_user = User(2, 'first_name', False) + bio = 'bio' + invite_link = ChatInviteLink( + 'https://invite.link', + User(42, 'creator', False), + name='InviteLink', + is_revoked=False, + is_primary=False, + ) + + def test_slot_behaviour(self, chat_join_request, recwarn, mro_slots): + inst = chat_join_request + for attr in inst.__slots__: + assert getattr(inst, attr, 'err') != 'err', f"got extra slot '{attr}'" + assert not inst.__dict__, f"got missing slot(s): {inst.__dict__}" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + inst.custom, inst.bio = 'should give warning', self.bio + assert len(recwarn) == 1 and 'custom' in str(recwarn[0].message), recwarn.list + + def test_de_json(self, bot, time): + json_dict = { + 'chat': self.chat.to_dict(), + 'from': self.from_user.to_dict(), + 'date': to_timestamp(time), + } + chat_join_request = ChatJoinRequest.de_json(json_dict, bot) + + assert chat_join_request.chat == self.chat + assert chat_join_request.from_user == self.from_user + assert pytest.approx(chat_join_request.date == time) + assert to_timestamp(chat_join_request.date) == to_timestamp(time) + + json_dict.update({'bio': self.bio, 'invite_link': self.invite_link.to_dict()}) + chat_join_request = ChatJoinRequest.de_json(json_dict, bot) + + assert chat_join_request.chat == self.chat + assert chat_join_request.from_user == self.from_user + assert pytest.approx(chat_join_request.date == time) + assert to_timestamp(chat_join_request.date) == to_timestamp(time) + assert chat_join_request.bio == self.bio + assert chat_join_request.invite_link == self.invite_link + + def test_to_dict(self, chat_join_request, time): + chat_join_request_dict = chat_join_request.to_dict() + + assert isinstance(chat_join_request_dict, dict) + assert chat_join_request_dict['chat'] == chat_join_request.chat.to_dict() + assert chat_join_request_dict['from'] == chat_join_request.from_user.to_dict() + assert chat_join_request_dict['date'] == to_timestamp(chat_join_request.date) + assert chat_join_request_dict['bio'] == chat_join_request.bio + assert chat_join_request_dict['invite_link'] == chat_join_request.invite_link.to_dict() + + def test_equality(self, chat_join_request, time): + a = chat_join_request + b = ChatJoinRequest(self.chat, self.from_user, time) + c = ChatJoinRequest(self.chat, self.from_user, time, bio='bio') + d = ChatJoinRequest(self.chat, self.from_user, time + datetime.timedelta(1)) + e = ChatJoinRequest(self.chat, User(-1, 'last_name', True), time) + f = User(456, '', False) + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + assert a != f + assert hash(a) != hash(f) + + def test_approve(self, monkeypatch, chat_join_request): + def make_assertion(*_, **kwargs): + chat_id_test = kwargs['chat_id'] == chat_join_request.chat.id + user_id_test = kwargs['user_id'] == chat_join_request.from_user.id + + return chat_id_test and user_id_test + + assert check_shortcut_signature( + ChatJoinRequest.approve, Bot.approve_chat_join_request, ['chat_id', 'user_id'], [] + ) + assert check_shortcut_call( + chat_join_request.approve, chat_join_request.bot, 'approve_chat_join_request' + ) + assert check_defaults_handling(chat_join_request.approve, chat_join_request.bot) + + monkeypatch.setattr(chat_join_request.bot, 'approve_chat_join_request', make_assertion) + assert chat_join_request.approve() + + def test_decline(self, monkeypatch, chat_join_request): + def make_assertion(*_, **kwargs): + chat_id_test = kwargs['chat_id'] == chat_join_request.chat.id + user_id_test = kwargs['user_id'] == chat_join_request.from_user.id + + return chat_id_test and user_id_test + + assert check_shortcut_signature( + ChatJoinRequest.decline, Bot.decline_chat_join_request, ['chat_id', 'user_id'], [] + ) + assert check_shortcut_call( + chat_join_request.decline, chat_join_request.bot, 'decline_chat_join_request' + ) + assert check_defaults_handling(chat_join_request.decline, chat_join_request.bot) + + monkeypatch.setattr(chat_join_request.bot, 'decline_chat_join_request', make_assertion) + assert chat_join_request.decline() diff --git a/tests/test_chatjoinrequesthandler.py b/tests/test_chatjoinrequesthandler.py new file mode 100644 index 000000000..dcd7a7574 --- /dev/null +++ b/tests/test_chatjoinrequesthandler.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2021 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program 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 Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import datetime +from queue import Queue + +import pytest +import pytz + +from telegram import ( + Update, + Bot, + Message, + User, + Chat, + CallbackQuery, + ChosenInlineResult, + ShippingQuery, + PreCheckoutQuery, + ChatJoinRequest, + ChatInviteLink, +) +from telegram.ext import CallbackContext, JobQueue, ChatJoinRequestHandler + + +message = Message(1, None, Chat(1, ''), from_user=User(1, '', False), text='Text') + +params = [ + {'message': message}, + {'edited_message': message}, + {'callback_query': CallbackQuery(1, User(1, '', False), 'chat', message=message)}, + {'channel_post': message}, + {'edited_channel_post': message}, + {'chosen_inline_result': ChosenInlineResult('id', User(1, '', False), '')}, + {'shipping_query': ShippingQuery('id', User(1, '', False), '', None)}, + {'pre_checkout_query': PreCheckoutQuery('id', User(1, '', False), '', 0, '')}, + {'callback_query': CallbackQuery(1, User(1, '', False), 'chat')}, +] + +ids = ( + 'message', + 'edited_message', + 'callback_query', + 'channel_post', + 'edited_channel_post', + 'chosen_inline_result', + 'shipping_query', + 'pre_checkout_query', + 'callback_query_without_message', +) + + +@pytest.fixture(scope='class', params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +@pytest.fixture(scope='class') +def time(): + return datetime.datetime.now(tz=pytz.utc) + + +@pytest.fixture(scope='class') +def chat_join_request(time, bot): + return ChatJoinRequest( + chat=Chat(1, Chat.SUPERGROUP), + from_user=User(2, 'first_name', False), + date=time, + bio='bio', + invite_link=ChatInviteLink( + 'https://invite.link', + User(42, 'creator', False), + name='InviteLink', + is_revoked=False, + is_primary=False, + ), + bot=bot, + ) + + +@pytest.fixture(scope='function') +def chat_join_request_update(bot, chat_join_request): + return Update(0, chat_join_request=chat_join_request) + + +class TestChatJoinRequestHandler: + test_flag = False + + def test_slot_behaviour(self, recwarn, mro_slots): + action = ChatJoinRequestHandler(self.callback_basic) + for attr in action.__slots__: + assert getattr(action, attr, 'err') != 'err', f"got extra slot '{attr}'" + assert not action.__dict__, f"got missing slot(s): {action.__dict__}" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + action.custom = 'should give warning' + assert len(recwarn) == 1 and 'custom' in str(recwarn[0].message), recwarn.list + + @pytest.fixture(autouse=True) + def reset(self): + self.test_flag = False + + def callback_basic(self, bot, update): + test_bot = isinstance(bot, Bot) + test_update = isinstance(update, Update) + self.test_flag = test_bot and test_update + + def callback_data_1(self, bot, update, user_data=None, chat_data=None): + self.test_flag = (user_data is not None) or (chat_data is not None) + + def callback_data_2(self, bot, update, user_data=None, chat_data=None): + self.test_flag = (user_data is not None) and (chat_data is not None) + + def callback_queue_1(self, bot, update, job_queue=None, update_queue=None): + self.test_flag = (job_queue is not None) or (update_queue is not None) + + def callback_queue_2(self, bot, update, job_queue=None, update_queue=None): + self.test_flag = (job_queue is not None) and (update_queue is not None) + + def callback_context(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(context.bot, Bot) + and isinstance(update, Update) + and isinstance(context.update_queue, Queue) + and isinstance(context.job_queue, JobQueue) + and isinstance(context.user_data, dict) + and isinstance(context.chat_data, dict) + and isinstance(context.bot_data, dict) + and isinstance( + update.chat_join_request, + ChatJoinRequest, + ) + ) + + def test_basic(self, dp, chat_join_request_update): + handler = ChatJoinRequestHandler(self.callback_basic) + dp.add_handler(handler) + + assert handler.check_update(chat_join_request_update) + + dp.process_update(chat_join_request_update) + assert self.test_flag + + def test_pass_user_or_chat_data(self, dp, chat_join_request_update): + handler = ChatJoinRequestHandler(self.callback_data_1, pass_user_data=True) + dp.add_handler(handler) + + dp.process_update(chat_join_request_update) + assert self.test_flag + + dp.remove_handler(handler) + handler = ChatJoinRequestHandler(self.callback_data_1, pass_chat_data=True) + dp.add_handler(handler) + + self.test_flag = False + dp.process_update(chat_join_request_update) + assert self.test_flag + + dp.remove_handler(handler) + handler = ChatJoinRequestHandler( + self.callback_data_2, pass_chat_data=True, pass_user_data=True + ) + dp.add_handler(handler) + + self.test_flag = False + dp.process_update(chat_join_request_update) + assert self.test_flag + + def test_pass_job_or_update_queue(self, dp, chat_join_request_update): + handler = ChatJoinRequestHandler(self.callback_queue_1, pass_job_queue=True) + dp.add_handler(handler) + + dp.process_update(chat_join_request_update) + assert self.test_flag + + dp.remove_handler(handler) + handler = ChatJoinRequestHandler(self.callback_queue_1, pass_update_queue=True) + dp.add_handler(handler) + + self.test_flag = False + dp.process_update(chat_join_request_update) + assert self.test_flag + + dp.remove_handler(handler) + handler = ChatJoinRequestHandler( + self.callback_queue_2, pass_job_queue=True, pass_update_queue=True + ) + dp.add_handler(handler) + + self.test_flag = False + dp.process_update(chat_join_request_update) + assert self.test_flag + + def test_other_update_types(self, false_update): + handler = ChatJoinRequestHandler(self.callback_basic) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + def test_context(self, cdp, chat_join_request_update): + handler = ChatJoinRequestHandler(callback=self.callback_context) + cdp.add_handler(handler) + + cdp.process_update(chat_join_request_update) + assert self.test_flag diff --git a/tests/test_update.py b/tests/test_update.py index 2777ff008..12d556623 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -34,6 +34,7 @@ from telegram import ( PollOption, ChatMemberUpdated, ChatMember, + ChatJoinRequest, ) from telegram.poll import PollAnswer from telegram.utils.helpers import from_timestamp @@ -47,6 +48,14 @@ chat_member_updated = ChatMemberUpdated( ChatMember(User(1, '', False), ChatMember.CREATOR), ) + +chat_join_request = ChatJoinRequest( + chat=Chat(1, Chat.SUPERGROUP), + from_user=User(1, 'first_name', False), + date=from_timestamp(int(time.time())), + bio='bio', +) + params = [ {'message': message}, {'edited_message': message}, @@ -57,11 +66,13 @@ params = [ {'chosen_inline_result': ChosenInlineResult('id', User(1, '', False), '')}, {'shipping_query': ShippingQuery('id', User(1, '', False), '', None)}, {'pre_checkout_query': PreCheckoutQuery('id', User(1, '', False), '', 0, '')}, - {'callback_query': CallbackQuery(1, User(1, '', False), 'chat')}, {'poll': Poll('id', '?', [PollOption('.', 1)], False, False, False, Poll.REGULAR, True)}, {'poll_answer': PollAnswer("id", User(1, '', False), [1])}, {'my_chat_member': chat_member_updated}, {'chat_member': chat_member_updated}, + {'chat_join_request': chat_join_request}, + # Must be last to conform with `ids` below! + {'callback_query': CallbackQuery(1, User(1, '', False), 'chat')}, ] all_types = ( @@ -78,6 +89,7 @@ all_types = ( 'poll_answer', 'my_chat_member', 'chat_member', + 'chat_join_request', ) ids = all_types + ('callback_query_without_message',) @@ -171,6 +183,7 @@ class TestUpdate: or update.poll_answer is not None or update.my_chat_member is not None or update.chat_member is not None + or update.chat_join_request is not None ): assert eff_message.message_id == message.message_id else: diff --git a/tests/test_user.py b/tests/test_user.py index 85f75bb8b..dc8019571 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -430,6 +430,40 @@ class TestUser: monkeypatch.setattr(user.bot, 'copy_message', make_assertion) assert user.copy_message(chat_id='chat_id', message_id='message_id') + def test_instance_method_approve_join_request(self, monkeypatch, user): + def make_assertion(*_, **kwargs): + chat_id = kwargs['chat_id'] == 'chat_id' + user_id = kwargs['user_id'] == user.id + return chat_id and user_id + + assert check_shortcut_signature( + User.approve_join_request, Bot.approve_chat_join_request, ['user_id'], [] + ) + assert check_shortcut_call( + user.approve_join_request, user.bot, 'approve_chat_join_request' + ) + assert check_defaults_handling(user.approve_join_request, user.bot) + + monkeypatch.setattr(user.bot, 'approve_chat_join_request', make_assertion) + assert user.approve_join_request(chat_id='chat_id') + + def test_instance_method_decline_join_request(self, monkeypatch, user): + def make_assertion(*_, **kwargs): + chat_id = kwargs['chat_id'] == 'chat_id' + user_id = kwargs['user_id'] == user.id + return chat_id and user_id + + assert check_shortcut_signature( + User.decline_join_request, Bot.decline_chat_join_request, ['user_id'], [] + ) + assert check_shortcut_call( + user.decline_join_request, user.bot, 'decline_chat_join_request' + ) + assert check_defaults_handling(user.decline_join_request, user.bot) + + monkeypatch.setattr(user.bot, 'decline_chat_join_request', make_assertion) + assert user.decline_join_request(chat_id='chat_id') + def test_mention_html(self, user): expected = '{}'