From ef1685c4362c4161e8466aedfa8e8450e15a1478 Mon Sep 17 00:00:00 2001 From: Bibo-Joshi <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sun, 1 Dec 2024 10:26:48 +0100 Subject: [PATCH] Full Support for Bot API 8.0 (#4566, #4568, #4570, #4571, #4574, #4576, #4572) --- README.rst | 4 +- docs/source/inclusions/bot_methods.rst | 35 +- docs/source/telegram.at-tree.rst | 1 - docs/source/telegram.gift.rst | 6 + docs/source/telegram.gifts.rst | 6 + docs/source/telegram.inline-tree.rst | 1 + .../source/telegram.preparedinlinemessage.rst | 6 + docs/source/telegram.stickers-tree.rst | 3 + docs/substitutions/global.rst | 2 + telegram/__init__.py | 5 + telegram/_bot.py | 304 +++++++++++++++++- telegram/_chat.py | 41 +++ telegram/_gifts.py | 136 ++++++++ telegram/_inline/preparedinlinemessage.py | 83 +++++ telegram/_payment/stars.py | 49 ++- telegram/_payment/successfulpayment.py | 40 +++ telegram/_telegramobject.py | 2 + telegram/_user.py | 38 +++ telegram/constants.py | 25 +- telegram/ext/_callbackdatacache.py | 4 +- telegram/ext/_defaults.py | 4 +- telegram/ext/_extbot.py | 138 +++++++- tests/_inline/test_preparedinlinemessage.py | 108 +++++++ tests/_payment/test_invoice.py | 11 +- tests/_payment/test_successfulpayment.py | 45 +++ tests/test_bot.py | 62 ++++ tests/test_chat.py | 22 ++ tests/test_gifts.py | 267 +++++++++++++++ tests/test_official/arg_type_checker.py | 18 +- tests/test_official/exceptions.py | 4 +- tests/test_stars.py | 32 ++ tests/test_user.py | 22 ++ 32 files changed, 1479 insertions(+), 45 deletions(-) create mode 100644 docs/source/telegram.gift.rst create mode 100644 docs/source/telegram.gifts.rst create mode 100644 docs/source/telegram.preparedinlinemessage.rst create mode 100644 telegram/_gifts.py create mode 100644 telegram/_inline/preparedinlinemessage.py create mode 100644 tests/_inline/test_preparedinlinemessage.py create mode 100644 tests/test_gifts.py diff --git a/README.rst b/README.rst index 7788e075d..772aa7571 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.11-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-8.0-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.11** are natively supported by this library. +All types and methods of the Telegram Bot API **8.0** 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/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 3189de1c1..b8501d8b2 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -25,6 +25,8 @@ - Used for sending documents * - :meth:`~telegram.Bot.send_game` - Used for sending a game + * - :meth:`~telegram.Bot.send_gift` + - Used for sending a gift * - :meth:`~telegram.Bot.send_invoice` - Used for sending an invoice * - :meth:`~telegram.Bot.send_location` @@ -151,6 +153,8 @@ - Used for setting a chat title * - :meth:`~telegram.Bot.set_chat_description` - Used for setting the description of a chat + * - :meth:`~telegram.Bot.set_user_emoji_status` + - Used for setting the users status emoji * - :meth:`~telegram.Bot.pin_chat_message` - Used for pinning a message * - :meth:`~telegram.Bot.unpin_chat_message` @@ -355,7 +359,7 @@ .. raw:: html
- Miscellaneous + Payments and Stars .. list-table:: :align: left @@ -363,18 +367,39 @@ * - :meth:`~telegram.Bot.create_invoice_link` - Used to generate an HTTP link for an invoice + * - :meth:`~telegram.Bot.edit_user_star_subscription` + - Used for editing a user's star subscription + * - :meth:`~telegram.Bot.get_star_transactions` + - Used for obtaining the bot's Telegram Stars transactions + * - :meth:`~telegram.Bot.refund_star_payment` + - Used for refunding a payment in Telegram Stars + +.. raw:: html + +
+
+ +.. raw:: html + +
+ Miscellaneous + +.. list-table:: + :align: left + :widths: 1 4 + * - :meth:`~telegram.Bot.close` - Used for closing server instance when switching to another local server * - :meth:`~telegram.Bot.log_out` - Used for logging out from cloud Bot API server * - :meth:`~telegram.Bot.get_file` - Used for getting basic info about a file + * - :meth:`~telegram.Bot.get_available_gifts` + - Used for getting information about gifts available for sending * - :meth:`~telegram.Bot.get_me` - Used for getting basic information about the bot - * - :meth:`~telegram.Bot.get_star_transactions` - - Used for obtaining the bot's Telegram Stars transactions - * - :meth:`~telegram.Bot.refund_star_payment` - - Used for refunding a payment in Telegram Stars + * - :meth:`~telegram.Bot.save_prepared_inline_message` + - Used for storing a message to be sent by a user of a Mini App .. raw:: html diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index bdeb70159..22abbfb38 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -93,7 +93,6 @@ Available Types telegram.inputpaidmediaphoto telegram.inputpaidmediavideo telegram.inputpolloption - telegram.inputsticker telegram.keyboardbutton telegram.keyboardbuttonpolltype telegram.keyboardbuttonrequestchat diff --git a/docs/source/telegram.gift.rst b/docs/source/telegram.gift.rst new file mode 100644 index 000000000..e42cb720a --- /dev/null +++ b/docs/source/telegram.gift.rst @@ -0,0 +1,6 @@ +Gift +==== + +.. autoclass:: telegram.Gift + :members: + :show-inheritance: diff --git a/docs/source/telegram.gifts.rst b/docs/source/telegram.gifts.rst new file mode 100644 index 000000000..649522d0d --- /dev/null +++ b/docs/source/telegram.gifts.rst @@ -0,0 +1,6 @@ +Gifts +===== + +.. autoclass:: telegram.Gifts + :members: + :show-inheritance: diff --git a/docs/source/telegram.inline-tree.rst b/docs/source/telegram.inline-tree.rst index c187219e0..c21b3c338 100644 --- a/docs/source/telegram.inline-tree.rst +++ b/docs/source/telegram.inline-tree.rst @@ -42,3 +42,4 @@ To enable this option, send the ``/setinline`` command to `@BotFather `__ .. |allow_paid_broadcast| replace:: Pass True to allow up to :tg-const:`telegram.constants.FloodLimit.PAID_MESSAGES_PER_SECOND` messages per second, ignoring `broadcasting limits `__ for a fee of 0.1 Telegram Stars per message. The relevant Stars will be withdrawn from the bot's balance. + +.. |tz-naive-dtms| replace:: For timezone naive :obj:`datetime.datetime` objects, the default timezone of the bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used. \ No newline at end of file diff --git a/telegram/__init__.py b/telegram/__init__.py index a4902d4d8..009a51dcc 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -101,6 +101,8 @@ __all__ = ( "GameHighScore", "GeneralForumTopicHidden", "GeneralForumTopicUnhidden", + "Gift", + "Gifts", "Giveaway", "GiveawayCompleted", "GiveawayCreated", @@ -201,6 +203,7 @@ __all__ = ( "PollAnswer", "PollOption", "PreCheckoutQuery", + "PreparedInlineMessage", "ProximityAlertTriggered", "ReactionCount", "ReactionType", @@ -373,6 +376,7 @@ from ._forumtopic import ( from ._games.callbackgame import CallbackGame from ._games.game import Game from ._games.gamehighscore import GameHighScore +from ._gifts import Gift, Gifts from ._giveaway import Giveaway, GiveawayCompleted, GiveawayCreated, GiveawayWinners from ._inline.inlinekeyboardbutton import InlineKeyboardButton from ._inline.inlinekeyboardmarkup import InlineKeyboardMarkup @@ -405,6 +409,7 @@ from ._inline.inputlocationmessagecontent import InputLocationMessageContent from ._inline.inputmessagecontent import InputMessageContent from ._inline.inputtextmessagecontent import InputTextMessageContent from ._inline.inputvenuemessagecontent import InputVenueMessageContent +from ._inline.preparedinlinemessage import PreparedInlineMessage from ._keyboardbutton import KeyboardButton from ._keyboardbuttonpolltype import KeyboardButtonPollType from ._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUsers diff --git a/telegram/_bot.py b/telegram/_bot.py index cc2ba38fb..338c2843b 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -24,7 +24,7 @@ import contextlib import copy import pickle from collections.abc import Sequence -from datetime import datetime +from datetime import datetime, timedelta from types import TracebackType from typing import ( TYPE_CHECKING, @@ -75,7 +75,9 @@ from telegram._files.videonote import VideoNote from telegram._files.voice import Voice from telegram._forumtopic import ForumTopic from telegram._games.gamehighscore import GameHighScore +from telegram._gifts import Gift, Gifts from telegram._inline.inlinequeryresultsbutton import InlineQueryResultsButton +from telegram._inline.preparedinlinemessage import PreparedInlineMessage from telegram._menubutton import MenuButton from telegram._message import Message from telegram._messageid import MessageId @@ -3641,6 +3643,65 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): api_kwargs=api_kwargs, ) + async def save_prepared_inline_message( + self, + user_id: int, + result: "InlineQueryResult", + allow_user_chats: Optional[bool] = None, + allow_bot_chats: Optional[bool] = None, + allow_group_chats: Optional[bool] = None, + allow_channel_chats: Optional[bool] = 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, + ) -> PreparedInlineMessage: + """Stores a message that can be sent by a user of a Mini App. + + .. versionadded:: NEXT.VERSION + + Args: + user_id (:obj:`int`): Unique identifier of the target user that can use the prepared + message. + result (:class:`telegram.InlineQueryResult`): The result to store. + allow_user_chats (:obj:`bool`, optional): Pass :obj:`True` if the message can be sent + to private chats with users + allow_bot_chats (:obj:`bool`, optional): Pass :obj:`True` if the message can be sent + to private chats with bots + allow_group_chats (:obj:`bool`, optional): Pass :obj:`True` if the message can be sent + to group and supergroup chats + allow_channel_chats (:obj:`bool`, optional): Pass :obj:`True` if the message can be + sent to channels + + Returns: + :class:`telegram.PreparedInlineMessage`: On success, the prepared message is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "result": result, + "allow_user_chats": allow_user_chats, + "allow_bot_chats": allow_bot_chats, + "allow_group_chats": allow_group_chats, + "allow_channel_chats": allow_channel_chats, + } + return PreparedInlineMessage.de_json( # type: ignore[return-value] + await self._post( + "savePreparedInlineMessage", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + self, + ) + async def get_user_profile_photos( self, user_id: int, @@ -3779,9 +3840,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): be unbanned, unix time. If user is banned for more than 366 days or less than 30 seconds from the current time they are considered to be banned forever. Applied for supergroups and channels only. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| revoke_messages (:obj:`bool`, optional): Pass :obj:`True` to delete all messages from the chat for the user that is being removed. If :obj:`False`, the user will be able to see messages in the group that were sent before the user was removed. @@ -5415,9 +5474,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): will be lifted for the user, unix time. If user is restricted for more than 366 days or less than 30 seconds from the current time, they are considered to be restricted forever. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| permissions (:class:`telegram.ChatPermissions`): An object for new user permissions. use_independent_chat_permissions (:obj:`bool`, optional): Pass :obj:`True` if chat @@ -5761,9 +5818,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| expire_date (:obj:`int` | :obj:`datetime.datetime`, optional): Date when the link will expire. Integer input will be interpreted as Unix timestamp. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| 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; :tg-const:`telegram.constants.ChatInviteLinkLimit.MIN_MEMBER_LIMIT`- @@ -5840,9 +5895,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): Now also accepts :class:`telegram.ChatInviteLink` instances. expire_date (:obj:`int` | :obj:`datetime.datetime`, optional): Date when the link will expire. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| 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; :tg-const:`telegram.constants.ChatInviteLinkLimit.MIN_MEMBER_LIMIT`- @@ -6176,6 +6229,56 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): api_kwargs=api_kwargs, ) + async def set_user_emoji_status( + self, + user_id: int, + emoji_status_custom_emoji_id: Optional[str] = None, + emoji_status_expiration_date: Optional[Union[int, datetime]] = 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, + ) -> bool: + """Changes the emoji status for a given user that previously allowed the bot to manage + their emoji status via the Mini App method + `requestEmojiStatusAccess `_ + . + + .. versionadded:: NEXT.VERSION + + Args: + user_id (:obj:`int`): Unique identifier of the target user + emoji_status_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of the + emoji status to set. Pass an empty string to remove the status. + emoji_status_expiration_date (Union[:obj:`int`, :obj:`datetime.datetime`], optional): + Expiration date of the emoji status, if any, as unix timestamp or + :class:`datetime.datetime` object. + |tz-naive-dtms| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "user_id": user_id, + "emoji_status_custom_emoji_id": emoji_status_custom_emoji_id, + "emoji_status_expiration_date": emoji_status_expiration_date, + } + return await self._post( + "setUserEmojiStatus", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def pin_chat_message( self, chat_id: Union[str, int], @@ -7127,9 +7230,7 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. :tg-const:`telegram.Poll.MIN_OPEN_PERIOD` and no more than :tg-const:`telegram.Poll.MAX_OPEN_PERIOD` seconds in the future. Can't be used together with :paramref:`open_period`. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| is_closed (:obj:`bool`, optional): Pass :obj:`True`, if the poll needs to be immediately closed. This can be useful for poll preview. disable_notification (:obj:`bool`, optional): |disable_notification| @@ -8024,6 +8125,8 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. send_phone_number_to_provider: Optional[bool] = None, send_email_to_provider: Optional[bool] = None, is_flexible: Optional[bool] = None, + subscription_period: Optional[Union[int, timedelta]] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -8036,6 +8139,10 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. .. versionadded:: 20.0 Args: + business_connection_id (:obj:`str`, optional): |business_id_str| + For payments in |tg_stars| only. + + .. versionadded:: NEXT.VERSION title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`- :tg-const:`telegram.Invoice.MAX_TITLE_LENGTH` characters. description (:obj:`str`): Product description. @@ -8062,6 +8169,15 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. .. versionchanged:: 20.0 |sequenceargs| + subscription_period (:obj:`int` | :class:`datetime.timedelta`, optional): The time the + subscription will be active for before the next payment, either as number of + seconds or as :class:`datetime.timedelta` object. The currency must be set to + ``“XTR”`` (Telegram Stars) if the parameter is used. Currently, it must always be + :tg-const:`telegram.constants.InvoiceLimit.SUBSCRIPTION_PERIOD` if specified. Any + number of subscriptions can be active for a given bot at the same time, including + multiple concurrent subscriptions from the same user. + + .. versionadded:: NEXT.VERSION max_tip_amount (:obj:`int`, optional): The maximum accepted amount for tips in the *smallest units* of the currency (integer, **not** float/double). For example, for a maximum tip of ``US$ 1.45`` pass ``max_tip_amount = 145``. See the ``exp`` @@ -8127,6 +8243,12 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. "is_flexible": is_flexible, "send_phone_number_to_provider": send_phone_number_to_provider, "send_email_to_provider": send_email_to_provider, + "subscription_period": ( + subscription_period.total_seconds() + if isinstance(subscription_period, timedelta) + else subscription_period + ), + "business_connection_id": business_connection_id, } return await self._post( @@ -9254,6 +9376,53 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. bot=self, ) + async def edit_user_star_subscription( + self, + user_id: int, + telegram_payment_charge_id: str, + is_canceled: bool, + *, + 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, + ) -> bool: + """Allows the bot to cancel or re-enable extension of a subscription paid in Telegram + Stars. + + .. versionadded:: NEXT.VERSION + + Args: + user_id (:obj:`int`): Identifier of the user whose subscription will be edited. + telegram_payment_charge_id (:obj:`str`): Telegram payment identifier for the + subscription. + is_canceled (:obj:`bool`): Pass :obj:`True` to cancel extension of the user + subscription; the subscription must be active up to the end of the current + subscription period. Pass :obj:`False` to allow the user to re-enable a + subscription that was previously canceled by the bot. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "telegram_payment_charge_id": telegram_payment_charge_id, + "is_canceled": is_canceled, + } + return await self._post( + "editUserStartSubscription", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def send_paid_media( self, chat_id: Union[str, int], @@ -9475,6 +9644,99 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + async def get_available_gifts( + self, + *, + 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, + ) -> Gifts: + """Returns the list of gifts that can be sent by the bot to users. + Requires no parameters. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.Gifts` + + Raises: + :class:`telegram.error.TelegramError` + """ + return Gifts.de_json( # type: ignore[return-value] + await self._post( + "getAvailableGifts", + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def send_gift( + self, + user_id: int, + gift_id: Union[str, Gift], + text: Optional[str] = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Optional[Sequence["MessageEntity"]] = 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, + ) -> bool: + """Sends a gift to the given user. + The gift can't be converted to Telegram Stars by the user + + .. versionadded:: NEXT.VERSION + + Args: + user_id (:obj:`int`): Unique identifier of the target user that will receive the gift + gift_id (:obj:`str` | :class:`~telegram.Gift`): Identifier of the gift or a + :class:`~telegram.Gift` object + text (:obj:`str`, optional): Text that will be shown along with the gift; + 0- :tg-const:`telegram.constants.GiftLimit.MAX_TEXT_LENGTH` characters + text_parse_mode (:obj:`str`, optional): Mode for parsing entities. + See :class:`telegram.constants.ParseMode` and + `formatting options `__ for + more details. Entities other than :attr:`~MessageEntity.BOLD`, + :attr:`~MessageEntity.ITALIC`, :attr:`~MessageEntity.UNDERLINE`, + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, and + :attr:`~MessageEntity.CUSTOM_EMOJI` are ignored. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): A list of special + entities that appear in the gift text. It can be specified instead of + :paramref:`text_parse_mode`. Entities other than :attr:`~MessageEntity.BOLD`, + :attr:`~MessageEntity.ITALIC`, :attr:`~MessageEntity.UNDERLINE`, + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, and + :attr:`~MessageEntity.CUSTOM_EMOJI` are ignored. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "gift_id": gift_id.id if isinstance(gift_id, Gift) else gift_id, + "text": text, + "text_parse_mode": text_parse_mode, + "text_entities": text_entities, + } + return await self._post( + "sendGift", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + 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} @@ -9531,6 +9793,8 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. """Alias for :meth:`send_chat_action`""" answerInlineQuery = answer_inline_query """Alias for :meth:`answer_inline_query`""" + savePreparedInlineMessage = save_prepared_inline_message + """Alias for :meth:`save_prepared_inline_message`""" getUserProfilePhotos = get_user_profile_photos """Alias for :meth:`get_user_profile_photos`""" getFile = get_file @@ -9615,6 +9879,8 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. """Alias for :meth:`set_chat_title`""" setChatDescription = set_chat_description """Alias for :meth:`set_chat_description`""" + setUserEmojiStatus = set_user_emoji_status + """Alias for :meth:`set_user_emoji_status`""" pinChatMessage = pin_chat_message """Alias for :meth:`pin_chat_message`""" unpinChatMessage = unpin_chat_message @@ -9729,9 +9995,15 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. """Alias for :meth:`refund_star_payment`""" getStarTransactions = get_star_transactions """Alias for :meth:`get_star_transactions`""" + editUserStarSubscription = edit_user_star_subscription + """Alias for :meth:`edit_user_star_subscription`""" 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`""" + getAvailableGifts = get_available_gifts + """Alias for :meth:`get_available_gifts`""" + sendGift = send_gift + """Alias for :meth:`send_gift`""" diff --git a/telegram/_chat.py b/telegram/_chat.py index bb0e24b1d..08321fe25 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -44,6 +44,7 @@ if TYPE_CHECKING: ChatMember, Contact, Document, + Gift, InlineKeyboardMarkup, InputMediaAudio, InputMediaDocument, @@ -3436,6 +3437,46 @@ class _ChatBase(TelegramObject): allow_paid_broadcast=allow_paid_broadcast, ) + async def send_gift( + self, + gift_id: Union[str, "Gift"], + text: Optional[str] = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Optional[Sequence["MessageEntity"]] = 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, + ) -> bool: + """Shortcut for:: + + await bot.send_gift(user_id=update.effective_chat.id, *args, **kwargs ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_gift`. + + Caution: + Can only work, if the chat is a private chat, see :attr:`type`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().send_gift( + user_id=self.id, + gift_id=gift_id, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + class Chat(_ChatBase): """This object represents a chat. diff --git a/telegram/_gifts.py b/telegram/_gifts.py new file mode 100644 index 000000000..63055974c --- /dev/null +++ b/telegram/_gifts.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +# pylint: disable=redefined-builtin +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 classes related to gifs sent by bots.""" +from collections.abc import Sequence +from typing import TYPE_CHECKING, Optional + +from telegram._files.sticker import Sticker +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class Gift(TelegramObject): + """This object represents a gift that can be sent by the bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`id` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + id (:obj:`str`): Unique identifier of the gift + sticker (:class:`~telegram.Sticker`): The sticker that represents the gift + star_count (:obj:`int`): The number of Telegram Stars that must be paid to send the sticker + total_count (:obj:`int`, optional): The total number of the gifts of this type that can be + sent; for limited gifts only + remaining_count (:obj:`int`, optional): The number of remaining gifts of this type that can + be sent; for limited gifts only + + Attributes: + id (:obj:`str`): Unique identifier of the gift + sticker (:class:`~telegram.Sticker`): The sticker that represents the gift + star_count (:obj:`int`): The number of Telegram Stars that must be paid to send the sticker + total_count (:obj:`int`): Optional. The total number of the gifts of this type that can be + sent; for limited gifts only + remaining_count (:obj:`int`): Optional. The number of remaining gifts of this type that can + be sent; for limited gifts only + + """ + + __slots__ = ("id", "remaining_count", "star_count", "sticker", "total_count") + + def __init__( + self, + id: str, + sticker: Sticker, + star_count: int, + total_count: Optional[int] = None, + remaining_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.sticker: Sticker = sticker + self.star_count: int = star_count + self.total_count: Optional[int] = total_count + self.remaining_count: Optional[int] = remaining_count + + self._id_attrs = (self.id,) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Gift"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["sticker"] = Sticker.de_json(data.get("sticker"), bot) + return cls(**data) + + +class Gifts(TelegramObject): + """This object represent a list of gifts. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`gifts` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + gifts (Sequence[:class:`Gift`]): The sequence of gifts + + Attributes: + gifts (tuple[:class:`Gift`]): The sequence of gifts + + """ + + __slots__ = ("gifts",) + + def __init__( + self, + gifts: Sequence[Gift], + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.gifts: tuple[Gift, ...] = parse_sequence_arg(gifts) + + self._id_attrs = (self.gifts,) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optional["Gifts"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["gifts"] = Gift.de_list(data.get("gifts"), bot) + return cls(**data) diff --git a/telegram/_inline/preparedinlinemessage.py b/telegram/_inline/preparedinlinemessage.py new file mode 100644 index 000000000..cc4078d2d --- /dev/null +++ b/telegram/_inline/preparedinlinemessage.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 Prepared inline Message.""" +import datetime as dtm +from typing import TYPE_CHECKING, Optional + +from telegram._telegramobject import TelegramObject +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class PreparedInlineMessage(TelegramObject): + """Describes an inline message to be sent by a user of a Mini App. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`id` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + id (:obj:`str`): Unique identifier of the prepared message + expiration_date (:class:`datetime.datetime`): Expiration date of the prepared message. + Expired prepared messages can no longer be used. + |datetime_localization| + + Attributes: + id (:obj:`str`): Unique identifier of the prepared message + expiration_date (:class:`datetime.datetime`): Expiration date of the prepared message. + Expired prepared messages can no longer be used. + |datetime_localization| + """ + + __slots__ = ("expiration_date", "id") + + def __init__( + self, + id: str, # pylint: disable=redefined-builtin + expiration_date: dtm.datetime, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.expiration_date: dtm.datetime = expiration_date + + self._id_attrs = (self.id,) + + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PreparedInlineMessage"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if data is None: + return None + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["expiration_date"] = from_timestamp(data.get("expiration_date"), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_payment/stars.py b/telegram/_payment/stars.py index a47d3b44f..e6c685497 100644 --- a/telegram/_payment/stars.py +++ b/telegram/_payment/stars.py @@ -19,11 +19,12 @@ # pylint: disable=redefined-builtin """This module contains the classes for Telegram Stars transactions.""" +import datetime as dtm from collections.abc import Sequence -from datetime import datetime from typing import TYPE_CHECKING, Final, Optional from telegram import constants +from telegram._gifts import Gift from telegram._paidmedia import PaidMedia from telegram._telegramobject import TelegramObject from telegram._user import User @@ -144,7 +145,7 @@ class RevenueWithdrawalStateSucceeded(RevenueWithdrawalState): def __init__( self, - date: datetime, + date: dtm.datetime, url: str, *, api_kwargs: Optional[JSONDict] = None, @@ -152,7 +153,7 @@ class RevenueWithdrawalStateSucceeded(RevenueWithdrawalState): super().__init__(type=RevenueWithdrawalState.SUCCEEDED, api_kwargs=api_kwargs) with self._unfrozen(): - self.date: datetime = date + self.date: dtm.datetime = date self.url: str = url self._id_attrs = ( self.type, @@ -328,30 +329,51 @@ class TransactionPartnerUser(TransactionPartner): Args: user (:class:`telegram.User`): Information about the user. invoice_payload (:obj:`str`, optional): Bot-specified invoice payload. + subscription_period (:class:`datetime.timedelta`, optional): The duration of the paid + subscription + + .. versionadded:: NEXT.VERSION paid_media (Sequence[:class:`telegram.PaidMedia`], optional): Information about the paid media bought by the user. .. versionadded:: 21.5 - paid_media_payload (:obj:`str`, optional): Optional. Bot-specified paid media payload. + paid_media_payload (:obj:`str`, optional): Bot-specified paid media payload. .. versionadded:: 21.6 + gift (:class:`telegram.Gift`, optional): The gift sent to the user by the bot + + .. 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. + subscription_period (:class:`datetime.timedelta`): Optional. The duration of the paid + subscription + + .. versionadded:: NEXT.VERSION paid_media (tuple[:class:`telegram.PaidMedia`]): Optional. Information about the paid media bought by the user. .. versionadded:: 21.5 - paid_media_payload (:obj:`str`): Optional. Optional. Bot-specified paid media payload. + paid_media_payload (:obj:`str`): Optional. Bot-specified paid media payload. .. versionadded:: 21.6 + gift (:class:`telegram.Gift`): Optional. The gift sent to the user by the bot + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("invoice_payload", "paid_media", "paid_media_payload", "user") + __slots__ = ( + "gift", + "invoice_payload", + "paid_media", + "paid_media_payload", + "subscription_period", + "user", + ) def __init__( self, @@ -359,6 +381,8 @@ class TransactionPartnerUser(TransactionPartner): invoice_payload: Optional[str] = None, paid_media: Optional[Sequence[PaidMedia]] = None, paid_media_payload: Optional[str] = None, + subscription_period: Optional[dtm.timedelta] = None, + gift: Optional[Gift] = None, *, api_kwargs: Optional[JSONDict] = None, ) -> None: @@ -369,6 +393,9 @@ class TransactionPartnerUser(TransactionPartner): self.invoice_payload: Optional[str] = invoice_payload self.paid_media: Optional[tuple[PaidMedia, ...]] = parse_sequence_arg(paid_media) self.paid_media_payload: Optional[str] = paid_media_payload + self.subscription_period: Optional[dtm.timedelta] = subscription_period + self.gift: Optional[Gift] = gift + self._id_attrs = ( self.type, self.user, @@ -386,6 +413,12 @@ class TransactionPartnerUser(TransactionPartner): data["user"] = User.de_json(data.get("user"), bot) data["paid_media"] = PaidMedia.de_list(data.get("paid_media"), bot=bot) + data["subscription_period"] = ( + dtm.timedelta(seconds=sp) + if (sp := data.get("subscription_period")) is not None + else None + ) + data["gift"] = Gift.de_json(data.get("gift"), bot) return super().de_json(data=data, bot=bot) # type: ignore[return-value] @@ -496,7 +529,7 @@ class StarTransaction(TelegramObject): self, id: str, amount: int, - date: datetime, + date: dtm.datetime, source: Optional[TransactionPartner] = None, receiver: Optional[TransactionPartner] = None, *, @@ -505,7 +538,7 @@ class StarTransaction(TelegramObject): super().__init__(api_kwargs=api_kwargs) self.id: str = id self.amount: int = amount - self.date: datetime = date + self.date: dtm.datetime = date self.source: Optional[TransactionPartner] = source self.receiver: Optional[TransactionPartner] = receiver diff --git a/telegram/_payment/successfulpayment.py b/telegram/_payment/successfulpayment.py index 34bce2914..434cecfcf 100644 --- a/telegram/_payment/successfulpayment.py +++ b/telegram/_payment/successfulpayment.py @@ -18,10 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram SuccessfulPayment.""" +import datetime as dtm from typing import TYPE_CHECKING, Optional from telegram._payment.orderinfo import OrderInfo from telegram._telegramobject import TelegramObject +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -45,6 +47,17 @@ class SuccessfulPayment(TelegramObject): it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). invoice_payload (:obj:`str`): Bot-specified invoice payload. + subscription_expiration_date (:class:`datetime.datetime`, optional): Expiration date of the + subscription; for recurring payments only. + + .. versionadded:: NEXT.VERSION + is_recurring (:obj:`bool`, optional): True, if the payment is for a subscription. + + .. versionadded:: NEXT.VERSION + is_first_recurring (:obj:`bool`, optional): True, if the payment is the first payment of a + subscription. + + .. versionadded:: NEXT.VERSION shipping_option_id (:obj:`str`, optional): Identifier of the shipping option chosen by the user. order_info (:class:`telegram.OrderInfo`, optional): Order info provided by the user. @@ -61,6 +74,17 @@ class SuccessfulPayment(TelegramObject): it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). invoice_payload (:obj:`str`): Bot-specified invoice payload. + subscription_expiration_date (:class:`datetime.datetime`): Optional. Expiration + date of the subscription; for recurring payments only. + + .. versionadded:: NEXT.VERSION + is_recurring (:obj:`bool`): Optional. True, if the payment is for a subscription. + + .. versionadded:: NEXT.VERSION + is_first_recurring (:obj:`bool`): Optional. True, if the payment is the first payment of a + subscription. + + .. versionadded:: NEXT.VERSION shipping_option_id (:obj:`str`): Optional. Identifier of the shipping option chosen by the user. order_info (:class:`telegram.OrderInfo`): Optional. Order info provided by the user. @@ -72,9 +96,12 @@ class SuccessfulPayment(TelegramObject): __slots__ = ( "currency", "invoice_payload", + "is_first_recurring", + "is_recurring", "order_info", "provider_payment_charge_id", "shipping_option_id", + "subscription_expiration_date", "telegram_payment_charge_id", "total_amount", ) @@ -88,6 +115,9 @@ class SuccessfulPayment(TelegramObject): provider_payment_charge_id: str, shipping_option_id: Optional[str] = None, order_info: Optional[OrderInfo] = None, + subscription_expiration_date: Optional[dtm.datetime] = None, + is_recurring: Optional[bool] = None, + is_first_recurring: Optional[bool] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -99,6 +129,9 @@ class SuccessfulPayment(TelegramObject): self.order_info: Optional[OrderInfo] = order_info self.telegram_payment_charge_id: str = telegram_payment_charge_id self.provider_payment_charge_id: str = provider_payment_charge_id + self.subscription_expiration_date: Optional[dtm.datetime] = subscription_expiration_date + self.is_recurring: Optional[bool] = is_recurring + self.is_first_recurring: Optional[bool] = is_first_recurring self._id_attrs = (self.telegram_payment_charge_id, self.provider_payment_charge_id) @@ -116,4 +149,11 @@ class SuccessfulPayment(TelegramObject): data["order_info"] = OrderInfo.de_json(data.get("order_info"), bot) + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["subscription_expiration_date"] = from_timestamp( + data.get("subscription_expiration_date"), tzinfo=loc_tzinfo + ) + return super().de_json(data=data, bot=bot) diff --git a/telegram/_telegramobject.py b/telegram/_telegramobject.py index 6f5038da4..1b29095e8 100644 --- a/telegram/_telegramobject.py +++ b/telegram/_telegramobject.py @@ -636,6 +636,8 @@ class TelegramObject: elif isinstance(value, datetime.datetime): out[key] = to_timestamp(value) + elif isinstance(value, datetime.timedelta): + out[key] = value.total_seconds() for key in pop_keys: out.pop(key) diff --git a/telegram/_user.py b/telegram/_user.py index 980ce7e49..2a4b4dc7a 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: Audio, Contact, Document, + Gift, InlineKeyboardMarkup, InputMediaAudio, InputMediaDocument, @@ -1646,6 +1647,43 @@ class User(TelegramObject): allow_paid_broadcast=allow_paid_broadcast, ) + async def send_gift( + self, + gift_id: Union[str, "Gift"], + text: Optional[str] = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Optional[Sequence["MessageEntity"]] = 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, + ) -> bool: + """Shortcut for:: + + await bot.send_gift( user_id=update.effective_user.id, *args, **kwargs ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_gift`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().send_gift( + user_id=self.id, + gift_id=gift_id, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def send_copy( self, from_chat_id: Union[str, int], diff --git a/telegram/constants.py b/telegram/constants.py index 52a47d54c..b3d17c4ff 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -64,6 +64,7 @@ __all__ = [ "FloodLimit", "ForumIconColor", "ForumTopicLimit", + "GiftLimit", "GiveawayLimit", "InlineKeyboardButtonLimit", "InlineKeyboardMarkupLimit", @@ -152,7 +153,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=7, minor=11) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=8, minor=0) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -1224,6 +1225,21 @@ class ForumIconColor(IntEnum): """ +class GiftLimit(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.send_gift`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + MAX_TEXT_LENGTH = 255 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.send_gift.text` parameter of :meth:`~telegram.Bot.send_gift`. + """ + + class GiveawayLimit(IntEnum): """This enum contains limitations for :class:`telegram.Giveaway` and related classes. The enum members of this enumeration are instances of :class:`int` and can be treated as such. @@ -2902,6 +2918,13 @@ class InvoiceLimit(IntEnum): .. versionadded:: 21.6 """ + SUBSCRIPTION_PERIOD = datetime.timedelta(days=30).total_seconds() + """:obj:`int`: The period of time for which the subscription is active before + the next payment, passed as :paramref:`~telegram.Bot.create_invoice_link.subscription_period` + parameter of :meth:`telegram.Bot.create_invoice_link`. + + .. versionadded:: NEXT.VERSION + """ class UserProfilePhotosLimit(IntEnum): diff --git a/telegram/ext/_callbackdatacache.py b/telegram/ext/_callbackdatacache.py index 97649d2eb..058a53e20 100644 --- a/telegram/ext/_callbackdatacache.py +++ b/telegram/ext/_callbackdatacache.py @@ -437,9 +437,7 @@ class CallbackDataCache: Args: time_cutoff (:obj:`float` | :obj:`datetime.datetime`, optional): Pass a UNIX timestamp or a :obj:`datetime.datetime` to clear only entries which are older. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| """ self.__clear(self._keyboard_data, time_cutoff=time_cutoff) diff --git a/telegram/ext/_defaults.py b/telegram/ext/_defaults.py index 93e7e3748..031914622 100644 --- a/telegram/ext/_defaults.py +++ b/telegram/ext/_defaults.py @@ -188,6 +188,7 @@ class Defaults: "explanation_parse_mode", "link_preview_options", "parse_mode", + "text_parse_mode", "protect_content", "question_parse_mode", ): @@ -271,7 +272,8 @@ class Defaults: @property def text_parse_mode(self) -> Optional[str]: """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for - the corresponding parameter of :class:`telegram.InputPollOption`. + the corresponding parameter of :class:`telegram.InputPollOption` and + :meth:`telegram.Bot.send_gift`. .. versionadded:: 21.2 """ diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 66921e415..d2d2881e8 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -20,7 +20,7 @@ """This module contains an object that represents a Telegram Bot with convenience extensions.""" from collections.abc import Sequence from copy import copy -from datetime import datetime +from datetime import datetime, timedelta from typing import ( TYPE_CHECKING, Any, @@ -57,6 +57,8 @@ from telegram import ( File, ForumTopic, GameHighScore, + Gift, + Gifts, InlineKeyboardMarkup, InlineQueryResultsButton, InputMedia, @@ -69,6 +71,7 @@ from telegram import ( MessageId, PhotoSize, Poll, + PreparedInlineMessage, ReactionType, ReplyParameters, SentWebAppMessage, @@ -979,6 +982,36 @@ class ExtBot(Bot, Generic[RLARGS]): api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def save_prepared_inline_message( + self, + user_id: int, + result: "InlineQueryResult", + allow_user_chats: Optional[bool] = None, + allow_bot_chats: Optional[bool] = None, + allow_group_chats: Optional[bool] = None, + allow_channel_chats: Optional[bool] = 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, + ) -> PreparedInlineMessage: + return await super().save_prepared_inline_message( + user_id=user_id, + result=result, + allow_user_chats=allow_user_chats, + allow_bot_chats=allow_bot_chats, + allow_group_chats=allow_group_chats, + allow_channel_chats=allow_channel_chats, + 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 answer_pre_checkout_query( self, pre_checkout_query_id: str, @@ -1171,6 +1204,8 @@ class ExtBot(Bot, Generic[RLARGS]): send_phone_number_to_provider: Optional[bool] = None, send_email_to_provider: Optional[bool] = None, is_flexible: Optional[bool] = None, + subscription_period: Optional[Union[int, timedelta]] = None, + business_connection_id: Optional[str] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1204,6 +1239,8 @@ class ExtBot(Bot, Generic[RLARGS]): write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, + subscription_period=subscription_period, + business_connection_id=business_connection_id, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) @@ -3385,6 +3422,30 @@ class ExtBot(Bot, Generic[RLARGS]): api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def set_user_emoji_status( + self, + user_id: int, + emoji_status_custom_emoji_id: Optional[str] = None, + emoji_status_expiration_date: Optional[Union[int, datetime]] = 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, + ) -> bool: + return await super().set_user_emoji_status( + user_id=user_id, + emoji_status_custom_emoji_id=emoji_status_custom_emoji_id, + emoji_status_expiration_date=emoji_status_expiration_date, + 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 set_chat_menu_button( self, chat_id: Optional[int] = None, @@ -4255,6 +4316,30 @@ class ExtBot(Bot, Generic[RLARGS]): api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def edit_user_star_subscription( + self, + user_id: int, + telegram_payment_charge_id: str, + is_canceled: bool, + *, + 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, + ) -> bool: + return await super().edit_user_star_subscription( + user_id=user_id, + telegram_payment_charge_id=telegram_payment_charge_id, + is_canceled=is_canceled, + 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 send_paid_media( self, chat_id: Union[str, int], @@ -4355,6 +4440,52 @@ class ExtBot(Bot, Generic[RLARGS]): api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def get_available_gifts( + self, + *, + 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, + ) -> Gifts: + return await super().get_available_gifts( + 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 send_gift( + self, + user_id: int, + gift_id: Union[str, Gift], + text: Optional[str] = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Optional[Sequence["MessageEntity"]] = 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, + ) -> bool: + return await super().send_gift( + user_id=user_id, + gift_id=gift_id, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + 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 getMe = get_me sendMessage = send_message @@ -4379,6 +4510,7 @@ class ExtBot(Bot, Generic[RLARGS]): sendGame = send_game sendChatAction = send_chat_action answerInlineQuery = answer_inline_query + savePreparedInlineMessage = save_prepared_inline_message getUserProfilePhotos = get_user_profile_photos getFile = get_file banChatMember = ban_chat_member @@ -4421,6 +4553,7 @@ class ExtBot(Bot, Generic[RLARGS]): deleteChatPhoto = delete_chat_photo setChatTitle = set_chat_title setChatDescription = set_chat_description + setUserEmojiStatus = set_user_emoji_status pinChatMessage = pin_chat_message unpinChatMessage = unpin_chat_message unpinAllChatMessages = unpin_all_chat_messages @@ -4478,6 +4611,9 @@ class ExtBot(Bot, Generic[RLARGS]): replaceStickerInSet = replace_sticker_in_set refundStarPayment = refund_star_payment getStarTransactions = get_star_transactions + editUserStarSubscription = edit_user_star_subscription createChatSubscriptionInviteLink = create_chat_subscription_invite_link editChatSubscriptionInviteLink = edit_chat_subscription_invite_link sendPaidMedia = send_paid_media + getAvailableGifts = get_available_gifts + sendGift = send_gift diff --git a/tests/_inline/test_preparedinlinemessage.py b/tests/_inline/test_preparedinlinemessage.py new file mode 100644 index 000000000..c20a3135c --- /dev/null +++ b/tests/_inline/test_preparedinlinemessage.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 as dtm + +import pytest + +from telegram import Location, PreparedInlineMessage +from telegram._utils.datetime import UTC, to_timestamp +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def prepared_inline_message(): + return PreparedInlineMessage( + PreparedInlineMessageTestBase.id, + PreparedInlineMessageTestBase.expiration_date, + ) + + +class PreparedInlineMessageTestBase: + id = "some_uid" + expiration_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +class TestPreparedInlineMessageWithoutRequest(PreparedInlineMessageTestBase): + def test_slot_behaviour(self, prepared_inline_message): + inst = prepared_inline_message + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, prepared_inline_message): + assert prepared_inline_message.id == self.id + assert prepared_inline_message.expiration_date == self.expiration_date + + def test_de_json(self, prepared_inline_message): + json_dict = { + "id": self.id, + "expiration_date": to_timestamp(self.expiration_date), + } + new_prepared_inline_message = PreparedInlineMessage.de_json(json_dict, None) + + assert isinstance(new_prepared_inline_message, PreparedInlineMessage) + assert new_prepared_inline_message.id == prepared_inline_message.id + assert ( + new_prepared_inline_message.expiration_date == prepared_inline_message.expiration_date + ) + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "id": "some_uid", + "expiration_date": to_timestamp(self.expiration_date), + } + pim = PreparedInlineMessage.de_json(json_dict, offline_bot) + pim_raw = PreparedInlineMessage.de_json(json_dict, raw_bot) + pim_tz = PreparedInlineMessage.de_json(json_dict, tz_bot) + + # comparing utcoffset because comparing tzinfo objects is not reliable + offset = pim_tz.expiration_date.utcoffset() + offset_tz = tz_bot.defaults.tzinfo.utcoffset(pim_tz.expiration_date.replace(tzinfo=None)) + + assert pim.expiration_date.tzinfo == UTC + assert pim_raw.expiration_date.tzinfo == UTC + assert offset_tz == offset + + def test_to_dict(self, prepared_inline_message): + prepared_inline_message_dict = prepared_inline_message.to_dict() + + assert isinstance(prepared_inline_message_dict, dict) + assert prepared_inline_message_dict["id"] == prepared_inline_message.id + assert prepared_inline_message_dict["expiration_date"] == to_timestamp( + self.expiration_date + ) + + def test_equality(self, prepared_inline_message): + a = prepared_inline_message + b = PreparedInlineMessage(self.id, self.expiration_date) + c = PreparedInlineMessage(self.id, self.expiration_date + dtm.timedelta(seconds=1)) + d = PreparedInlineMessage("other_uid", self.expiration_date) + e = Location(123, 456) + + assert a == b + assert hash(a) == hash(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) diff --git a/tests/_payment/test_invoice.py b/tests/_payment/test_invoice.py index 585f4fd3e..a28aedc43 100644 --- a/tests/_payment/test_invoice.py +++ b/tests/_payment/test_invoice.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/]. import asyncio +import datetime as dtm import pytest @@ -122,10 +123,14 @@ class TestInvoiceWithoutRequest(InvoiceTestBase): protect_content=True, ) - async def test_send_all_args_create_invoice_link(self, offline_bot, monkeypatch): + @pytest.mark.parametrize("subscription_period", [42, dtm.timedelta(seconds=42)]) + async def test_send_all_args_create_invoice_link( + self, offline_bot, monkeypatch, subscription_period + ): async def make_assertion(*args, **_): kwargs = args[1] - return all(kwargs[i] == i for i in kwargs) + sp = kwargs.pop("subscription_period") == 42 + return all(kwargs[i] == i for i in kwargs) and sp monkeypatch.setattr(offline_bot, "_post", make_assertion) assert await offline_bot.create_invoice_link( @@ -149,6 +154,8 @@ class TestInvoiceWithoutRequest(InvoiceTestBase): send_phone_number_to_provider="send_phone_number_to_provider", send_email_to_provider="send_email_to_provider", is_flexible="is_flexible", + business_connection_id="business_connection_id", + subscription_period=subscription_period, ) async def test_send_object_as_provider_data( diff --git a/tests/_payment/test_successfulpayment.py b/tests/_payment/test_successfulpayment.py index 2b4cf0918..04a455673 100644 --- a/tests/_payment/test_successfulpayment.py +++ b/tests/_payment/test_successfulpayment.py @@ -16,9 +16,12 @@ # # 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 as dtm + import pytest from telegram import OrderInfo, SuccessfulPayment +from telegram._utils.datetime import UTC, to_timestamp from tests.auxil.slots import mro_slots @@ -32,6 +35,9 @@ def successful_payment(): SuccessfulPaymentTestBase.provider_payment_charge_id, shipping_option_id=SuccessfulPaymentTestBase.shipping_option_id, order_info=SuccessfulPaymentTestBase.order_info, + subscription_expiration_date=SuccessfulPaymentTestBase.subscription_expiration_date, + is_recurring=SuccessfulPaymentTestBase.is_recurring, + is_first_recurring=SuccessfulPaymentTestBase.is_first_recurring, ) @@ -43,6 +49,9 @@ class SuccessfulPaymentTestBase: order_info = OrderInfo() telegram_payment_charge_id = "telegram_payment_charge_id" provider_payment_charge_id = "provider_payment_charge_id" + subscription_expiration_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + is_recurring = True + is_first_recurring = True class TestSuccessfulPaymentWithoutRequest(SuccessfulPaymentTestBase): @@ -61,6 +70,9 @@ class TestSuccessfulPaymentWithoutRequest(SuccessfulPaymentTestBase): "order_info": self.order_info.to_dict(), "telegram_payment_charge_id": self.telegram_payment_charge_id, "provider_payment_charge_id": self.provider_payment_charge_id, + "subscription_expiration_date": to_timestamp(self.subscription_expiration_date), + "is_recurring": self.is_recurring, + "is_first_recurring": self.is_first_recurring, } successful_payment = SuccessfulPayment.de_json(json_dict, offline_bot) assert successful_payment.api_kwargs == {} @@ -72,6 +84,32 @@ class TestSuccessfulPaymentWithoutRequest(SuccessfulPaymentTestBase): assert successful_payment.order_info == self.order_info assert successful_payment.telegram_payment_charge_id == self.telegram_payment_charge_id assert successful_payment.provider_payment_charge_id == self.provider_payment_charge_id + assert successful_payment.subscription_expiration_date == self.subscription_expiration_date + assert successful_payment.is_recurring == self.is_recurring + assert successful_payment.is_first_recurring == self.is_first_recurring + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "invoice_payload": self.invoice_payload, + "currency": self.currency, + "total_amount": self.total_amount, + "telegram_payment_charge_id": self.telegram_payment_charge_id, + "provider_payment_charge_id": self.provider_payment_charge_id, + "subscription_expiration_date": to_timestamp(self.subscription_expiration_date), + } + successful_payment = SuccessfulPayment.de_json(json_dict, offline_bot) + successful_payment_raw = SuccessfulPayment.de_json(json_dict, raw_bot) + successful_payment_tz = SuccessfulPayment.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + date_offset = successful_payment_tz.subscription_expiration_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + successful_payment_tz.subscription_expiration_date.replace(tzinfo=None) + ) + + assert successful_payment_raw.subscription_expiration_date.tzinfo == UTC + assert successful_payment.subscription_expiration_date.tzinfo == UTC + assert date_offset == tz_bot_offset def test_to_dict(self, successful_payment): successful_payment_dict = successful_payment.to_dict() @@ -92,6 +130,13 @@ class TestSuccessfulPaymentWithoutRequest(SuccessfulPaymentTestBase): successful_payment_dict["provider_payment_charge_id"] == successful_payment.provider_payment_charge_id ) + assert successful_payment_dict["subscription_expiration_date"] == to_timestamp( + successful_payment.subscription_expiration_date + ) + assert successful_payment_dict["is_recurring"] == successful_payment.is_recurring + assert ( + successful_payment_dict["is_first_recurring"] == successful_payment.is_first_recurring + ) def test_equality(self): a = SuccessfulPayment( diff --git a/tests/test_bot.py b/tests/test_bot.py index 8ff0dec8d..7977efec3 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -67,6 +67,7 @@ from telegram import ( MessageEntity, Poll, PollOption, + PreparedInlineMessage, ReactionTypeCustomEmoji, ReactionTypeEmoji, ReplyParameters, @@ -2321,6 +2322,22 @@ class TestBotWithoutRequest: obj = await offline_bot.get_star_transactions(offset=3) assert isinstance(obj, StarTransactions) + async def test_edit_user_star_subscription(self, offline_bot, monkeypatch): + """Can't properly test, so we only check that the correct values are passed""" + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return ( + request_data.parameters.get("user_id") == 42 + and request_data.parameters.get("telegram_payment_charge_id") + == "telegram_payment_charge_id" + and request_data.parameters.get("is_canceled") is False + ) + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.edit_user_star_subscription( + 42, "telegram_payment_charge_id", False + ) + async def test_create_chat_subscription_invite_link( self, monkeypatch, @@ -2336,6 +2353,39 @@ class TestBotWithoutRequest: await offline_bot.create_chat_subscription_invite_link(1234, 2592000, 6) + @pytest.mark.parametrize( + "expiration_date", [dtm.datetime(2024, 1, 1), 1704067200], ids=["datetime", "timestamp"] + ) + async def test_set_user_emoji_status_basic(self, offline_bot, monkeypatch, expiration_date): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 4242 + assert ( + request_data.parameters.get("emoji_status_custom_emoji_id") + == "emoji_status_custom_emoji_id" + ) + assert request_data.parameters.get("emoji_status_expiration_date") == 1704067200 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.set_user_emoji_status( + 4242, "emoji_status_custom_emoji_id", expiration_date + ) + + async def test_set_user_emoji_status_default_timezone(self, tz_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 4242 + assert ( + request_data.parameters.get("emoji_status_custom_emoji_id") + == "emoji_status_custom_emoji_id" + ) + assert request_data.parameters.get("emoji_status_expiration_date") == to_timestamp( + dtm.datetime(2024, 1, 1), tzinfo=tz_bot.defaults.tzinfo + ) + + monkeypatch.setattr(tz_bot.request, "post", make_assertion) + await tz_bot.set_user_emoji_status( + 4242, "emoji_status_custom_emoji_id", dtm.datetime(2024, 1, 1) + ) + class TestBotWithRequest: """ @@ -2345,6 +2395,9 @@ class TestBotWithRequest: is tested in `test_callbackdatacache` """ + # get_available_gifts, send_gift are tested in `test_gift`. + # No need to duplicate here. + async def test_invalid_token_server_response(self): with pytest.raises(InvalidToken, match="The token `12` was rejected by the server."): async with ExtBot(token="12"): @@ -2841,6 +2894,15 @@ class TestBotWithRequest: 1234, results=inline_results, next_offset=42, current_offset=51 ) + async def test_save_prepared_inline_message(self, bot, chat_id): + # We can't really check that the result is stored correctly, we just ensur ethat we get + # a proper return value + result = InlineQueryResultArticle( + id="some_id", title="title", input_message_content=InputTextMessageContent("text") + ) + out = await bot.save_prepared_inline_message(chat_id, result, True, False, True, False) + assert isinstance(out, PreparedInlineMessage) + async def test_get_user_profile_photos(self, bot, chat_id): user_profile_photos = await bot.get_user_profile_photos(chat_id) assert user_profile_photos.photos[0][0].file_size == 5403 diff --git a/tests/test_chat.py b/tests/test_chat.py index 966e82016..b2db4e36f 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1311,6 +1311,28 @@ class TestChatWithoutRequest(ChatTestBase): media="media", star_count=42, caption="stars", payload="payload" ) + async def test_instance_method_send_gift(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == chat.id + and kwargs["gift_id"] == "gift_id" + and kwargs["text"] == "text" + and kwargs["text_parse_mode"] == "text_parse_mode" + and kwargs["text_entities"] == "text_entities" + ) + + assert check_shortcut_signature(Chat.send_gift, Bot.send_gift, ["user_id"], []) + assert await check_shortcut_call(chat.send_gift, chat.get_bot(), "send_gift") + assert await check_defaults_handling(chat.send_gift, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_gift", make_assertion) + assert await chat.send_gift( + gift_id="gift_id", + text="text", + text_parse_mode="text_parse_mode", + text_entities="text_entities", + ) + def test_mention_html(self): chat = Chat(id=1, type="foo") with pytest.raises(TypeError, match="Can not create a mention to a private group chat"): diff --git a/tests/test_gifts.py b/tests/test_gifts.py new file mode 100644 index 000000000..e7e13c75c --- /dev/null +++ b/tests/test_gifts.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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/]. +from collections.abc import Sequence + +import pytest + +from telegram import BotCommand, Gift, Gifts, MessageEntity, Sticker +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram.request import RequestData +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def gift(request): + return Gift( + id=GiftTestBase.id, + sticker=GiftTestBase.sticker, + star_count=GiftTestBase.star_count, + total_count=GiftTestBase.total_count, + remaining_count=GiftTestBase.remaining_count, + ) + + +class GiftTestBase: + id = "some_id" + sticker = Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ) + star_count = 5 + total_count = 10 + remaining_count = 5 + + +class TestGiftWithoutRequest(GiftTestBase): + def test_slot_behaviour(self, gift): + for attr in gift.__slots__: + assert getattr(gift, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(gift)) == len(set(mro_slots(gift))), "duplicate slot" + + def test_de_json(self, offline_bot, gift): + json_dict = { + "id": self.id, + "sticker": self.sticker.to_dict(), + "star_count": self.star_count, + "total_count": self.total_count, + "remaining_count": self.remaining_count, + } + gift = Gift.de_json(json_dict, offline_bot) + assert gift.api_kwargs == {} + + assert gift.id == self.id + assert gift.sticker == self.sticker + assert gift.star_count == self.star_count + assert gift.total_count == self.total_count + assert gift.remaining_count == self.remaining_count + + assert Gift.de_json(None, offline_bot) is None + + def test_to_dict(self, gift): + gift_dict = gift.to_dict() + + assert isinstance(gift_dict, dict) + assert gift_dict["id"] == self.id + assert gift_dict["sticker"] == self.sticker.to_dict() + assert gift_dict["star_count"] == self.star_count + assert gift_dict["total_count"] == self.total_count + assert gift_dict["remaining_count"] == self.remaining_count + + def test_equality(self, gift): + a = gift + b = Gift(self.id, self.sticker, self.star_count, self.total_count, self.remaining_count) + c = Gift( + "other_uid", self.sticker, self.star_count, self.total_count, self.remaining_count + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + @pytest.mark.parametrize( + "gift", + [ + "gift_id", + Gift( + "gift_id", + Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + 5, + 10, + 5, + ), + ], + ids=["string", "Gift"], + ) + async def test_send_gift(self, offline_bot, gift, monkeypatch): + # We can't send actual gifts, so we just check that the correct parameters are passed + text_entities = [ + MessageEntity(MessageEntity.TEXT_LINK, 0, 4, "url"), + MessageEntity(MessageEntity.BOLD, 5, 9), + ] + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + user_id = request_data.parameters["user_id"] == "user_id" + gift_id = request_data.parameters["gift_id"] == "gift_id" + text = request_data.parameters["text"] == "text" + text_parse_mode = request_data.parameters["text_parse_mode"] == "text_parse_mode" + tes = request_data.parameters["text_entities"] == [ + me.to_dict() for me in text_entities + ] + return user_id and gift_id and text and text_parse_mode and tes + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_gift( + "user_id", gift, "text", text_parse_mode="text_parse_mode", text_entities=text_entities + ) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_send_gift_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return request_data.parameters.get("text_parse_mode") == expected_value + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "user_id": "user_id", + "gift_id": "gift_id", + } + if passed_value is not DEFAULT_NONE: + kwargs["text_parse_mode"] = passed_value + + assert await default_bot.send_gift(**kwargs) + + +@pytest.fixture +def gifts(request): + return Gifts(gifts=GiftsTestBase.gifts) + + +class GiftsTestBase: + gifts: Sequence[Gift] = [ + Gift( + id="id1", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=5, + total_count=5, + remaining_count=5, + ), + Gift( + id="id2", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=6, + total_count=6, + remaining_count=6, + ), + Gift( + id="id3", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=7, + total_count=7, + remaining_count=7, + ), + ] + + +class TestGiftsWithoutRequest(GiftsTestBase): + def test_slot_behaviour(self, gifts): + for attr in gifts.__slots__: + assert getattr(gifts, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(gifts)) == len(set(mro_slots(gifts))), "duplicate slot" + + def test_de_json(self, offline_bot, gifts): + json_dict = {"gifts": [gift.to_dict() for gift in self.gifts]} + gifts = Gifts.de_json(json_dict, offline_bot) + assert gifts.api_kwargs == {} + + assert gifts.gifts == tuple(self.gifts) + for de_json_gift, original_gift in zip(gifts.gifts, self.gifts): + assert de_json_gift.id == original_gift.id + assert de_json_gift.sticker == original_gift.sticker + assert de_json_gift.star_count == original_gift.star_count + assert de_json_gift.total_count == original_gift.total_count + assert de_json_gift.remaining_count == original_gift.remaining_count + + assert Gifts.de_json(None, offline_bot) is None + + def test_to_dict(self, gifts): + gifts_dict = gifts.to_dict() + + assert isinstance(gifts_dict, dict) + assert gifts_dict["gifts"] == [gift.to_dict() for gift in self.gifts] + + def test_equality(self, gifts): + a = gifts + b = Gifts(self.gifts) + c = Gifts(self.gifts[:2]) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +class TestGiftsWithRequest(GiftTestBase): + async def test_get_available_gifts(self, bot, chat_id): + # We don't control the available gifts, so we can not make any better assertions + assert isinstance(await bot.get_available_gifts(), Gifts) diff --git a/tests/test_official/arg_type_checker.py b/tests/test_official/arg_type_checker.py index c6d5bae53..ad5251bd3 100644 --- a/tests/test_official/arg_type_checker.py +++ b/tests/test_official/arg_type_checker.py @@ -24,7 +24,7 @@ import inspect import logging import re from collections.abc import Sequence -from datetime import datetime +from datetime import datetime, timedelta from types import FunctionType from typing import Any @@ -67,6 +67,7 @@ DATETIME_REGEX = re.compile( """, re.VERBOSE, ) +TIMEDELTA_REGEX = re.compile(r"\w+_period$") # Parameter names ending with "_period" log = logging.debug @@ -191,7 +192,18 @@ def check_param_type( # If it's a class, we only accept datetime as the parameter mapped_type = datetime if is_class else mapped_type | datetime - # 4) COMPLEX TYPES: + # 4) HANDLING TIMEDELTA: + elif re.search(TIMEDELTA_REGEX, ptb_param.name) and obj.__name__ in ( + "TransactionPartnerUser", + "create_invoice_link", + ): + # Currently we only support timedelta for `subscription_period` in `TransactionPartnerUser` + # and `create_invoice_link`. + # See https://github.com/python-telegram-bot/python-telegram-bot/issues/4575 + log("Checking that `%s` is a timedelta!\n", ptb_param.name) + mapped_type = timedelta if is_class else mapped_type | timedelta + + # 5) COMPLEX TYPES: # Some types are too complicated, so we replace our annotation with a simpler type: elif any(ptb_param.name in key for key in PTCE.COMPLEX_TYPES): log("Converting `%s` to a simpler type!\n", ptb_param.name) @@ -199,7 +211,7 @@ def check_param_type( if ptb_param.name == param_name and is_class is is_expected_class: ptb_annotation = wrap_with_none(tg_parameter, exception_type, obj) - # 5) HANDLING DEFAULTS PARAMETERS: + # 6) HANDLING DEFAULTS PARAMETERS: # Classes whose parameters are all ODVInput should be converted and checked. elif obj.__name__ in PTCE.IGNORED_DEFAULTS_CLASSES: log("Checking that `%s`'s param is ODVInput:\n", obj.__name__) diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 50551559b..d6eb421e8 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -18,8 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains exceptions to our API compared to the official API.""" - -from telegram import Animation, Audio, Document, PhotoSize, Sticker, Video, VideoNote, Voice +from telegram import Animation, Audio, Document, Gift, PhotoSize, Sticker, Video, VideoNote, Voice from tests.test_official.helpers import _get_params_base IGNORED_OBJECTS = ("ResponseParameters",) @@ -45,6 +44,7 @@ class ParamTypeCheckingExceptions: "animation": Animation, "voice": Voice, "sticker": Sticker, + "gift_id": Gift, } # TODO: Look into merging this with COMPLEX_TYPES diff --git a/tests/test_stars.py b/tests/test_stars.py index 12329b62e..5fb7a3c40 100644 --- a/tests/test_stars.py +++ b/tests/test_stars.py @@ -24,6 +24,7 @@ import pytest from telegram import ( Dice, + Gift, PaidMediaPhoto, PhotoSize, RevenueWithdrawalState, @@ -32,6 +33,7 @@ from telegram import ( RevenueWithdrawalStateSucceeded, StarTransaction, StarTransactions, + Sticker, TransactionPartner, TransactionPartnerFragment, TransactionPartnerOther, @@ -76,6 +78,22 @@ def transaction_partner_user(): ) ], paid_media_payload="payload", + subscription_period=datetime.timedelta(days=1), + gift=Gift( + id="some_id", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=5, + total_count=10, + remaining_count=5, + ), ) @@ -515,6 +533,20 @@ class TestTransactionPartnerWithoutRequest(TransactionPartnerTestBase): assert hash(c) != hash(f) +class TestTransactionPartnerUserWithoutRequest(TransactionPartnerTestBase): + def test_de_json_required(self, offline_bot): + json_dict = { + "user": transaction_partner_user().user.to_dict(), + } + tp = TransactionPartnerUser.de_json(json_dict, offline_bot) + assert tp.api_kwargs == {} + assert tp.user == transaction_partner_user().user + + # This test is here mainly to check that the below cases work + assert tp.subscription_period is None + assert tp.gift is None + + class RevenueWithdrawalStateTestBase: date = datetime.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC) url = "url" diff --git a/tests/test_user.py b/tests/test_user.py index d8a6265f0..ede518f6f 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -720,3 +720,25 @@ class TestUserWithoutRequest(UserTestBase): monkeypatch.setattr(user.get_bot(), "refund_star_payment", make_assertion) assert await user.refund_star_payment(telegram_payment_charge_id=42) + + async def test_instance_method_send_gift(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["gift_id"] == "gift_id" + and kwargs["text"] == "text" + and kwargs["text_parse_mode"] == "text_parse_mode" + and kwargs["text_entities"] == "text_entities" + ) + + assert check_shortcut_signature(user.send_gift, Bot.send_gift, ["user_id"], []) + assert await check_shortcut_call(user.send_gift, user.get_bot(), "send_gift") + assert await check_defaults_handling(user.send_gift, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "send_gift", make_assertion) + assert await user.send_gift( + gift_id="gift_id", + text="text", + text_parse_mode="text_parse_mode", + text_entities="text_entities", + )