diff --git a/README.rst b/README.rst index b2de996d2..f41e1288f 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-7.8-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-7.9-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API version @@ -81,7 +81,7 @@ After installing_ the library, be sure to check out the section on `working with Telegram API support ~~~~~~~~~~~~~~~~~~~~ -All types and methods of the Telegram Bot API **7.8** are natively supported by this library. +All types and methods of the Telegram Bot API **7.9** are natively supported by this library. In addition, Bot API functionality not yet natively included can still be used as described `in our wiki `_. Notable Features diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 8d3238a27..bb6e4c814 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -130,6 +130,7 @@ Available Types telegram.reactiontype telegram.reactiontypecustomemoji telegram.reactiontypeemoji + telegram.reactiontypepaid telegram.replykeyboardmarkup telegram.replykeyboardremove telegram.replyparameters diff --git a/docs/source/telegram.reactiontypepaid.rst b/docs/source/telegram.reactiontypepaid.rst new file mode 100644 index 000000000..f5035a1ba --- /dev/null +++ b/docs/source/telegram.reactiontypepaid.rst @@ -0,0 +1,6 @@ +ReactionTypePaid +================ + +.. autoclass:: telegram.ReactionTypePaid + :members: + :show-inheritance: diff --git a/telegram/__init__.py b/telegram/__init__.py index 5b52bf85c..7b5803a3e 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -204,6 +204,7 @@ __all__ = ( "ReactionType", "ReactionTypeCustomEmoji", "ReactionTypeEmoji", + "ReactionTypePaid", "RefundedPayment", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", @@ -467,7 +468,13 @@ from ._payment.stars import ( from ._payment.successfulpayment import SuccessfulPayment from ._poll import InputPollOption, Poll, PollAnswer, PollOption from ._proximityalerttriggered import ProximityAlertTriggered -from ._reaction import ReactionCount, ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji +from ._reaction import ( + ReactionCount, + ReactionType, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, + ReactionTypePaid, +) from ._reply import ExternalReplyInfo, ReplyParameters, TextQuote from ._replykeyboardmarkup import ReplyKeyboardMarkup from ._replykeyboardremove import ReplyKeyboardRemove diff --git a/telegram/_bot.py b/telegram/_bot.py index d825a8878..7172344cb 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -18,6 +18,7 @@ # 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 Bot.""" + import asyncio import contextlib import copy @@ -8179,7 +8180,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. ) -> bool: """ Use this method to edit name and icon of a topic in a forum supergroup chat. The bot must - be an administrator in the chat for this to work and must have + be an administrator in the chat for this to work and must have the :paramref:`~telegram.ChatAdministratorRights.can_manage_topics` administrator rights, unless it is the creator of the topic. @@ -8447,7 +8448,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. ) -> bool: """ Use this method to edit the name of the 'General' topic in a forum supergroup chat. The bot - must be an administrator in the chat for this to work and must have + must be an administrator in the chat for this to work and must have the :attr:`~telegram.ChatAdministratorRights.can_manage_topics` administrator rights. .. versionadded:: 20.0 @@ -8946,7 +8947,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. """ Use this method to change the chosen reactions on a message. Service messages can't be reacted to. Automatically forwarded messages from a channel to its discussion group have - the same available reactions as messages in the channel. + the same available reactions as messages in the channel. Bots can't use paid reactions. .. versionadded:: 20.8 @@ -8959,7 +8960,8 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. :class:`telegram.ReactionType` | :obj:`str`, optional): A list of reaction types to set on the message. Currently, as non-premium users, bots can set up to one reaction per message. A custom emoji reaction can be used if it is either - already present on the message or explicitly allowed by chat administrators. + already present on the message or explicitly allowed by chat administrators. Paid + reactions can't be used by bots. Tip: Passed :obj:`str` values will be converted to either @@ -9201,6 +9203,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. protect_content: ODVInput[bool] = DEFAULT_NONE, reply_parameters: Optional["ReplyParameters"] = None, reply_markup: Optional[ReplyMarkup] = None, + business_connection_id: Optional[str] = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, reply_to_message_id: Optional[int] = None, @@ -9210,12 +9213,14 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: Optional[JSONDict] = None, ) -> Message: - """Use this method to send paid media to channel chats. + """Use this method to send paid media. .. versionadded:: 21.4 Args: - chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| If the chat is a channel, all + Telegram Star proceeds from this media will be credited to the chat's balance. + Otherwise, they will be credited to the bot's balance. star_count (:obj:`int`): The number of Telegram Stars that must be paid to buy access to the media. media (Sequence[:class:`telegram.InputPaidMedia`]): A list describing the media to be @@ -9233,6 +9238,9 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: NEXT.VERSION Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -9274,8 +9282,122 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, ) + async def create_chat_subscription_invite_link( + self, + chat_id: Union[str, int], + subscription_period: int, + subscription_price: int, + name: Optional[str] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> ChatInviteLink: + """ + Use this method to create a `subscription invite link `_ for a channel chat. + The bot must have the :attr:`~telegram.ChatPermissions.can_invite_users` administrator + right. The link can be edited using the :meth:`edit_chat_subscription_invite_link` or + revoked using the :meth:`revoke_chat_invite_link`. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + subscription_period (:obj:`int`): The number of seconds the subscription will be + active for before the next payment. Currently, it must always be + :tg-const:`telegram.constants.ChatSubscriptionLimit.SUBSCRIPTION_PERIOD` (30 days). + subscription_price (:obj:`int`): The number of Telegram Stars a user must pay initially + and after each subsequent subscription period to be a member of the chat; + :tg-const:`telegram.constants.ChatSubscriptionLimit.MIN_PRICE`- + :tg-const:`telegram.constants.ChatSubscriptionLimit.MAX_PRICE`. + name (:obj:`str`, optional): Invite link name; + 0-:tg-const:`telegram.constants.ChatInviteLinkLimit.NAME_LENGTH` characters. + + Returns: + :class:`telegram.ChatInviteLink` + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "subscription_period": subscription_period, + "subscription_price": subscription_price, + "name": name, + } + + result = await self._post( + "createChatSubscriptionInviteLink", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + + async def edit_chat_subscription_invite_link( + self, + chat_id: Union[str, int], + invite_link: Union[str, "ChatInviteLink"], + name: Optional[str] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> ChatInviteLink: + """ + Use this method to edit a subscription invite link created by the bot. The bot must have + :attr:`telegram.ChatPermissions.can_invite_users` administrator right. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + invite_link (:obj:`str` | :obj:`telegram.ChatInviteLink`): The invite link to edit. + name (:obj:`str`, optional): Invite link name; + 0-:tg-const:`telegram.constants.ChatInviteLinkLimit.NAME_LENGTH` characters. + + Tip: + Omitting this argument removes the name of the invite link. + + Returns: + :class:`telegram.ChatInviteLink` + + Raises: + :class:`telegram.error.TelegramError` + + """ + link = invite_link.invite_link if isinstance(invite_link, ChatInviteLink) else invite_link + data: JSONDict = { + "chat_id": chat_id, + "invite_link": link, + "name": name, + } + + result = await self._post( + "editChatSubscriptionInviteLink", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -9532,3 +9654,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. """Alias for :meth:`get_star_transactions`""" sendPaidMedia = send_paid_media """Alias for :meth:`send_paid_media`""" + createChatSubscriptionInviteLink = create_chat_subscription_invite_link + """Alias for :meth:`create_chat_subscription_invite_link`""" + editChatSubscriptionInviteLink = edit_chat_subscription_invite_link + """Alias for :meth:`edit_chat_subscription_invite_link`""" diff --git a/telegram/_chat.py b/telegram/_chat.py index 02d80c947..a73a504d8 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -2666,6 +2666,81 @@ class _ChatBase(TelegramObject): api_kwargs=api_kwargs, ) + async def create_subscription_invite_link( + self, + subscription_period: int, + subscription_price: int, + name: Optional[str] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> "ChatInviteLink": + """Shortcut for:: + + await bot.create_chat_subscription_invite_link( + chat_id=update.effective_chat.id, *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.create_chat_subscription_invite_link`. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.ChatInviteLink` + """ + return await self.get_bot().create_chat_subscription_invite_link( + chat_id=self.id, + subscription_period=subscription_period, + subscription_price=subscription_price, + name=name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def edit_subscription_invite_link( + self, + invite_link: Union[str, "ChatInviteLink"], + name: Optional[str] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> "ChatInviteLink": + """Shortcut for:: + + await bot.edit_chat_subscription_invite_link( + chat_id=update.effective_chat.id, *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.edit_chat_subscription_invite_link`. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.ChatInviteLink` + + """ + return await self.get_bot().edit_chat_subscription_invite_link( + chat_id=self.id, + invite_link=invite_link, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + name=name, + ) + async def approve_join_request( self, user_id: int, @@ -3274,6 +3349,7 @@ class _ChatBase(TelegramObject): protect_content: ODVInput[bool] = DEFAULT_NONE, reply_parameters: Optional["ReplyParameters"] = None, reply_markup: Optional[ReplyMarkup] = None, + business_connection_id: Optional[str] = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, reply_to_message_id: Optional[int] = None, @@ -3314,6 +3390,7 @@ class _ChatBase(TelegramObject): connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, ) diff --git a/telegram/_chatinvitelink.py b/telegram/_chatinvitelink.py index 43e7e8ab6..1e1a0e1cf 100644 --- a/telegram/_chatinvitelink.py +++ b/telegram/_chatinvitelink.py @@ -69,6 +69,16 @@ class ChatInviteLink(TelegramObject): created using this link. .. versionadded:: 13.8 + subscription_period (:obj:`int`, optional): The number of seconds the subscription will be + active for before the next payment. + + .. versionadded:: NEXT.VERSION + subscription_price (:obj:`int`, optional): The amount of Telegram Stars a user must pay + initially and after each subsequent subscription period to be a member of the chat + using the link. + + .. versionadded:: NEXT.VERSION + Attributes: invite_link (:obj:`str`): The invite link. If the link was created by another chat administrator, then the second part of the link will be replaced with ``'…'``. @@ -96,6 +106,15 @@ class ChatInviteLink(TelegramObject): created using this link. .. versionadded:: 13.8 + subscription_period (:obj:`int`): Optional. The number of seconds the subscription will be + active for before the next payment. + + .. versionadded:: NEXT.VERSION + subscription_price (:obj:`int`): Optional. The amount of Telegram Stars a user must pay + initially and after each subsequent subscription period to be a member of the chat + using the link. + + .. versionadded:: NEXT.VERSION """ @@ -109,6 +128,8 @@ class ChatInviteLink(TelegramObject): "member_limit", "name", "pending_join_request_count", + "subscription_period", + "subscription_price", ) def __init__( @@ -122,6 +143,8 @@ class ChatInviteLink(TelegramObject): member_limit: Optional[int] = None, name: Optional[str] = None, pending_join_request_count: Optional[int] = None, + subscription_period: Optional[int] = None, + subscription_price: Optional[int] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -140,6 +163,9 @@ class ChatInviteLink(TelegramObject): self.pending_join_request_count: Optional[int] = ( int(pending_join_request_count) if pending_join_request_count is not None else None ) + self.subscription_period: Optional[int] = subscription_period + self.subscription_price: Optional[int] = subscription_price + self._id_attrs = ( self.invite_link, self.creates_join_request, diff --git a/telegram/_chatmember.py b/telegram/_chatmember.py index 0cc06bf58..1eabaa14e 100644 --- a/telegram/_chatmember.py +++ b/telegram/_chatmember.py @@ -17,6 +17,7 @@ # 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 ChatMember.""" + import datetime from typing import TYPE_CHECKING, Dict, Final, Optional, Type @@ -391,24 +392,34 @@ class ChatMemberMember(ChatMember): Args: user (:class:`telegram.User`): Information about the user. + until_date (:class:`datetime.datetime`, optional): Date when the user's subscription will + expire. + + .. versionadded:: NEXT.VERSION Attributes: status (:obj:`str`): The member's status in the chat, always :tg-const:`telegram.ChatMember.MEMBER`. user (:class:`telegram.User`): Information about the user. + until_date (:class:`datetime.datetime`): Optional. Date when the user's subscription will + expire. + + .. versionadded:: NEXT.VERSION """ - __slots__ = () + __slots__ = ("until_date",) def __init__( self, user: User, + until_date: Optional[datetime.datetime] = None, *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(status=ChatMember.MEMBER, user=user, api_kwargs=api_kwargs) - self._freeze() + with self._unfrozen(): + self.until_date: Optional[datetime.datetime] = until_date class ChatMemberRestricted(ChatMember): diff --git a/telegram/_message.py b/telegram/_message.py index 7d077a4d9..5e416ab7a 100644 --- a/telegram/_message.py +++ b/telegram/_message.py @@ -280,15 +280,14 @@ class Message(MaybeInaccessibleMessage): Args: message_id (:obj:`int`): Unique message identifier inside this chat. - from_user (:class:`telegram.User`, optional): Sender of the message; empty for messages - sent to channels. For backward compatibility, this will contain a fake sender user in - non-channel chats, if the message was sent on behalf of a chat. - sender_chat (:class:`telegram.Chat`, optional): Sender of the message, sent on behalf of a - chat. For example, the channel itself for channel posts, the supergroup itself for - messages from anonymous group administrators, the linked channel for messages - automatically forwarded to the discussion group. For backward compatibility, - :attr:`from_user` contains a fake sender user in non-channel chats, if the message was - sent on behalf of a chat. + from_user (:class:`telegram.User`, optional): Sender of the message; may be empty for + messages sent to channels. For backward compatibility, if the message was sent on + behalf of a chat, the field contains a fake sender user in non-channel chats. + sender_chat (:class:`telegram.Chat`, optional): Sender of the message when sent on behalf + of a chat. For example, the supergroup itself for messages sent by its anonymous + administrators or a linked channel for messages automatically forwarded to the + channel's discussion group. For backward compatibility, if the message was sent on + behalf of a chat, the field from contains a fake sender user in non-channel chats. date (:class:`datetime.datetime`): Date the message was sent in Unix time. Converted to :class:`datetime.datetime`. @@ -591,15 +590,14 @@ class Message(MaybeInaccessibleMessage): Attributes: message_id (:obj:`int`): Unique message identifier inside this chat. - from_user (:class:`telegram.User`): Optional. Sender of the message; empty for messages - sent to channels. For backward compatibility, this will contain a fake sender user in - non-channel chats, if the message was sent on behalf of a chat. - sender_chat (:class:`telegram.Chat`): Optional. Sender of the message, sent on behalf of a - chat. For example, the channel itself for channel posts, the supergroup itself for - messages from anonymous group administrators, the linked channel for messages - automatically forwarded to the discussion group. For backward compatibility, - :attr:`from_user` contains a fake sender user in non-channel chats, if the message was - sent on behalf of a chat. + from_user (:class:`telegram.User`): Optional. Sender of the message; may be empty for + messages sent to channels. For backward compatibility, if the message was sent on + behalf of a chat, the field contains a fake sender user in non-channel chats. + sender_chat (:class:`telegram.Chat`): Optional. Sender of the message when sent on behalf + of a chat. For example, the supergroup itself for messages sent by its anonymous + administrators or a linked channel for messages automatically forwarded to the + channel's discussion group. For backward compatibility, if the message was sent on + behalf of a chat, the field from contains a fake sender user in non-channel chats. date (:class:`datetime.datetime`): Date the message was sent in Unix time. Converted to :class:`datetime.datetime`. diff --git a/telegram/_payment/stars.py b/telegram/_payment/stars.py index b176f2315..0baebd39d 100644 --- a/telegram/_payment/stars.py +++ b/telegram/_payment/stars.py @@ -23,6 +23,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Dict, Final, Optional, Sequence, Tuple, Type from telegram import constants +from telegram._paidmedia import PaidMedia from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum @@ -310,20 +311,33 @@ class TransactionPartnerUser(TransactionPartner): Args: user (:class:`telegram.User`): Information about the user. invoice_payload (:obj:`str`, optional): Bot-specified invoice payload. + paid_media (Sequence[:class:`telegram.PaidMedia`], optional): Information about the paid + media bought by the user. + + .. versionadded:: NEXT.VERSION Attributes: type (:obj:`str`): The type of the transaction partner, always :tg-const:`telegram.TransactionPartner.USER`. user (:class:`telegram.User`): Information about the user. invoice_payload (:obj:`str`): Optional. Bot-specified invoice payload. + paid_media (Tuple[:class:`telegram.PaidMedia`]): Optional. Information about the paid + media bought by the user. + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("invoice_payload", "user") + __slots__ = ( + "invoice_payload", + "paid_media", + "user", + ) def __init__( self, user: "User", invoice_payload: Optional[str] = None, + paid_media: Optional[Sequence[PaidMedia]] = None, *, api_kwargs: Optional[JSONDict] = None, ) -> None: @@ -332,6 +346,7 @@ class TransactionPartnerUser(TransactionPartner): with self._unfrozen(): self.user: User = user self.invoice_payload: Optional[str] = invoice_payload + self.paid_media: Optional[Tuple[PaidMedia, ...]] = parse_sequence_arg(paid_media) self._id_attrs = ( self.type, self.user, @@ -347,6 +362,7 @@ class TransactionPartnerUser(TransactionPartner): return None data["user"] = User.de_json(data.get("user"), bot) + data["paid_media"] = PaidMedia.de_list(data.get("paid_media"), bot=bot) return super().de_json(data=data, bot=bot) # type: ignore[return-value] diff --git a/telegram/_reaction.py b/telegram/_reaction.py index d1ba718f0..55e8968e8 100644 --- a/telegram/_reaction.py +++ b/telegram/_reaction.py @@ -17,7 +17,8 @@ # 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 objects that represents a Telegram ReactionType.""" -from typing import TYPE_CHECKING, Final, Literal, Optional, Union + +from typing import TYPE_CHECKING, Dict, Final, Literal, Optional, Type, Union from telegram import constants from telegram._telegramobject import TelegramObject @@ -30,16 +31,22 @@ if TYPE_CHECKING: class ReactionType(TelegramObject): """Base class for Telegram ReactionType Objects. - There exist :class:`telegram.ReactionTypeEmoji` and :class:`telegram.ReactionTypeCustomEmoji`. + There exist :class:`telegram.ReactionTypeEmoji`, :class:`telegram.ReactionTypeCustomEmoji` + and :class:`telegram.ReactionTypePaid`. .. versionadded:: 20.8 + .. versionchanged:: NEXT.VERSION + + Added paid reaction. Args: type (:obj:`str`): Type of the reaction. Can be - :attr:`~telegram.ReactionType.EMOJI` or :attr:`~telegram.ReactionType.CUSTOM_EMOJI`. + :attr:`~telegram.ReactionType.EMOJI`, :attr:`~telegram.ReactionType.CUSTOM_EMOJI` or + :attr:`~telegram.ReactionType.PAID`. Attributes: type (:obj:`str`): Type of the reaction. Can be - :attr:`~telegram.ReactionType.EMOJI` or :attr:`~telegram.ReactionType.CUSTOM_EMOJI`. + :attr:`~telegram.ReactionType.EMOJI`, :attr:`~telegram.ReactionType.CUSTOM_EMOJI` or + :attr:`~telegram.ReactionType.PAID`. """ @@ -49,11 +56,16 @@ class ReactionType(TelegramObject): """:const:`telegram.constants.ReactionType.EMOJI`""" CUSTOM_EMOJI: Final[constants.ReactionType] = constants.ReactionType.CUSTOM_EMOJI """:const:`telegram.constants.ReactionType.CUSTOM_EMOJI`""" + PAID: Final[constants.ReactionType] = constants.ReactionType.PAID + """:const:`telegram.constants.ReactionType.PAID` + + .. versionadded:: NEXT.VERSION + """ def __init__( self, type: Union[ # pylint: disable=redefined-builtin - Literal["emoji", "custom_emoji"], constants.ReactionType + Literal["emoji", "custom_emoji", "paid"], constants.ReactionType ], *, api_kwargs: Optional[JSONDict] = None, @@ -71,14 +83,20 @@ class ReactionType(TelegramObject): """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: + if data is None: return None - if cls is ReactionType and data.get("type") in [cls.EMOJI, cls.CUSTOM_EMOJI]: - reaction_type = data.pop("type") - if reaction_type == cls.EMOJI: - return ReactionTypeEmoji.de_json(data=data, bot=bot) - return ReactionTypeCustomEmoji.de_json(data=data, bot=bot) + if not data and cls is ReactionType: + return None + + _class_mapping: Dict[str, Type[ReactionType]] = { + cls.EMOJI: ReactionTypeEmoji, + cls.CUSTOM_EMOJI: ReactionTypeCustomEmoji, + cls.PAID: ReactionTypePaid, + } + + if cls is ReactionType and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data, bot) return super().de_json(data=data, bot=bot) @@ -152,6 +170,24 @@ class ReactionTypeCustomEmoji(ReactionType): self._id_attrs = (self.custom_emoji_id,) +class ReactionTypePaid(ReactionType): + """ + The reaction is paid. + + .. versionadded:: NEXT.VERSION + + Attributes: + type (:obj:`str`): Type of the reaction, + always :tg-const:`telegram.ReactionType.PAID`. + """ + + __slots__ = () + + def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + super().__init__(type=ReactionType.PAID, api_kwargs=api_kwargs) + self._freeze() + + class ReactionCount(TelegramObject): """This class represents a reaction added to a message along with the number of times it was added. diff --git a/telegram/constants.py b/telegram/constants.py index 1aba1f2a9..2867c6c7c 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -54,6 +54,7 @@ __all__ = [ "ChatLimit", "ChatMemberStatus", "ChatPhotoSize", + "ChatSubscriptionLimit", "ChatType", "ContactLimit", "CustomEmojiStickerLimit", @@ -151,7 +152,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=7, minor=8) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=7, minor=9) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -2903,6 +2904,11 @@ class ReactionType(StringEnum): """:obj:`str`: A :class:`telegram.ReactionType` with a normal emoji.""" CUSTOM_EMOJI = "custom_emoji" """:obj:`str`: A :class:`telegram.ReactionType` with a custom emoji.""" + PAID = "paid" + """:obj:`str`: A :class:`telegram.ReactionType` with a paid reaction. + + .. versionadded:: NEXT.VERSION + """ class ReactionEmoji(StringEnum): @@ -3096,3 +3102,22 @@ class BackgroundFillType(StringEnum): """:obj:`str`: A :class:`telegram.BackgroundFill` with gradient fill.""" FREEFORM_GRADIENT = "freeform_gradient" """:obj:`str`: A :class:`telegram.BackgroundFill` with freeform_gradient fill.""" + + +class ChatSubscriptionLimit(IntEnum): + """This enum contains limitations for + :paramref:`telegram.Bot.create_chat_subscription_invite_link.subscription_period` and + :paramref:`telegram.Bot.create_chat_subscription_invite_link.subscription_price`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + SUBSCRIPTION_PERIOD = 2592000 + """:obj:`int`: The number of seconds the subscription will be active.""" + MIN_PRICE = 1 + """:obj:`int`: Amount of stars a user pays, minimum amount the subscription can be set to.""" + MAX_PRICE = 2500 + """:obj:`int`: Amount of stars a user pays, maximum amount the subscription can be set to.""" diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index d85d8822d..76b17bad0 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -4234,6 +4234,7 @@ class ExtBot(Bot, Generic[RLARGS]): protect_content: ODVInput[bool] = DEFAULT_NONE, reply_parameters: Optional["ReplyParameters"] = None, reply_markup: Optional[ReplyMarkup] = None, + business_connection_id: Optional[str] = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, reply_to_message_id: Optional[int] = None, @@ -4263,6 +4264,57 @@ class ExtBot(Bot, Generic[RLARGS]): connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + business_connection_id=business_connection_id, + ) + + async def create_chat_subscription_invite_link( + self, + chat_id: Union[str, int], + subscription_period: int, + subscription_price: int, + name: Optional[str] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> ChatInviteLink: + return await super().create_chat_subscription_invite_link( + chat_id=chat_id, + subscription_period=subscription_period, + subscription_price=subscription_price, + name=name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_chat_subscription_invite_link( + self, + chat_id: Union[str, int], + invite_link: Union[str, "ChatInviteLink"], + name: Optional[str] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> ChatInviteLink: + return await super().edit_chat_subscription_invite_link( + chat_id=chat_id, + invite_link=invite_link, + name=name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) # updated camelCase aliases @@ -4388,4 +4440,6 @@ class ExtBot(Bot, Generic[RLARGS]): replaceStickerInSet = replace_sticker_in_set refundStarPayment = refund_star_payment getStarTransactions = get_star_transactions + createChatSubscriptionInviteLink = create_chat_subscription_invite_link + editChatSubscriptionInviteLink = edit_chat_subscription_invite_link sendPaidMedia = send_paid_media diff --git a/tests/auxil/ci_bots.py b/tests/auxil/ci_bots.py index bdb25a2f0..069a65cce 100644 --- a/tests/auxil/ci_bots.py +++ b/tests/auxil/ci_bots.py @@ -29,15 +29,16 @@ from telegram._utils.strings import TextEncoding # purposes than testing. FALLBACKS = ( "W3sidG9rZW4iOiAiNTc5Njk0NzE0OkFBRnBLOHc2emtrVXJENHhTZVl3RjNNTzhlLTRHcm1jeTdjIiwgInBheW1lbnRfc" - "HJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpRME5qWmxOekk1WWpKaSIsICJjaGF0X2 lkIjogIjY3NTY2N" - "jIyNCIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTMxMDkxMTEzNSIsICJmb3J1bV9ncm91cF9pZCI6ICItMTAwMTgzOD" - "AwNDU3NyIsICJjaGFubmVsX2lkIjogIkBweXRob250ZWxlZ3JhbWJvdHRlc3RzIi wgIm5hbWUiOiAiUFRCIHRlc3RzIG" - "ZhbGxiYWNrIDEiLCAidXNlcm5hbWUiOiAiQHB0Yl9mYWxsYmFja18xX2JvdCJ9LCB7InRva2VuIjogIjU1ODE5NDA2Njp" - "BQUZ3RFBJRmx6R1VsQ2FXSHRUT0VYNFJGclg4dTlETXFmbyIsIC JwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY" - "4NTA2MzpURVNUOllqRXdPRFF3TVRGbU5EY3kiLCAiY2hhdF9pZCI6ICI2NzU2NjYyMjQiLCAic3VwZXJfZ3JvdXBfaWQi" - "OiAiLTEwMDEyMjEyMTY4MzAiLCAiZm9ydW1fZ3 JvdXBfaWQiOiAiLTEwMDE4NTc4NDgzMTQiLCAiY2hhbm5lbF9pZCI6" - "ICJAcHl0aG9udGVsZWdyYW1ib3R0ZXN0cyIsICJuYW1lIjogIlBUQiB0ZXN0cyBmYWxsYmFjayAyIiwgInVzZXJuYW1lI" - "jogIkBwdGJfZmFsbGJhY2tfMl9ib3QifV0=" + "HJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpRME5qWmxOekk1WWpKaSIsICJjaGF0X2lkIjogIjY3NTY2Nj" + "IyNCIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTMxMDkxMTEzNSIsICJmb3J1bV9ncm91cF9pZCI6ICItMTAwMTgzODA" + "wNDU3NyIsICJjaGFubmVsX2lkIjogIkBweXRob250ZWxlZ3JhbWJvdHRlc3RzIiwgIm5hbWUiOiAiUFRCIHRlc3RzIGZh" + "bGxiYWNrIDEiLCAidXNlcm5hbWUiOiAiQHB0Yl9mYWxsYmFja18xX2JvdCIsICJzdWJzY3JpcHRpb25fY2hhbm5lbF9pZ" + "CI6IC0xMDAyMjI5NjQ5MzAzfSwgeyJ0b2tlbiI6ICI1NTgxOTQwNjY6QUFGd0RQSUZsekdVbENhV0h0VE9FWDRSRnJYOH" + "U5RE1xZm8iLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpZakV3T0RRd01URm1ORGN5Iiw" + "gImNoYXRfaWQiOiAiNjc1NjY2MjI0IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMjIxMjE2ODMwIiwgImZvcnVtX2dy" + "b3VwX2lkIjogIi0xMDAxODU3ODQ4MzE0IiwgImNoYW5uZWxfaWQiOiAiQHB5dGhvbnRlbGVncmFtYm90dGVzdHMiLCAib" + "mFtZSI6ICJQVEIgdGVzdHMgZmFsbGJhY2sgMiIsICJ1c2VybmFtZSI6ICJAcHRiX2ZhbGxiYWNrXzJfYm90IiwgInN1Yn" + "NjcmlwdGlvbl9jaGFubmVsX2lkIjogLTEwMDIyMjk2NDkzMDN9XQ==" ) GITHUB_ACTION = os.getenv("GITHUB_ACTION", None) diff --git a/tests/conftest.py b/tests/conftest.py index dd553f9fe..c721605bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -206,6 +206,11 @@ def provider_token(bot_info): return bot_info["payment_provider_token"] +@pytest.fixture(scope="session") +def subscription_channel_id(bot_info): + return bot_info["subscription_channel_id"] + + @pytest.fixture async def app(bot_info): # We build a new bot each time so that we use `app` in a context manager without problems diff --git a/tests/test_bot.py b/tests/test_bot.py index 6615927ad..705b14c6d 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -2265,6 +2265,21 @@ class TestBotWithoutRequest: obj = await bot.get_star_transactions(offset=3) assert isinstance(obj, StarTransactions) + async def test_create_chat_subscription_invite_link( + self, + monkeypatch, + bot, + ): + # Since the chat invite link object does not say if the sub args are passed we can + # only check here + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("subscription_period") == 2592000 + assert request_data.parameters.get("subscription_price") == 6 + + monkeypatch.setattr(bot.request, "post", make_assertion) + + await bot.create_chat_subscription_invite_link(1234, 2592000, 6) + class TestBotWithRequest: """ @@ -4261,3 +4276,23 @@ class TestBotWithRequest: transactions = await bot.get_star_transactions(limit=1) assert isinstance(transactions, StarTransactions) assert len(transactions.transactions) == 0 + + async def test_create_edit_chat_subscription_link( + self, bot, subscription_channel_id, channel_id + ): + sub_link = await bot.create_chat_subscription_invite_link( + subscription_channel_id, + name="sub_name", + subscription_period=2592000, + subscription_price=13, + ) + assert sub_link.name == "sub_name" + assert sub_link.subscription_period == 2592000 + assert sub_link.subscription_price == 13 + + edited_link = await bot.edit_chat_subscription_invite_link( + chat_id=subscription_channel_id, invite_link=sub_link, name="sub_name_2" + ) + assert edited_link.name == "sub_name_2" + assert sub_link.subscription_period == 2592000 + assert sub_link.subscription_price == 13 diff --git a/tests/test_chat.py b/tests/test_chat.py index 682bdbe51..28905934c 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -889,6 +889,54 @@ class TestChatWithoutRequest(TestChatBase): monkeypatch.setattr(chat.get_bot(), "revoke_chat_invite_link", make_assertion) assert await chat.revoke_invite_link(invite_link=link) + async def test_create_subscription_invite_link(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["subscription_price"] == 42 + and kwargs["subscription_period"] == 42 + ) + + assert check_shortcut_signature( + Chat.create_subscription_invite_link, + Bot.create_chat_subscription_invite_link, + ["chat_id"], + [], + ) + assert await check_shortcut_call( + chat.create_subscription_invite_link, + chat.get_bot(), + "create_chat_subscription_invite_link", + ) + assert await check_defaults_handling(chat.create_subscription_invite_link, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "create_chat_subscription_invite_link", make_assertion) + assert await chat.create_subscription_invite_link( + subscription_price=42, subscription_period=42 + ) + + async def test_edit_subscription_invite_link(self, monkeypatch, chat): + link = "ThisIsALink" + + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id and kwargs["invite_link"] == link + + assert check_shortcut_signature( + Chat.edit_subscription_invite_link, + Bot.edit_chat_subscription_invite_link, + ["chat_id"], + [], + ) + assert await check_shortcut_call( + chat.edit_subscription_invite_link, + chat.get_bot(), + "edit_chat_subscription_invite_link", + ) + assert await check_defaults_handling(chat.edit_subscription_invite_link, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "edit_chat_subscription_invite_link", make_assertion) + assert await chat.edit_subscription_invite_link(invite_link=link) + async def test_instance_method_get_menu_button(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id diff --git a/tests/test_chatinvitelink.py b/tests/test_chatinvitelink.py index 1b95177d9..2a4007986 100644 --- a/tests/test_chatinvitelink.py +++ b/tests/test_chatinvitelink.py @@ -42,6 +42,8 @@ def invite_link(creator): member_limit=TestChatInviteLinkBase.member_limit, name=TestChatInviteLinkBase.name, pending_join_request_count=TestChatInviteLinkBase.pending_join_request_count, + subscription_period=TestChatInviteLinkBase.subscription_period, + subscription_price=TestChatInviteLinkBase.subscription_price, ) @@ -54,6 +56,8 @@ class TestChatInviteLinkBase: member_limit = 42 name = "LinkName" pending_join_request_count = 42 + subscription_period = 43 + subscription_price = 44 class TestChatInviteLinkWithoutRequest(TestChatInviteLinkBase): @@ -91,6 +95,8 @@ class TestChatInviteLinkWithoutRequest(TestChatInviteLinkBase): "member_limit": self.member_limit, "name": self.name, "pending_join_request_count": str(self.pending_join_request_count), + "subscription_period": self.subscription_period, + "subscription_price": self.subscription_price, } invite_link = ChatInviteLink.de_json(json_dict, bot) @@ -106,6 +112,8 @@ class TestChatInviteLinkWithoutRequest(TestChatInviteLinkBase): 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 + assert invite_link.subscription_period == self.subscription_period + assert invite_link.subscription_price == self.subscription_price def test_de_json_localization(self, tz_bot, bot, raw_bot, creator): json_dict = { @@ -146,6 +154,8 @@ class TestChatInviteLinkWithoutRequest(TestChatInviteLinkBase): 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 + assert invite_link_dict["subscription_period"] == self.subscription_period + assert invite_link_dict["subscription_price"] == self.subscription_price def test_equality(self): a = ChatInviteLink("link", User(1, "", False), True, True, True) diff --git a/tests/test_chatmember.py b/tests/test_chatmember.py index b4eac51cf..4296fdd27 100644 --- a/tests/test_chatmember.py +++ b/tests/test_chatmember.py @@ -101,7 +101,7 @@ def chat_member_administrator(): def chat_member_member(): - return ChatMemberMember(CMDefaults.user) + return ChatMemberMember(CMDefaults.user, until_date=CMDefaults.until_date) def chat_member_restricted(): @@ -230,7 +230,9 @@ class TestChatMemberTypesWithoutRequest: def test_de_json_chatmemberbanned_localization(self, chat_member_type, tz_bot, bot, raw_bot): # We only test two classes because the other three don't have datetimes in them. - if isinstance(chat_member_type, (ChatMemberBanned, ChatMemberRestricted)): + if isinstance( + chat_member_type, (ChatMemberBanned, ChatMemberRestricted, ChatMemberMember) + ): json_dict = make_json_dict(chat_member_type, include_optional_args=True) chatmember_raw = ChatMember.de_json(json_dict, raw_bot) chatmember_bot = ChatMember.de_json(json_dict, bot) diff --git a/tests/test_reaction.py b/tests/test_reaction.py index 6f3d3cb46..67ece80e3 100644 --- a/tests/test_reaction.py +++ b/tests/test_reaction.py @@ -28,6 +28,7 @@ from telegram import ( ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji, + ReactionTypePaid, ) from telegram.constants import ReactionEmoji from tests.auxil.slots import mro_slots @@ -48,6 +49,10 @@ def reaction_type_emoji(): return ReactionTypeEmoji(RTDefaults.normal_emoji) +def reaction_type_paid(): + return ReactionTypePaid() + + def make_json_dict(instance: ReactionType, include_optional_args: bool = False) -> dict: """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" json_dict = {"type": instance.type} @@ -99,6 +104,7 @@ def reaction_type(request): [ reaction_type_custom_emoji, reaction_type_emoji, + reaction_type_paid, ], indirect=True, ) @@ -112,6 +118,7 @@ class TestReactionTypesWithoutRequest: def test_de_json_required_args(self, bot, reaction_type): cls = reaction_type.__class__ assert cls.de_json(None, bot) is None + assert ReactionType.de_json({}, bot) is None json_dict = make_json_dict(reaction_type) const_reaction_type = ReactionType.de_json(json_dict, bot) @@ -155,7 +162,7 @@ class TestReactionTypesWithoutRequest: assert reaction_type_dict["type"] == reaction_type.type if reaction_type.type == ReactionType.EMOJI: assert reaction_type_dict["emoji"] == reaction_type.emoji - else: + elif reaction_type.type == ReactionType.CUSTOM_EMOJI: assert reaction_type_dict["custom_emoji_id"] == reaction_type.custom_emoji_id for slot in reaction_type.__slots__: # additional verification for the optional args diff --git a/tests/test_stars.py b/tests/test_stars.py index d3560af7d..10ed7e63b 100644 --- a/tests/test_stars.py +++ b/tests/test_stars.py @@ -24,6 +24,8 @@ import pytest from telegram import ( Dice, + PaidMediaPhoto, + PhotoSize, RevenueWithdrawalState, RevenueWithdrawalStateFailed, RevenueWithdrawalStatePending, @@ -62,6 +64,16 @@ def withdrawal_state_pending(): def transaction_partner_user(): return TransactionPartnerUser( user=User(id=1, is_bot=False, first_name="first_name", username="username"), + invoice_payload="payload", + paid_media=[ + PaidMediaPhoto( + photo=[ + PhotoSize( + file_id="file_id", width=1, height=1, file_unique_id="file_unique_id" + ) + ] + ) + ], )