From 769dcf5652ef71b7a8633d46526c9fabcdee7b3c Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sun, 24 Nov 2024 22:00:46 +0100 Subject: [PATCH 1/8] Add `Gift` and `Gifts` --- docs/source/telegram.at-tree.rst | 1 - docs/source/telegram.gift.rst | 6 + docs/source/telegram.gifts.rst | 6 + docs/source/telegram.stickers-tree.rst | 3 + telegram/__init__.py | 3 + telegram/_gifts.py | 136 +++++++++++++++++ tests/test_gifts.py | 202 +++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 docs/source/telegram.gift.rst create mode 100644 docs/source/telegram.gifts.rst create mode 100644 telegram/_gifts.py create mode 100644 tests/test_gifts.py diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index bdeb7015985..22abbfb3867 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 00000000000..e42cb720ac2 --- /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 00000000000..649522d0dce --- /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.stickers-tree.rst b/docs/source/telegram.stickers-tree.rst index 2ea687183c0..e45dcacb56b 100644 --- a/docs/source/telegram.stickers-tree.rst +++ b/docs/source/telegram.stickers-tree.rst @@ -6,6 +6,9 @@ The following methods and objects allow your bot to handle stickers and sticker .. toctree:: :titlesonly: + telegram.gift + telegram.gifts + telegram.inputsticker telegram.maskposition telegram.sticker telegram.stickerset diff --git a/telegram/__init__.py b/telegram/__init__.py index a4902d4d882..69f06976dd7 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -101,6 +101,8 @@ "GameHighScore", "GeneralForumTopicHidden", "GeneralForumTopicUnhidden", + "Gift", + "Gifts", "Giveaway", "GiveawayCompleted", "GiveawayCreated", @@ -373,6 +375,7 @@ 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 diff --git a/telegram/_gifts.py b/telegram/_gifts.py new file mode 100644 index 00000000000..98bdc213fb1 --- /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 = id + self.sticker = sticker + self.star_count = star_count + self.total_count = total_count + self.remaining_count = 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 = 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/tests/test_gifts.py b/tests/test_gifts.py new file mode 100644 index 00000000000..ddb73825888 --- /dev/null +++ b/tests/test_gifts.py @@ -0,0 +1,202 @@ +#!/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, Sticker +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.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) From 6417fa3d6aec8f75449fbacfd5494e8522652d58 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sun, 24 Nov 2024 22:24:49 +0100 Subject: [PATCH 2/8] Add `TransactionPartnerUser.(subscription_period|gift)` --- telegram/_payment/stars.py | 49 +++++++++++++++++++++++++++++++------ telegram/_telegramobject.py | 2 ++ tests/test_stars.py | 32 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/telegram/_payment/stars.py b/telegram/_payment/stars.py index a47d3b44ff8..e6c685497be 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def de_json( 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 @@ def __init__( self, id: str, amount: int, - date: datetime, + date: dtm.datetime, source: Optional[TransactionPartner] = None, receiver: Optional[TransactionPartner] = None, *, @@ -505,7 +538,7 @@ def __init__( 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/_telegramobject.py b/telegram/_telegramobject.py index 6f5038da44b..1b29095e824 100644 --- a/telegram/_telegramobject.py +++ b/telegram/_telegramobject.py @@ -636,6 +636,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: 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/tests/test_stars.py b/tests/test_stars.py index 12329b62e75..5fb7a3c4068 100644 --- a/tests/test_stars.py +++ b/tests/test_stars.py @@ -24,6 +24,7 @@ from telegram import ( Dice, + Gift, PaidMediaPhoto, PhotoSize, RevenueWithdrawalState, @@ -32,6 +33,7 @@ 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 @@ def test_equality(self, transaction_partner, offline_bot): 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" From 305f482336f81cab8ce29576e52e319ac3340210 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Sun, 24 Nov 2024 22:34:03 +0100 Subject: [PATCH 3/8] Improve Type Completeness --- telegram/_gifts.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/telegram/_gifts.py b/telegram/_gifts.py index 98bdc213fb1..63055974c41 100644 --- a/telegram/_gifts.py +++ b/telegram/_gifts.py @@ -71,11 +71,11 @@ def __init__( api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) - self.id = id - self.sticker = sticker - self.star_count = star_count - self.total_count = total_count - self.remaining_count = remaining_count + 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,) @@ -118,7 +118,7 @@ def __init__( api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) - self.gifts = parse_sequence_arg(gifts) + self.gifts: tuple[Gift, ...] = parse_sequence_arg(gifts) self._id_attrs = (self.gifts,) From 677cd920de016d010e73ce9512cc6eac1fa8d3cc Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Mon, 25 Nov 2024 17:58:11 +0100 Subject: [PATCH 4/8] Add `Bot.(get_available_gifs|send_gift)` --- docs/source/inclusions/bot_methods.rst | 4 ++ telegram/_bot.py | 98 ++++++++++++++++++++++++++ telegram/ext/_extbot.py | 50 +++++++++++++ tests/test_bot.py | 3 + tests/test_gifts.py | 45 +++++++++++- 5 files changed, 199 insertions(+), 1 deletion(-) diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 3189de1c1d3..dee73ce4c79 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` @@ -369,6 +371,8 @@ - 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` diff --git a/telegram/_bot.py b/telegram/_bot.py index cc2ba38fb3a..52dd569968b 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -75,6 +75,7 @@ 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._menubutton import MenuButton from telegram._message import Message @@ -9475,6 +9476,99 @@ async def edit_chat_subscription_invite_link( 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-255 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 if isinstance(gift_id, str) else gift_id.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} @@ -9735,3 +9829,7 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`create_chat_subscription_invite_link`""" editChatSubscriptionInviteLink = edit_chat_subscription_invite_link """Alias for :meth:`edit_chat_subscription_invite_link`""" + getAvailableGifst = get_available_gifts + """Alias for :meth:`get_available_gifts`""" + sendGift = send_gift + """Alias for :meth:`send_gift`""" diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 66921e415df..6689fb0e212 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -57,6 +57,8 @@ File, ForumTopic, GameHighScore, + Gift, + Gifts, InlineKeyboardMarkup, InlineQueryResultsButton, InputMedia, @@ -4355,6 +4357,52 @@ async def edit_chat_subscription_invite_link( 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 @@ -4481,3 +4529,5 @@ async def edit_chat_subscription_invite_link( createChatSubscriptionInviteLink = create_chat_subscription_invite_link editChatSubscriptionInviteLink = edit_chat_subscription_invite_link sendPaidMedia = send_paid_media + getAvailableGifst = get_available_gifts + sendGift = send_gift diff --git a/tests/test_bot.py b/tests/test_bot.py index 8ff0dec8d7b..b2f8d884740 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -2345,6 +2345,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"): diff --git a/tests/test_gifts.py b/tests/test_gifts.py index ddb73825888..2b6bffbcbfd 100644 --- a/tests/test_gifts.py +++ b/tests/test_gifts.py @@ -20,7 +20,8 @@ import pytest -from telegram import BotCommand, Gift, Gifts, Sticker +from telegram import BotCommand, Gift, Gifts, MessageEntity, Sticker +from telegram.request import RequestData from tests.auxil.slots import mro_slots @@ -103,6 +104,42 @@ def test_equality(self, gift): 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.fixture def gifts(request): @@ -200,3 +237,9 @@ def test_equality(self, gifts): 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) From 090f67c086c6ea1e103504e9173e5a48c968761c Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:08:55 +0100 Subject: [PATCH 5/8] Add Defaults Handling to `send_gift` --- telegram/_bot.py | 2 +- telegram/ext/_defaults.py | 4 +++- tests/test_gifts.py | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/telegram/_bot.py b/telegram/_bot.py index 52dd569968b..6f6cfe0ab8c 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -9554,7 +9554,7 @@ async def send_gift( """ data: JSONDict = { "user_id": user_id, - "gift_id": gift_id if isinstance(gift_id, str) else gift_id.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, diff --git a/telegram/ext/_defaults.py b/telegram/ext/_defaults.py index 93e7e3748b8..c406e7203f6 100644 --- a/telegram/ext/_defaults.py +++ b/telegram/ext/_defaults.py @@ -188,6 +188,7 @@ def __init__( "explanation_parse_mode", "link_preview_options", "parse_mode", + "text_parse_mode", "protect_content", "question_parse_mode", ): @@ -271,7 +272,8 @@ def quote_parse_mode(self, _: object) -> NoReturn: @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:`Bot.send_gift`. .. versionadded:: 21.2 """ diff --git a/tests/test_gifts.py b/tests/test_gifts.py index 2b6bffbcbfd..e7e13c75cef 100644 --- a/tests/test_gifts.py +++ b/tests/test_gifts.py @@ -21,6 +21,7 @@ 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 @@ -140,6 +141,27 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "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): From 1e077cf50d07397ddae93862e620f57f45ceffe9 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:31:04 +0100 Subject: [PATCH 6/8] Add Shortcut Methods --- telegram/_chat.py | 41 +++++++++++++++++++++++++++++++++++++++++ telegram/_user.py | 38 ++++++++++++++++++++++++++++++++++++++ tests/test_chat.py | 22 ++++++++++++++++++++++ tests/test_user.py | 22 ++++++++++++++++++++++ 4 files changed, 123 insertions(+) diff --git a/telegram/_chat.py b/telegram/_chat.py index bb0e24b1da5..08321fe25d0 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -44,6 +44,7 @@ ChatMember, Contact, Document, + Gift, InlineKeyboardMarkup, InputMediaAudio, InputMediaDocument, @@ -3436,6 +3437,46 @@ async def send_paid_media( 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/_user.py b/telegram/_user.py index 980ce7e4991..2a4b4dc7a99 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -36,6 +36,7 @@ Audio, Contact, Document, + Gift, InlineKeyboardMarkup, InputMediaAudio, InputMediaDocument, @@ -1646,6 +1647,43 @@ async def send_poll( 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/tests/test_chat.py b/tests/test_chat.py index 966e820162e..b2db4e36f76 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1311,6 +1311,28 @@ async def make_assertion(*_, **kwargs): 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_user.py b/tests/test_user.py index d8a6265f0cf..ede518f6f8f 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -720,3 +720,25 @@ async def make_assertion(*_, **kwargs): 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", + ) From 9feb130c550e208a21a75565d92653a3f535ad00 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:45:19 +0100 Subject: [PATCH 7/8] Add constants --- telegram/_bot.py | 2 +- telegram/constants.py | 16 ++++++++++++++++ telegram/ext/_defaults.py | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/telegram/_bot.py b/telegram/_bot.py index 6f6cfe0ab8c..ba52341fdc1 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -9531,7 +9531,7 @@ async def send_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-255 characters + 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 diff --git a/telegram/constants.py b/telegram/constants.py index 10f887a938f..e92fd36ac04 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -64,6 +64,7 @@ "FloodLimit", "ForumIconColor", "ForumTopicLimit", + "GiftLimit", "GiveawayLimit", "InlineKeyboardButtonLimit", "InlineKeyboardMarkupLimit", @@ -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. diff --git a/telegram/ext/_defaults.py b/telegram/ext/_defaults.py index c406e7203f6..03191462252 100644 --- a/telegram/ext/_defaults.py +++ b/telegram/ext/_defaults.py @@ -273,7 +273,7 @@ def quote_parse_mode(self, _: object) -> NoReturn: def text_parse_mode(self) -> Optional[str]: """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for the corresponding parameter of :class:`telegram.InputPollOption` and - :meth:`Bot.send_gift`. + :meth:`telegram.Bot.send_gift`. .. versionadded:: 21.2 """ From d6fbcfbfc381142d1082b5f26ee484aa856fcbc0 Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:58:15 +0100 Subject: [PATCH 8/8] fix a typo --- telegram/_bot.py | 2 +- telegram/ext/_extbot.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/telegram/_bot.py b/telegram/_bot.py index ba52341fdc1..f6a460ecece 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -9829,7 +9829,7 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`create_chat_subscription_invite_link`""" editChatSubscriptionInviteLink = edit_chat_subscription_invite_link """Alias for :meth:`edit_chat_subscription_invite_link`""" - getAvailableGifst = get_available_gifts + getAvailableGifts = get_available_gifts """Alias for :meth:`get_available_gifts`""" sendGift = send_gift """Alias for :meth:`send_gift`""" diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 6689fb0e212..dede8def27f 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -4529,5 +4529,5 @@ async def send_gift( createChatSubscriptionInviteLink = create_chat_subscription_invite_link editChatSubscriptionInviteLink = edit_chat_subscription_invite_link sendPaidMedia = send_paid_media - getAvailableGifst = get_available_gifts + getAvailableGifts = get_available_gifts sendGift = send_gift