From 17afc6a4ae35cd58e3d44fce6228e6e31e3da230 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Tue, 15 Apr 2025 03:49:58 +0300 Subject: [PATCH 01/10] Add unique gifts', gift info's classes plus tests. Gifts - Added the class `UniqueGift` describing a gift that was upgraded to a unique one. - Added the classes `UniqueGiftModel`, `UniqueGiftSymbol`, `UniqueGiftBackdropColors`, and `UniqueGiftBackdrop` to describe the properties of a unique gift. - Added the class `UniqueGiftInfo` and the field `unique_gift` to the class `Message`, describing a service message about a unique gift that was sent or received. - Added the class `GiftInfo` and the field `gift` to the class `Message`, describing a service message about a regular gift that was sent or received. Also added new filters the new message types; `message.gift` and `message.unique_gift` --- docs/source/telegram.at-tree.rst | 7 + docs/source/telegram.giftinfo.rst | 7 + docs/source/telegram.uniquegift.rst | 7 + docs/source/telegram.uniquegiftbackdrop.rst | 7 + .../telegram.uniquegiftbackdropcolors.rst | 7 + docs/source/telegram.uniquegiftinfo.rst | 7 + docs/source/telegram.uniquegiftmodel.rst | 7 + docs/source/telegram.uniquegiftsymbol.rst | 7 + telegram/__init__.py | 17 +- telegram/_gifts.py | 151 ++++++ telegram/_message.py | 26 + telegram/_uniquegift.py | 401 +++++++++++++++ telegram/constants.py | 26 + telegram/ext/filters.py | 26 + tests/ext/test_filters.py | 10 + tests/test_gifts.py | 110 ++++- tests/test_message.py | 47 ++ tests/test_uniquegift.py | 459 ++++++++++++++++++ 18 files changed, 1327 insertions(+), 2 deletions(-) create mode 100644 docs/source/telegram.giftinfo.rst create mode 100644 docs/source/telegram.uniquegift.rst create mode 100644 docs/source/telegram.uniquegiftbackdrop.rst create mode 100644 docs/source/telegram.uniquegiftbackdropcolors.rst create mode 100644 docs/source/telegram.uniquegiftinfo.rst create mode 100644 docs/source/telegram.uniquegiftmodel.rst create mode 100644 docs/source/telegram.uniquegiftsymbol.rst create mode 100644 telegram/_uniquegift.py create mode 100644 tests/test_uniquegift.py diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 5ea6949e815..edf5080935e 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -76,6 +76,7 @@ Available Types telegram.forumtopicreopened telegram.generalforumtopichidden telegram.generalforumtopicunhidden + telegram.giftinfo.rst telegram.giveaway telegram.giveawaycompleted telegram.giveawaycreated @@ -142,6 +143,12 @@ Available Types telegram.switchinlinequerychosenchat telegram.telegramobject telegram.textquote + telegram.uniquegift.rst + telegram.uniquegiftbackdrop.rst + telegram.uniquegiftbackdropcolors.rst + telegram.uniquegiftinfo.rst + telegram.uniquegiftmodel.rst + telegram.uniquegiftsymbol.rst telegram.update telegram.user telegram.userchatboosts diff --git a/docs/source/telegram.giftinfo.rst b/docs/source/telegram.giftinfo.rst new file mode 100644 index 00000000000..ff5ab6ad352 --- /dev/null +++ b/docs/source/telegram.giftinfo.rst @@ -0,0 +1,7 @@ +GiftInfo +======== + +.. autoclass:: telegram.GiftInfo + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegift.rst b/docs/source/telegram.uniquegift.rst new file mode 100644 index 00000000000..0d9d1a12d32 --- /dev/null +++ b/docs/source/telegram.uniquegift.rst @@ -0,0 +1,7 @@ +UniqueGift +========== + +.. autoclass:: telegram.UniqueGift + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftbackdrop.rst b/docs/source/telegram.uniquegiftbackdrop.rst new file mode 100644 index 00000000000..52264731b22 --- /dev/null +++ b/docs/source/telegram.uniquegiftbackdrop.rst @@ -0,0 +1,7 @@ +UniqueGiftBackdrop +================== + +.. autoclass:: telegram.UniqueGiftBackdrop + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftbackdropcolors.rst b/docs/source/telegram.uniquegiftbackdropcolors.rst new file mode 100644 index 00000000000..40fbf609a37 --- /dev/null +++ b/docs/source/telegram.uniquegiftbackdropcolors.rst @@ -0,0 +1,7 @@ +UniqueGiftBackdropColors +======================== + +.. autoclass:: telegram.UniqueGiftBackdropColors + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftinfo.rst b/docs/source/telegram.uniquegiftinfo.rst new file mode 100644 index 00000000000..5d8ef6402cf --- /dev/null +++ b/docs/source/telegram.uniquegiftinfo.rst @@ -0,0 +1,7 @@ +UniqueGiftInfo +============== + +.. autoclass:: telegram.UniqueGiftInfo + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftmodel.rst b/docs/source/telegram.uniquegiftmodel.rst new file mode 100644 index 00000000000..a0a95a04307 --- /dev/null +++ b/docs/source/telegram.uniquegiftmodel.rst @@ -0,0 +1,7 @@ +UniqueGiftModel +=============== + +.. autoclass:: telegram.UniqueGiftModel + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftsymbol.rst b/docs/source/telegram.uniquegiftsymbol.rst new file mode 100644 index 00000000000..8246da5cf17 --- /dev/null +++ b/docs/source/telegram.uniquegiftsymbol.rst @@ -0,0 +1,7 @@ +UniqueGiftSymbol +================ + +.. autoclass:: telegram.UniqueGiftSymbol + :members: + :show-inheritance: + diff --git a/telegram/__init__.py b/telegram/__init__.py index 5b444129261..4b67addbeca 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -104,6 +104,7 @@ "GeneralForumTopicHidden", "GeneralForumTopicUnhidden", "Gift", + "GiftInfo", "Gifts", "Giveaway", "GiveawayCompleted", @@ -245,6 +246,12 @@ "TransactionPartnerTelegramAds", "TransactionPartnerTelegramApi", "TransactionPartnerUser", + "UniqueGift", + "UniqueGiftBackdrop", + "UniqueGiftBackdropColors", + "UniqueGiftInfo", + "UniqueGiftModel", + "UniqueGiftSymbol", "Update", "User", "UserChatBoosts", @@ -393,7 +400,7 @@ from ._games.callbackgame import CallbackGame from ._games.game import Game from ._games.gamehighscore import GameHighScore -from ._gifts import Gift, Gifts +from ._gifts import Gift, GiftInfo, Gifts from ._giveaway import Giveaway, GiveawayCompleted, GiveawayCreated, GiveawayWinners from ._inline.inlinekeyboardbutton import InlineKeyboardButton from ._inline.inlinekeyboardmarkup import InlineKeyboardMarkup @@ -510,6 +517,14 @@ from ._story import Story from ._switchinlinequerychosenchat import SwitchInlineQueryChosenChat from ._telegramobject import TelegramObject +from ._uniquegift import ( + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) from ._update import Update from ._user import User from ._userprofilephotos import UserProfilePhotos diff --git a/telegram/_gifts.py b/telegram/_gifts.py index d068923c6df..9be2d938247 100644 --- a/telegram/_gifts.py +++ b/telegram/_gifts.py @@ -22,8 +22,10 @@ from typing import TYPE_CHECKING, Optional from telegram._files.sticker import Sticker +from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.entities import parse_message_entities, parse_message_entity from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -145,3 +147,152 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Gifts": data["gifts"] = de_list_optional(data.get("gifts"), Gift, bot) return super().de_json(data=data, bot=bot) + + +class GiftInfo(TelegramObject): + """Describes a service message about a regular gift that was sent or received. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`gift` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + gift (:class:`Gift`): Information about the gift. + owned_gift_id (:obj:`str`, optional): Unique identifier of the received gift for the bot; + only present for gifts received on behalf of business accounts + convert_star_count (:obj:`int`, optional) Number of Telegram Stars that can be claimed by + the receiver by converting the gift; omitted if conversion to Telegram Stars + is impossible + prepaid_upgrade_star_count (:obj:`int`, optional): Number of Telegram Stars that were + prepaid by the sender for the ability to upgrade the gift + can_be_upgraded (:obj:`bool`, optional): :obj:`True`, if the gift can be upgraded + to a unique gift. + text (:obj:`str`, optional): Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities that + appear in the text. + is_private (:obj:`bool`, optional): :obj:`True`, if the sender and gift text are + shown only to the gift receiver; otherwise, everyone will be able to see them. + + Attributes: + gift (:class:`Gift`): Information about the gift. + owned_gift_id (:obj:`str`): Optional. Unique identifier of the received gift for the bot; + only present for gifts received on behalf of business accounts + convert_star_count (:obj:`int`): Optional. Number of Telegram Stars that can be claimed by + the receiver by converting the gift; omitted if conversion to Telegram Stars + is impossible + prepaid_upgrade_star_count (:obj:`int`): Optional. Number of Telegram Stars that were + prepaid by the sender for the ability to upgrade the gift + can_be_upgraded (:obj:`bool`): Optional. :obj:`True`, if the gift can be upgraded + to a unique gift. + text (:obj:`str`): Optional. Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`]): Optional. Special entities that + appear in the text. + is_private (:obj:`bool`): Optional. :obj:`True`, if the sender and gift text are + shown only to the gift receiver; otherwise, everyone will be able to see them. + + """ + + __slots__ = ( + "can_be_upgraded", + "convert_star_count", + "entities", + "gift", + "is_private", + "owned_gift_id", + "prepaid_upgrade_star_count", + "text", + ) + + def __init__( + self, + gift: Gift, + owned_gift_id: Optional[str] = None, + convert_star_count: Optional[int] = None, + prepaid_upgrade_star_count: Optional[int] = None, + can_be_upgraded: Optional[bool] = None, + text: Optional[str] = None, + entities: Optional[Sequence[MessageEntity]] = None, + is_private: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.gift: Gift = gift + # Optional + self.owned_gift_id: Optional[str] = owned_gift_id + self.convert_star_count: Optional[int] = convert_star_count + self.prepaid_upgrade_star_count: Optional[int] = prepaid_upgrade_star_count + self.can_be_upgraded: Optional[bool] = can_be_upgraded + self.text: Optional[str] = text + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.is_private: Optional[bool] = is_private + + self._id_attrs = (self.gift,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "GiftInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gift"] = de_json_optional(data.get("gift"), Gift, bot) + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`entities`. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the gift info has no text. + + """ + if not self.text: + raise RuntimeError("This GiftInfo has no 'text'.") + + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this gift info's text filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + Raises: + RuntimeError: If the gift info has no text. + + """ + if not self.text: + raise RuntimeError("This GiftInfo has no 'text'.") + + return parse_message_entities(self.text, self.entities, types) diff --git a/telegram/_message.py b/telegram/_message.py index 8871145cf11..4108bccb927 100644 --- a/telegram/_message.py +++ b/telegram/_message.py @@ -49,6 +49,7 @@ GeneralForumTopicUnhidden, ) from telegram._games.game import Game +from telegram._gifts import GiftInfo from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged @@ -64,6 +65,7 @@ from telegram._shared import ChatShared, UsersShared from telegram._story import Story from telegram._telegramobject import TelegramObject +from telegram._uniquegift import UniqueGiftInfo from telegram._user import User from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp @@ -525,6 +527,14 @@ class Message(MaybeInaccessibleMessage): with the bot. .. versionadded:: 20.1 + gift (:class:`telegram.GiftInfo`, optional): Service message: a regular gift was sent + or received. + + .. versionadded:: NEXT.VERSION + unique_gift (:class:`telegram.UniqueGiftInfo`, optional): Service message: a unique gift + was sent or received + + .. versionadded:: NEXT.VERSION giveaway_created (:class:`telegram.GiveawayCreated`, optional): Service message: a scheduled giveaway was created @@ -853,6 +863,14 @@ class Message(MaybeInaccessibleMessage): with the bot. .. versionadded:: 20.1 + gift (:class:`telegram.GiftInfo`): Optional. Service message: a regular gift was sent + or received. + + .. versionadded:: NEXT.VERSION + unique_gift (:class:`telegram.UniqueGiftInfo`): Optional. Service message: a unique gift + was sent or received + + .. versionadded:: NEXT.VERSION giveaway_created (:class:`telegram.GiveawayCreated`): Optional. Service message: a scheduled giveaway was created @@ -966,6 +984,7 @@ class Message(MaybeInaccessibleMessage): "game", "general_forum_topic_hidden", "general_forum_topic_unhidden", + "gift", "giveaway", "giveaway_completed", "giveaway_created", @@ -1008,6 +1027,7 @@ class Message(MaybeInaccessibleMessage): "successful_payment", "supergroup_chat_created", "text", + "unique_gift", "users_shared", "venue", "via_bot", @@ -1109,6 +1129,8 @@ def __init__( show_caption_above_media: Optional[bool] = None, paid_media: Optional[PaidMediaInfo] = None, refunded_payment: Optional[RefundedPayment] = None, + gift: Optional[GiftInfo] = None, + unique_gift: Optional[UniqueGiftInfo] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -1212,6 +1234,8 @@ def __init__( self.show_caption_above_media: Optional[bool] = show_caption_above_media self.paid_media: Optional[PaidMediaInfo] = paid_media self.refunded_payment: Optional[RefundedPayment] = refunded_payment + self.gift: Optional[GiftInfo] = gift + self.unique_gift: Optional[UniqueGiftInfo] = unique_gift self._effective_attachment = DEFAULT_NONE @@ -1346,6 +1370,8 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Message": data["refunded_payment"] = de_json_optional( data.get("refunded_payment"), RefundedPayment, bot ) + data["gift"] = de_json_optional(data.get("gift"), GiftInfo, bot) + data["unique_gift"] = de_json_optional(data.get("unique_gift"), UniqueGiftInfo, bot) # Unfortunately, this needs to be here due to cyclic imports from telegram._giveaway import ( # pylint: disable=import-outside-toplevel diff --git a/telegram/_uniquegift.py b/telegram/_uniquegift.py new file mode 100644 index 00000000000..61926552f3f --- /dev/null +++ b/telegram/_uniquegift.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python +# +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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 unique gifs.""" +from typing import TYPE_CHECKING, Final, Optional + +from telegram import constants +from telegram._files.sticker import Sticker +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class UniqueGiftModel(TelegramObject): + """This object describes the model of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`name`, :attr:`sticker` and :attr:`rarity_per_mille` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + name (:obj:`str`): Name of the model. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + Attributes: + name (:obj:`str`): Name of the model. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + """ + + __slots__ = ( + "name", + "rarity_per_mille", + "sticker", + ) + + def __init__( + self, + name: str, + sticker: Sticker, + rarity_per_mille: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.name: str = name + self.sticker: Sticker = sticker + self.rarity_per_mille: int = rarity_per_mille + + self._id_attrs = (self.name, self.sticker, self.rarity_per_mille) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "UniqueGiftModel": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGiftSymbol(TelegramObject): + """This object describes the symbol shown on the pattern of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`name`, :attr:`sticker` and :attr:`rarity_per_mille` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + name (:obj:`str`): Name of the symbol. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + Attributes: + name (:obj:`str`): Name of the symbol. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + """ + + __slots__ = ( + "name", + "rarity_per_mille", + "sticker", + ) + + def __init__( + self, + name: str, + sticker: Sticker, + rarity_per_mille: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.name: str = name + self.sticker: Sticker = sticker + self.rarity_per_mille: int = rarity_per_mille + + self._id_attrs = (self.name, self.sticker, self.rarity_per_mille) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "UniqueGiftSymbol": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGiftBackdropColors(TelegramObject): + """This object describes the colors of the backdrop of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`center_color`, :attr:`edge_color`, :attr:`symbol_color`, + and :attr:`text_color` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + center_color (:obj:`int`): The color in the center of the backdrop in RGB format. + edge_color (:obj:`int`): The color on the edges of the backdrop in RGB format. + symbol_color (:obj:`int`): The color to be applied to the symbol in RGB format. + text_color (:obj:`int`): The color for the text on the backdrop in RGB format. + + Attributes: + center_color (:obj:`int`): The color in the center of the backdrop in RGB format. + edge_color (:obj:`int`): The color on the edges of the backdrop in RGB format. + symbol_color (:obj:`int`): The color to be applied to the symbol in RGB format. + text_color (:obj:`int`): The color for the text on the backdrop in RGB format. + + """ + + __slots__ = ( + "center_color", + "edge_color", + "symbol_color", + "text_color", + ) + + def __init__( + self, + center_color: int, + edge_color: int, + symbol_color: int, + text_color: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.center_color: int = center_color + self.edge_color: int = edge_color + self.symbol_color: int = symbol_color + self.text_color: int = text_color + + self._id_attrs = (self.center_color, self.edge_color, self.symbol_color, self.text_color) + + self._freeze() + + +class UniqueGiftBackdrop(TelegramObject): + """This object describes the backdrop of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`name`, :attr:`colors`, and :attr:`rarity_per_mille` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + name (:obj:`str`): Name of the backdrop. + colors (:class:`telegram.UniqueGiftBackdropColors`): Colors of the backdrop. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this backdrop + for every ``1000`` gifts upgraded. + + Attributes: + name (:obj:`str`): Name of the backdrop. + colors (:class:`telegram.UniqueGiftBackdropColors`): Colors of the backdrop. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this backdrop + for every ``1000`` gifts upgraded. + + """ + + __slots__ = ( + "colors", + "name", + "rarity_per_mille", + ) + + def __init__( + self, + name: str, + colors: UniqueGiftBackdropColors, + rarity_per_mille: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.name: str = name + self.colors: UniqueGiftBackdropColors = colors + self.rarity_per_mille: int = rarity_per_mille + + self._id_attrs = (self.name, self.colors, self.rarity_per_mille) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "UniqueGiftBackdrop": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["colors"] = de_json_optional(data.get("colors"), UniqueGiftBackdropColors, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGift(TelegramObject): + """This object describes a unique gift that was upgraded from a regular gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`base_name`, :attr:`name`, :attr:`number`, :class:`model`, + :attr:`symbol`, and :attr:`backdrop` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + base_name (:obj:`str`): Human-readable name of the regular gift from which this unique + gift was upgraded. + name (:obj:`str`): Unique name of the gift. This name can be used + in ``https://t.me/nft/...`` links and story areas. + number (:obj:`int`): Unique number of the upgraded gift among gifts upgraded from the + same regular gift. + model (:class:`UniqueGiftModel`): Model of the gift. + symbol (:class:`UniqueGiftSymbol`): Symbol of the gift. + backdrop (:class:`UniqueGiftBackdrop`): Backdrop of the gift. + + Attributes: + base_name (:obj:`str`): Human-readable name of the regular gift from which this unique + gift was upgraded. + name (:obj:`str`): Unique name of the gift. This name can be used + in ``https://t.me/nft/...`` links and story areas. + number (:obj:`int`): Unique number of the upgraded gift among gifts upgraded from the + same regular gift. + model (:class:`telegram.UniqueGiftModel`): Model of the gift. + symbol (:class:`telegram.UniqueGiftSymbol`): Symbol of the gift. + backdrop (:class:`telegram.UniqueGiftBackdrop`): Backdrop of the gift. + + """ + + __slots__ = ( + "backdrop", + "base_name", + "model", + "name", + "number", + "symbol", + ) + + def __init__( + self, + base_name: str, + name: str, + number: int, + model: UniqueGiftModel, + symbol: UniqueGiftSymbol, + backdrop: UniqueGiftBackdrop, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.base_name: str = base_name + self.name: str = name + self.number: int = number + self.model: UniqueGiftModel = model + self.symbol: UniqueGiftSymbol = symbol + self.backdrop: UniqueGiftBackdrop = backdrop + + self._id_attrs = ( + self.base_name, + self.name, + self.number, + self.model, + self.symbol, + self.backdrop, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "UniqueGift": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["model"] = de_json_optional(data.get("model"), UniqueGiftModel, bot) + data["symbol"] = de_json_optional(data.get("symbol"), UniqueGiftSymbol, bot) + data["backdrop"] = de_json_optional(data.get("backdrop"), UniqueGiftBackdrop, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGiftInfo(TelegramObject): + """Describes a service message about a unique gift that was sent or received. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`gift`, and :attr:`origin` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + gift (:class:`UniqueGift`): Information about the gift. + origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` + or :attr:`TRANSFER`. + owned_gift_id (:obj:`str`, optional) Unique identifier of the received gift for the + bot; only present for gifts received on behalf of business accounts. + transfer_star_count (:obj:`int`, optional): Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + + Attributes: + gift (:class:`UniqueGift`): Information about the gift. + origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` + or :attr:`TRANSFER`. + owned_gift_id (:obj:`str`) Optional. Unique identifier of the received gift for the + bot; only present for gifts received on behalf of business accounts. + transfer_star_count (:obj:`int`): Optional. Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + + """ + + UPGRADE: Final[str] = constants.UniqueGiftInfoOrigin.UPGRADE + """:const:`telegram.constants.UniqueGiftInfoOrigin.UPGRADE`""" + TRANSFER: Final[str] = constants.UniqueGiftInfoOrigin.TRANSFER + """:const:`telegram.constants.UniqueGiftInfoOrigin.TRANSFER`""" + + __slots__ = ( + "gift", + "origin", + "owned_gift_id", + "transfer_star_count", + ) + + def __init__( + self, + gift: UniqueGift, + origin: str, + owned_gift_id: Optional[str] = None, + transfer_star_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.gift: UniqueGift = gift + self.origin: str = enum.get_member(constants.UniqueGiftInfoOrigin, origin, origin) + # Optional + self.owned_gift_id: Optional[str] = owned_gift_id + self.transfer_star_count: Optional[int] = transfer_star_count + + self._id_attrs = (self.gift, self.origin) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "UniqueGiftInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gift"] = de_json_optional(data.get("gift"), UniqueGift, bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/constants.py b/telegram/constants.py index b7e98462fac..85fbceeda3a 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -103,6 +103,7 @@ "StickerSetLimit", "StickerType", "TransactionPartnerType", + "UniqueGiftInfoOrigin", "UpdateType", "UserProfilePhotosLimit", "VerifyLimit", @@ -1984,6 +1985,11 @@ class MessageType(StringEnum): .. versionadded:: 20.8 """ + GIFT = "gift" + """:obj:`str`: Messages with :attr:`telegram.Message.gift`. + + .. versionadded:: NEXT.VERSION + """ GIVEAWAY = "giveaway" """:obj:`str`: Messages with :attr:`telegram.Message.giveaway`. @@ -2067,6 +2073,11 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.successful_payment`.""" TEXT = "text" """:obj:`str`: Messages with :attr:`telegram.Message.text`.""" + UNIQUE_GIFT = "unique_gift" + """:obj:`str`: Messages with :attr:`telegram.Message.unique_gift`. + + .. versionadded:: NEXT.VERSION + """ USERS_SHARED = "users_shared" """:obj:`str`: Messages with :attr:`telegram.Message.users_shared`. @@ -2810,6 +2821,21 @@ class PollType(StringEnum): """:obj:`str`: quiz polls.""" +class UniqueGiftInfoOrigin(StringEnum): + """This enum contains the available origins for :class:`telegram.UniqueGiftInfo`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + UPGRADE = "upgrade" + """:obj:`str` gift upgraded""" + TRANSFER = "transfer" + """:obj:`str` gift transfered""" + + class UpdateType(StringEnum): """This enum contains the available types of :class:`telegram.Update`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. diff --git a/telegram/ext/filters.py b/telegram/ext/filters.py index 8757fe3e7d8..13224e1e9db 100644 --- a/telegram/ext/filters.py +++ b/telegram/ext/filters.py @@ -1926,6 +1926,7 @@ def filter(self, update: Update) -> bool: or StatusUpdate.FORUM_TOPIC_REOPENED.check_update(update) or StatusUpdate.GENERAL_FORUM_TOPIC_HIDDEN.check_update(update) or StatusUpdate.GENERAL_FORUM_TOPIC_UNHIDDEN.check_update(update) + or StatusUpdate.GIFT.check_update(update) or StatusUpdate.GIVEAWAY_COMPLETED.check_update(update) or StatusUpdate.GIVEAWAY_CREATED.check_update(update) or StatusUpdate.LEFT_CHAT_MEMBER.check_update(update) @@ -1937,6 +1938,7 @@ def filter(self, update: Update) -> bool: or StatusUpdate.PINNED_MESSAGE.check_update(update) or StatusUpdate.PROXIMITY_ALERT_TRIGGERED.check_update(update) or StatusUpdate.REFUNDED_PAYMENT.check_update(update) + or StatusUpdate.UNIQUE_GIFT.check_update(update) or StatusUpdate.USERS_SHARED.check_update(update) or StatusUpdate.VIDEO_CHAT_ENDED.check_update(update) or StatusUpdate.VIDEO_CHAT_PARTICIPANTS_INVITED.check_update(update) @@ -2079,6 +2081,18 @@ def filter(self, message: Message) -> bool: .. versionadded:: 20.0 """ + class _Gift(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.gift) + + GIFT = _Gift(name="filters.StatusUpdate.GIFT") + """Messages that contain :attr:`telegram.Message.gift`. + + .. versionadded:: NEXT.VERSION + """ + class _GiveawayCreated(MessageFilter): __slots__ = () @@ -2193,6 +2207,18 @@ def filter(self, message: Message) -> bool: .. versionadded:: 21.4 """ + class _UniqueGift(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.unique_gift) + + UNIQUE_GIFT = _UniqueGift(name="filters.StatusUpdate.UNIQUE_GIFT") + """Messages that contain :attr:`telegram.Message.unique_gift`. + + .. versionadded:: NEXT.VERSION + """ + class _UsersShared(MessageFilter): __slots__ = () diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index b8ef90935ed..6ab3672506c 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -1098,6 +1098,16 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.REFUNDED_PAYMENT.check_update(update) update.message.refunded_payment = None + update.message.gift = "gift" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.GIFT.check_update(update) + update.message.gift = None + + update.message.unique_gift = "unique_gift" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.UNIQUE_GIFT.check_update(update) + update.message.unique_gift = None + def test_filters_forwarded(self, update, message_origin_user): assert filters.FORWARDED.check_update(update) update.message.forward_origin = MessageOriginHiddenUser(dtm.datetime.utcnow(), 1) diff --git a/tests/test_gifts.py b/tests/test_gifts.py index 3b3ef52cb39..7f040364e68 100644 --- a/tests/test_gifts.py +++ b/tests/test_gifts.py @@ -20,7 +20,7 @@ import pytest -from telegram import BotCommand, Gift, Gifts, MessageEntity, Sticker +from telegram import BotCommand, Gift, GiftInfo, Gifts, MessageEntity, Sticker from telegram._utils.defaultvalue import DEFAULT_NONE from telegram.request import RequestData from tests.auxil.slots import mro_slots @@ -291,3 +291,111 @@ 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) + + +@pytest.fixture +def gift_info(): + return GiftInfo( + gift=GiftInfoTestBase.gift, + owned_gift_id=GiftInfoTestBase.owned_gift_id, + convert_star_count=GiftInfoTestBase.convert_star_count, + prepaid_upgrade_star_count=GiftInfoTestBase.prepaid_upgrade_star_count, + can_be_upgraded=GiftInfoTestBase.can_be_upgraded, + text=GiftInfoTestBase.text, + entities=GiftInfoTestBase.entities, + is_private=GiftInfoTestBase.is_private, + ) + + +class GiftInfoTestBase: + gift = Gift( + id="some_id", + sticker=Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + star_count=5, + total_count=10, + remaining_count=15, + upgrade_star_count=20, + ) + owned_gift_id = "some_owned_gift_id" + convert_star_count = 100 + prepaid_upgrade_star_count = 200 + can_be_upgraded = True + text = "test text" + entities = ( + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ) + is_private = True + + +class TestGiftInfoWithoutRequest(GiftInfoTestBase): + def test_slot_behaviour(self, gift_info): + for attr in gift_info.__slots__: + assert getattr(gift_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(gift_info)) == len(set(mro_slots(gift_info))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "gift": self.gift.to_dict(), + "owned_gift_id": self.owned_gift_id, + "convert_star_count": self.convert_star_count, + "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, + "can_be_upgraded": self.can_be_upgraded, + "text": self.text, + "entities": [e.to_dict() for e in self.entities], + "is_private": self.is_private, + } + gift_info = GiftInfo.de_json(json_dict, offline_bot) + assert gift_info.api_kwargs == {} + assert gift_info.gift == self.gift + assert gift_info.owned_gift_id == self.owned_gift_id + assert gift_info.convert_star_count == self.convert_star_count + assert gift_info.prepaid_upgrade_star_count == self.prepaid_upgrade_star_count + assert gift_info.can_be_upgraded == self.can_be_upgraded + assert gift_info.text == self.text + assert gift_info.entities == self.entities + assert gift_info.is_private == self.is_private + + def test_to_dict(self, gift_info): + json_dict = gift_info.to_dict() + assert json_dict["gift"] == self.gift.to_dict() + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["convert_star_count"] == self.convert_star_count + assert json_dict["prepaid_upgrade_star_count"] == self.prepaid_upgrade_star_count + assert json_dict["can_be_upgraded"] == self.can_be_upgraded + assert json_dict["text"] == self.text + assert json_dict["entities"] == [e.to_dict() for e in self.entities] + assert json_dict["is_private"] == self.is_private + + def test_parse_entity(self, gift_info): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + + assert gift_info.parse_entity(entity) == "test" + + def test_parse_entities(self, gift_info): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 8) + + assert gift_info.parse_entities(MessageEntity.BOLD) == {entity: "test"} + assert gift_info.parse_entities() == {entity: "test", entity_2: "text"} + + def test_equality(self, gift_info): + a = gift_info + b = GiftInfo(gift=self.gift) + c = GiftInfo( + gift=Gift( + id="some_other_gift_id", + sticker=Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + star_count=5, + ), + ) + 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) diff --git a/tests/test_message.py b/tests/test_message.py index c713cd38862..7c78e37697c 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -75,6 +75,15 @@ Voice, WebAppData, ) +from telegram._gifts import Gift, GiftInfo +from telegram._uniquegift import ( + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) from telegram._utils.datetime import UTC from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import ODVInput @@ -232,6 +241,42 @@ def message(bot): {"message_thread_id": 123}, {"users_shared": UsersShared(1, users=[SharedUser(2, "user2"), SharedUser(3, "user3")])}, {"chat_shared": ChatShared(3, 4)}, + { + "gift": GiftInfo( + gift=Gift( + "gift_id", + Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + 5, + ) + ) + }, + { + "unique_gift": UniqueGiftInfo( + gift=UniqueGift( + "human_readable_name", + "unique_name", + 2, + UniqueGiftModel( + "model_name", + Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + 10, + ), + UniqueGiftSymbol( + "symbol_name", + Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + 20, + ), + UniqueGiftBackdrop( + "backdrop_name", + UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + 30, + ), + ), + origin=UniqueGiftInfo.UPGRADE, + owned_gift_id="id", + transfer_star_count=10, + ) + }, { "giveaway": Giveaway( chats=[Chat(1, Chat.SUPERGROUP)], @@ -337,6 +382,8 @@ def message(bot): "message_thread_id", "users_shared", "chat_shared", + "gift", + "unique_gift", "giveaway", "giveaway_created", "giveaway_winners", diff --git a/tests/test_uniquegift.py b/tests/test_uniquegift.py new file mode 100644 index 00000000000..051974b959b --- /dev/null +++ b/tests/test_uniquegift.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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 pytest + +from telegram import ( + BotCommand, + Sticker, + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def unique_gift(): + return UniqueGift( + base_name=UniqueGiftTestBase.base_name, + name=UniqueGiftTestBase.name, + number=UniqueGiftTestBase.number, + model=UniqueGiftTestBase.model, + symbol=UniqueGiftTestBase.symbol, + backdrop=UniqueGiftTestBase.backdrop, + ) + + +class UniqueGiftTestBase: + base_name = "human_readable" + name = "unique_name" + number = 10 + model = UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ) + symbol = UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ) + backdrop = UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=30, + ) + + +class TestUniqueGiftWithoutRequest(UniqueGiftTestBase): + def test_slot_behaviour(self, unique_gift): + for attr in unique_gift.__slots__: + assert getattr(unique_gift, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift)) == len(set(mro_slots(unique_gift))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "base_name": self.base_name, + "name": self.name, + "number": self.number, + "model": self.model.to_dict(), + "symbol": self.symbol.to_dict(), + "backdrop": self.backdrop.to_dict(), + } + unique_gift = UniqueGift.de_json(json_dict, offline_bot) + assert unique_gift.api_kwargs == {} + + assert unique_gift.base_name == self.base_name + assert unique_gift.name == self.name + assert unique_gift.number == self.number + assert unique_gift.model == self.model + assert unique_gift.symbol == self.symbol + assert unique_gift.backdrop == self.backdrop + + def test_to_dict(self, unique_gift): + gift_dict = unique_gift.to_dict() + + assert isinstance(gift_dict, dict) + assert gift_dict["base_name"] == self.base_name + assert gift_dict["name"] == self.name + assert gift_dict["number"] == self.number + assert gift_dict["model"] == self.model.to_dict() + assert gift_dict["symbol"] == self.symbol.to_dict() + assert gift_dict["backdrop"] == self.backdrop.to_dict() + + def test_equality(self, unique_gift): + a = unique_gift + b = UniqueGift( + self.base_name, + self.name, + self.number, + self.model, + self.symbol, + self.backdrop, + ) + c = UniqueGift( + "other_base_name", + self.name, + self.number, + self.model, + self.symbol, + self.backdrop, + ) + 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 unique_gift_model(): + return UniqueGiftModel( + name=UniqueGiftModelTestBase.name, + sticker=UniqueGiftModelTestBase.sticker, + rarity_per_mille=UniqueGiftModelTestBase.rarity_per_mille, + ) + + +class UniqueGiftModelTestBase: + name = "model_name" + sticker = Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular") + rarity_per_mille = 10 + + +class TestUniqueGiftModelWithoutRequest(UniqueGiftModelTestBase): + def test_slot_behaviour(self, unique_gift_model): + for attr in unique_gift_model.__slots__: + assert getattr(unique_gift_model, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_model)) == len( + set(mro_slots(unique_gift_model)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "name": self.name, + "sticker": self.sticker.to_dict(), + "rarity_per_mille": self.rarity_per_mille, + } + unique_gift_model = UniqueGiftModel.de_json(json_dict, offline_bot) + assert unique_gift_model.api_kwargs == {} + assert unique_gift_model.name == self.name + assert unique_gift_model.sticker == self.sticker + assert unique_gift_model.rarity_per_mille == self.rarity_per_mille + + def test_to_dict(self, unique_gift_model): + json_dict = unique_gift_model.to_dict() + assert json_dict["name"] == self.name + assert json_dict["sticker"] == self.sticker.to_dict() + assert json_dict["rarity_per_mille"] == self.rarity_per_mille + + def test_equality(self, unique_gift_model): + a = unique_gift_model + b = UniqueGiftModel(self.name, self.sticker, self.rarity_per_mille) + c = UniqueGiftModel("other_name", self.sticker, self.rarity_per_mille) + d = UniqueGiftSymbol(self.name, self.sticker, self.rarity_per_mille) + + 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 unique_gift_symbol(): + return UniqueGiftSymbol( + name=UniqueGiftSymbolTestBase.name, + sticker=UniqueGiftSymbolTestBase.sticker, + rarity_per_mille=UniqueGiftSymbolTestBase.rarity_per_mille, + ) + + +class UniqueGiftSymbolTestBase: + name = "symbol_name" + sticker = Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular") + rarity_per_mille = 20 + + +class TestUniqueGiftSymbolWithoutRequest(UniqueGiftSymbolTestBase): + def test_slot_behaviour(self, unique_gift_symbol): + for attr in unique_gift_symbol.__slots__: + assert getattr(unique_gift_symbol, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_symbol)) == len( + set(mro_slots(unique_gift_symbol)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "name": self.name, + "sticker": self.sticker.to_dict(), + "rarity_per_mille": self.rarity_per_mille, + } + unique_gift_symbol = UniqueGiftSymbol.de_json(json_dict, offline_bot) + assert unique_gift_symbol.api_kwargs == {} + assert unique_gift_symbol.name == self.name + assert unique_gift_symbol.sticker == self.sticker + assert unique_gift_symbol.rarity_per_mille == self.rarity_per_mille + + def test_to_dict(self, unique_gift_symbol): + json_dict = unique_gift_symbol.to_dict() + assert json_dict["name"] == self.name + assert json_dict["sticker"] == self.sticker.to_dict() + assert json_dict["rarity_per_mille"] == self.rarity_per_mille + + def test_equality(self, unique_gift_symbol): + a = unique_gift_symbol + b = UniqueGiftSymbol(self.name, self.sticker, self.rarity_per_mille) + c = UniqueGiftSymbol("other_name", self.sticker, self.rarity_per_mille) + d = UniqueGiftModel(self.name, self.sticker, self.rarity_per_mille) + + 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 unique_gift_backdrop(): + return UniqueGiftBackdrop( + name=UniqueGiftBackdropTestBase.name, + colors=UniqueGiftBackdropTestBase.colors, + rarity_per_mille=UniqueGiftBackdropTestBase.rarity_per_mille, + ) + + +class UniqueGiftBackdropTestBase: + name = "backdrop_name" + colors = UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F) + rarity_per_mille = 30 + + +class TestUniqueGiftBackdropWithoutRequest(UniqueGiftBackdropTestBase): + def test_slot_behaviour(self, unique_gift_backdrop): + for attr in unique_gift_backdrop.__slots__: + assert getattr(unique_gift_backdrop, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_backdrop)) == len( + set(mro_slots(unique_gift_backdrop)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "name": self.name, + "colors": self.colors.to_dict(), + "rarity_per_mille": self.rarity_per_mille, + } + unique_gift_backdrop = UniqueGiftBackdrop.de_json(json_dict, offline_bot) + assert unique_gift_backdrop.api_kwargs == {} + assert unique_gift_backdrop.name == self.name + assert unique_gift_backdrop.colors == self.colors + assert unique_gift_backdrop.rarity_per_mille == self.rarity_per_mille + + def test_to_dict(self, unique_gift_backdrop): + json_dict = unique_gift_backdrop.to_dict() + assert json_dict["name"] == self.name + assert json_dict["colors"] == self.colors.to_dict() + assert json_dict["rarity_per_mille"] == self.rarity_per_mille + + def test_equality(self, unique_gift_backdrop): + a = unique_gift_backdrop + b = UniqueGiftBackdrop(self.name, self.colors, self.rarity_per_mille) + c = UniqueGiftBackdrop("other_name", self.colors, self.rarity_per_mille) + 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 unique_gift_backdrop_colors(): + return UniqueGiftBackdropColors( + center_color=UniqueGiftBackdropColorsTestBase.center_color, + edge_color=UniqueGiftBackdropColorsTestBase.edge_color, + symbol_color=UniqueGiftBackdropColorsTestBase.symbol_color, + text_color=UniqueGiftBackdropColorsTestBase.text_color, + ) + + +class UniqueGiftBackdropColorsTestBase: + center_color = 0x00FF00 + edge_color = 0xEE00FF + symbol_color = 0xAA22BB + text_color = 0x20FE8F + + +class TestUniqueGiftBackdropColorsWithoutRequest(UniqueGiftBackdropColorsTestBase): + def test_slot_behaviour(self, unique_gift_backdrop_colors): + for attr in unique_gift_backdrop_colors.__slots__: + assert ( + getattr(unique_gift_backdrop_colors, attr, "err") != "err" + ), f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_backdrop_colors)) == len( + set(mro_slots(unique_gift_backdrop_colors)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "center_color": self.center_color, + "edge_color": self.edge_color, + "symbol_color": self.symbol_color, + "text_color": self.text_color, + } + unique_gift_backdrop_colors = UniqueGiftBackdropColors.de_json(json_dict, offline_bot) + assert unique_gift_backdrop_colors.api_kwargs == {} + assert unique_gift_backdrop_colors.center_color == self.center_color + assert unique_gift_backdrop_colors.edge_color == self.edge_color + assert unique_gift_backdrop_colors.symbol_color == self.symbol_color + assert unique_gift_backdrop_colors.text_color == self.text_color + + def test_to_dict(self, unique_gift_backdrop_colors): + json_dict = unique_gift_backdrop_colors.to_dict() + assert json_dict["center_color"] == self.center_color + assert json_dict["edge_color"] == self.edge_color + assert json_dict["symbol_color"] == self.symbol_color + assert json_dict["text_color"] == self.text_color + + def test_equality(self, unique_gift_backdrop_colors): + a = unique_gift_backdrop_colors + b = UniqueGiftBackdropColors( + center_color=self.center_color, + edge_color=self.edge_color, + symbol_color=self.symbol_color, + text_color=self.text_color, + ) + c = UniqueGiftBackdropColors( + center_color=0x000000, + edge_color=self.edge_color, + symbol_color=self.symbol_color, + text_color=self.text_color, + ) + 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 unique_gift_info(): + return UniqueGiftInfo( + gift=UniqueGiftInfoTestBase.gift, + origin=UniqueGiftInfoTestBase.origin, + owned_gift_id=UniqueGiftInfoTestBase.owned_gift_id, + transfer_star_count=UniqueGiftInfoTestBase.transfer_star_count, + ) + + +class UniqueGiftInfoTestBase: + gift = UniqueGift( + "human_readable_name", + "unique_name", + 10, + UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ), + UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=2, + ), + ) + origin = UniqueGiftInfo.UPGRADE + owned_gift_id = "some_id" + transfer_star_count = 10 + + +class TestUniqueGiftInfoWithoutRequest(UniqueGiftInfoTestBase): + def test_slot_behaviour(self, unique_gift_info): + for attr in unique_gift_info.__slots__: + assert getattr(unique_gift_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_info)) == len( + set(mro_slots(unique_gift_info)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "gift": self.gift.to_dict(), + "origin": self.origin, + "owned_gift_id": self.owned_gift_id, + "transfer_star_count": self.transfer_star_count, + } + unique_gift_info = UniqueGiftInfo.de_json(json_dict, offline_bot) + assert unique_gift_info.api_kwargs == {} + assert unique_gift_info.gift == self.gift + assert unique_gift_info.origin == self.origin + assert unique_gift_info.owned_gift_id == self.owned_gift_id + assert unique_gift_info.transfer_star_count == self.transfer_star_count + + def test_to_dict(self, unique_gift_info): + json_dict = unique_gift_info.to_dict() + assert json_dict["gift"] == self.gift.to_dict() + assert json_dict["origin"] == self.origin + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["transfer_star_count"] == self.transfer_star_count + + def test_equality(self, unique_gift_info): + a = unique_gift_info + b = UniqueGiftInfo(self.gift, self.origin, self.owned_gift_id, self.transfer_star_count) + c = UniqueGiftInfo( + self.gift, UniqueGiftInfo.TRANSFER, self.owned_gift_id, self.transfer_star_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) From ed7238e1c5f2d1cf66a7a81417da05c9c89fbb5c Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Tue, 15 Apr 2025 23:48:58 +0300 Subject: [PATCH 02/10] Add `AcceptedGiftTypes` class and tests. Gifts - Added the class `AcceptedGiftTypes` describing the types of gifts that are accepted by a user or a chat. --- docs/source/telegram.acceptedgifttypes.rst | 6 ++ docs/source/telegram.at-tree.rst | 1 + telegram/__init__.py | 3 +- telegram/_gifts.py | 59 ++++++++++++++++++ tests/test_gifts.py | 70 ++++++++++++++++++++++ 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 docs/source/telegram.acceptedgifttypes.rst diff --git a/docs/source/telegram.acceptedgifttypes.rst b/docs/source/telegram.acceptedgifttypes.rst new file mode 100644 index 00000000000..2926dffd338 --- /dev/null +++ b/docs/source/telegram.acceptedgifttypes.rst @@ -0,0 +1,6 @@ +AcceptedGiftTypes +================= + +.. autoclass:: telegram.AcceptedGiftTypes + :members: + :show-inheritance: diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index edf5080935e..acea2d106ff 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -4,6 +4,7 @@ Available Types .. toctree:: :titlesonly: + telegram.acceptedgifttypes telegram.animation telegram.audio telegram.birthdate diff --git a/telegram/__init__.py b/telegram/__init__.py index 4b67addbeca..14c70cd9608 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -20,6 +20,7 @@ __author__ = "devs@python-telegram-bot.org" __all__ = ( + "AcceptedGiftTypes", "AffiliateInfo", "Animation", "Audio", @@ -400,7 +401,7 @@ from ._games.callbackgame import CallbackGame from ._games.game import Game from ._games.gamehighscore import GameHighScore -from ._gifts import Gift, GiftInfo, Gifts +from ._gifts import AcceptedGiftTypes, Gift, GiftInfo, 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 index 9be2d938247..ad17451aa19 100644 --- a/telegram/_gifts.py +++ b/telegram/_gifts.py @@ -296,3 +296,62 @@ def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntit raise RuntimeError("This GiftInfo has no 'text'.") return parse_message_entities(self.text, self.entities, types) + + +class AcceptedGiftTypes(TelegramObject): + """This object describes the types of gifts that can be gifted to a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`unlimited_gifts`, :attr:`limited_gifts`, + :attr:`unique_gifts` and :attr:`premium_subscription` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + unlimited_gifts (:class:`bool`): :obj:`True`, if unlimited regular gifts are accepted. + limited_gifts (:class:`bool`): :obj:`True`, if limited regular gifts are accepted. + unique_gifts (:class:`bool`): :obj:`True`, if unique gifts or gifts that can be upgraded + to unique for free are accepted. + premium_subscription (:class:`bool`): :obj:`True`, if a Telegram Premium subscription + is accepted. + + Attributes: + unlimited_gifts (:class:`bool`): :obj:`True`, if unlimited regular gifts are accepted. + limited_gifts (:class:`bool`): :obj:`True`, if limited regular gifts are accepted. + unique_gifts (:class:`bool`): :obj:`True`, if unique gifts or gifts that can be upgraded + to unique for free are accepted. + premium_subscription (:class:`bool`): :obj:`True`, if a Telegram Premium subscription + is accepted. + + """ + + __slots__ = ( + "limited_gifts", + "premium_subscription", + "unique_gifts", + "unlimited_gifts", + ) + + def __init__( + self, + unlimited_gifts: bool, + limited_gifts: bool, + unique_gifts: bool, + premium_subscription: bool, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.unlimited_gifts: bool = unlimited_gifts + self.limited_gifts: bool = limited_gifts + self.unique_gifts: bool = unique_gifts + self.premium_subscription: bool = premium_subscription + + self._id_attrs = ( + self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + ) + + self._freeze() diff --git a/tests/test_gifts.py b/tests/test_gifts.py index 7f040364e68..a7257185f96 100644 --- a/tests/test_gifts.py +++ b/tests/test_gifts.py @@ -21,6 +21,7 @@ import pytest from telegram import BotCommand, Gift, GiftInfo, Gifts, MessageEntity, Sticker +from telegram._gifts import AcceptedGiftTypes from telegram._utils.defaultvalue import DEFAULT_NONE from telegram.request import RequestData from tests.auxil.slots import mro_slots @@ -399,3 +400,72 @@ def test_equality(self, gift_info): assert a != d assert hash(a) != hash(d) + + +@pytest.fixture +def accepted_gift_types(): + return AcceptedGiftTypes( + unlimited_gifts=AcceptedGiftTypesTestBase.unlimited_gifts, + limited_gifts=AcceptedGiftTypesTestBase.limited_gifts, + unique_gifts=AcceptedGiftTypesTestBase.unique_gifts, + premium_subscription=AcceptedGiftTypesTestBase.premium_subscription, + ) + + +class AcceptedGiftTypesTestBase: + unlimited_gifts = False + limited_gifts = True + unique_gifts = True + premium_subscription = True + + +class TestAcceptedGiftTypesWithoutRequest(AcceptedGiftTypesTestBase): + def test_slot_behaviour(self, accepted_gift_types): + for attr in accepted_gift_types.__slots__: + assert getattr(accepted_gift_types, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(accepted_gift_types)) == len( + set(mro_slots(accepted_gift_types)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "unlimited_gifts": self.unlimited_gifts, + "limited_gifts": self.limited_gifts, + "unique_gifts": self.unique_gifts, + "premium_subscription": self.premium_subscription, + } + accepted_gift_types = AcceptedGiftTypes.de_json(json_dict, offline_bot) + assert accepted_gift_types.api_kwargs == {} + assert accepted_gift_types.unlimited_gifts == self.unlimited_gifts + assert accepted_gift_types.limited_gifts == self.limited_gifts + assert accepted_gift_types.unique_gifts == self.unique_gifts + assert accepted_gift_types.premium_subscription == self.premium_subscription + + def test_to_dict(self, accepted_gift_types): + json_dict = accepted_gift_types.to_dict() + assert json_dict["unlimited_gifts"] == self.unlimited_gifts + assert json_dict["limited_gifts"] == self.limited_gifts + assert json_dict["unique_gifts"] == self.unique_gifts + assert json_dict["premium_subscription"] == self.premium_subscription + + def test_equality(self, accepted_gift_types): + a = accepted_gift_types + b = AcceptedGiftTypes( + self.unlimited_gifts, self.limited_gifts, self.unique_gifts, self.premium_subscription + ) + c = AcceptedGiftTypes( + not self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + ) + 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 ac1a191a0f8975e96a63a447f59eaacf128e9c31 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Wed, 16 Apr 2025 03:52:35 +0300 Subject: [PATCH 03/10] Replace field `ChatfullInfo.can_send_gift` with `accepted_gift_types`. and tests. Gifts - Replaced the field `can_send_gift` with the field `accepted_gift_types` of the type `AcceptedGiftTypes` in the class `ChatFullInfo`. --- telegram/_chatfullinfo.py | 69 ++++++++++++++++++++++++++++--- tests/auxil/dummy_objects.py | 4 ++ tests/test_chatfullinfo.py | 41 ++++++++++++++++++ tests/test_official/exceptions.py | 2 + 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/telegram/_chatfullinfo.py b/telegram/_chatfullinfo.py index 1ce640638e1..7d0bffe7e92 100644 --- a/telegram/_chatfullinfo.py +++ b/telegram/_chatfullinfo.py @@ -27,10 +27,17 @@ from telegram._chatlocation import ChatLocation from telegram._chatpermissions import ChatPermissions from telegram._files.chatphoto import ChatPhoto +from telegram._gifts import AcceptedGiftTypes from telegram._reaction import ReactionType from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict +from telegram._utils.warnings import warn +from telegram._utils.warnings_transition import ( + build_deprecation_warning_message, + warn_about_deprecated_attr_in_property, +) +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Bot, BusinessIntro, BusinessLocation, BusinessOpeningHours, Message @@ -64,6 +71,10 @@ class ChatFullInfo(_ChatBase): message in the chat. .. versionadded:: 21.2 + accepted_gift_types (:class:`telegram.AcceptedGiftTypes`): Information about types of + gifts that are accepted by the chat or by the corresponding user for private chats. + + .. versionadded:: NEXT.VERSION title (:obj:`str`, optional): Title, for supergroups, channels and group chats. username (:obj:`str`, optional): Username, for private chats, supergroups and channels if available. @@ -204,6 +215,10 @@ class ChatFullInfo(_ChatBase): .. versionadded:: 21.11 + .. deprecated:: NEXT.VERSION + Bot API 9.0 introduced :paramref:`accepted_gift_types`, replacing this argument. + Hence, this argument will be removed in future versions. + Attributes: id (:obj:`int`): Unique identifier for this chat. type (:obj:`str`): Type of chat, can be either :attr:`PRIVATE`, :attr:`GROUP`, @@ -218,6 +233,10 @@ class ChatFullInfo(_ChatBase): message in the chat. .. versionadded:: 21.2 + accepted_gift_types (:class:`telegram.AcceptedGiftTypes`): Information about types of + gifts that are accepted by the chat or by the corresponding user for private chats. + + .. versionadded:: NEXT.VERSION title (:obj:`str`, optional): Title, for supergroups, channels and group chats. username (:obj:`str`, optional): Username, for private chats, supergroups and channels if available. @@ -357,16 +376,15 @@ class ChatFullInfo(_ChatBase): sent or forwarded to the channel chat. The field is available only for channel chats. .. versionadded:: 21.4 - can_send_gift (:obj:`bool`): Optional. :obj:`True`, if gifts can be sent to the chat. - - .. versionadded:: 21.11 .. _accent colors: https://core.telegram.org/bots/api#accent-colors .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups """ __slots__ = ( + "_can_send_gift", "accent_color_id", + "accepted_gift_types", "active_usernames", "available_reactions", "background_custom_emoji_id", @@ -375,7 +393,6 @@ class ChatFullInfo(_ChatBase): "business_intro", "business_location", "business_opening_hours", - "can_send_gift", "can_send_paid_media", "can_set_sticker_set", "custom_emoji_sticker_set_name", @@ -452,7 +469,10 @@ def __init__( linked_chat_id: Optional[int] = None, location: Optional[ChatLocation] = None, can_send_paid_media: Optional[bool] = None, + # tags: deprecated NEXT.VERSION; bot api 9.0 can_send_gift: Optional[bool] = None, + # temporarily optional to account for changed signature + accepted_gift_types: Optional[AcceptedGiftTypes] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -466,6 +486,22 @@ def __init__( is_forum=is_forum, api_kwargs=api_kwargs, ) + if accepted_gift_types is None: + raise TypeError("`accepted_gift_type` is a required argument since Bot API 9.0") + + if can_send_gift is not None: + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + build_deprecation_warning_message( + deprecated_name="can_send_gift", + new_name="accepted_gift_types", + object_type="parameter", + bot_api_version="9.0", + ), + ), + stacklevel=2, + ) # Required and unique to this class- with self._unfrozen(): @@ -518,7 +554,27 @@ def __init__( self.business_location: Optional[BusinessLocation] = business_location self.business_opening_hours: Optional[BusinessOpeningHours] = business_opening_hours self.can_send_paid_media: Optional[bool] = can_send_paid_media - self.can_send_gift: Optional[bool] = can_send_gift + self._can_send_gift: Optional[bool] = can_send_gift + self.accepted_gift_types: AcceptedGiftTypes = accepted_gift_types + + @property + def can_send_gift(self) -> Optional[bool]: + """ + :obj:`bool`: Optional. :obj:`True`, if gifts can be sent to the chat. + + .. deprecated:: NEXT.VERSION + As Bot API 9.0 replaces this attribute with :attr:`accepted_gift_types`, this attribute + will be removed in future versions. + + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="can_send_gift", + new_attr_name="accepted_gift_types", + bot_api_version="9.0", + ptb_version="NEXT.VERSION", + stacklevel=2, + ) + return self._can_send_gift @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": @@ -533,6 +589,9 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": ) data["photo"] = de_json_optional(data.get("photo"), ChatPhoto, bot) + data["accepted_gift_types"] = de_json_optional( + data.get("accepted_gift_types"), AcceptedGiftTypes, bot + ) from telegram import ( # pylint: disable=import-outside-toplevel BusinessIntro, diff --git a/tests/auxil/dummy_objects.py b/tests/auxil/dummy_objects.py index 7e504f0db78..8c198359a9a 100644 --- a/tests/auxil/dummy_objects.py +++ b/tests/auxil/dummy_objects.py @@ -3,6 +3,7 @@ from typing import Union from telegram import ( + AcceptedGiftTypes, BotCommand, BotDescription, BotName, @@ -74,6 +75,9 @@ type="dummy_type", accent_color_id=1, max_reaction_count=1, + accepted_gift_types=AcceptedGiftTypes( + unlimited_gifts=True, limited_gifts=True, unique_gifts=True, premium_subscription=True + ), ), "ChatInviteLink": ChatInviteLink( "dummy_invite_link", diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index 11373567c9f..dff26aa7398 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -34,8 +34,10 @@ ReactionTypeCustomEmoji, ReactionTypeEmoji, ) +from telegram._gifts import AcceptedGiftTypes from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import ReactionEmoji +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -46,6 +48,7 @@ def chat_full_info(bot): type=ChatFullInfoTestBase.type_, accent_color_id=ChatFullInfoTestBase.accent_color_id, max_reaction_count=ChatFullInfoTestBase.max_reaction_count, + accepted_gift_types=ChatFullInfoTestBase.accepted_gift_types, title=ChatFullInfoTestBase.title, username=ChatFullInfoTestBase.username, sticker_set_name=ChatFullInfoTestBase.sticker_set_name, @@ -140,6 +143,8 @@ class ChatFullInfoTestBase: first_name = "first_name" last_name = "last_name" can_send_paid_media = True + can_send_gift = True + accepted_gift_types = AcceptedGiftTypes(True, True, True, True) class TestChatFullInfoWithoutRequest(ChatFullInfoTestBase): @@ -158,6 +163,8 @@ def test_de_json(self, offline_bot): "accent_color_id": self.accent_color_id, "max_reaction_count": self.max_reaction_count, "username": self.username, + "accepted_gift_types": self.accepted_gift_types.to_dict(), + "can_send_gift": self.can_send_gift, "sticker_set_name": self.sticker_set_name, "can_set_sticker_set": self.can_set_sticker_set, "permissions": self.permissions.to_dict(), @@ -195,10 +202,12 @@ def test_de_json(self, offline_bot): "can_send_paid_media": self.can_send_paid_media, } cfi = ChatFullInfo.de_json(json_dict, offline_bot) + assert cfi.api_kwargs == {} assert cfi.id == self.id_ assert cfi.title == self.title assert cfi.type == self.type_ assert cfi.username == self.username + assert cfi.accepted_gift_types == self.accepted_gift_types assert cfi.sticker_set_name == self.sticker_set_name assert cfi.can_set_sticker_set == self.can_set_sticker_set assert cfi.permissions == self.permissions @@ -245,6 +254,7 @@ def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): "type": self.type_, "accent_color_id": self.accent_color_id, "max_reaction_count": self.max_reaction_count, + "accepted_gift_types": self.accepted_gift_types.to_dict(), "emoji_status_expiration_date": to_timestamp(self.emoji_status_expiration_date), } cfi_bot = ChatFullInfo.de_json(json_dict, offline_bot) @@ -312,15 +322,46 @@ def test_to_dict(self, chat_full_info): assert cfi_dict["first_name"] == cfi.first_name assert cfi_dict["last_name"] == cfi.last_name assert cfi_dict["can_send_paid_media"] == cfi.can_send_paid_media + assert cfi_dict["accepted_gift_types"] == cfi.accepted_gift_types.to_dict() assert cfi_dict["max_reaction_count"] == cfi.max_reaction_count + def test_accepted_gift_types_is_required_argument(self): + with pytest.raises(TypeError, match="`accepted_gift_type` is a required argument"): + ChatFullInfo( + id=123, + type=Chat.PRIVATE, + accent_color_id=1, + max_reaction_count=2, + can_send_gift=True, + ) + + def test_can_send_gift_deprecation_warning(self): + with pytest.warns( + PTBDeprecationWarning, + match="'can_send_gift' was replaced by 'accepted_gift_types' in Bot API 9.0", + ): + chat_full_info = ChatFullInfo( + id=123, + type=Chat.PRIVATE, + accent_color_id=1, + max_reaction_count=2, + accepted_gift_types=self.accepted_gift_types, + can_send_gift=self.can_send_gift, + ) + with pytest.warns( + PTBDeprecationWarning, + match="Bot API 9.0 renamed the attribute 'can_send_gift' to 'accepted_gift_types'", + ): + chat_full_info.can_send_gift + def test_always_tuples_attributes(self): cfi = ChatFullInfo( id=123, type=Chat.PRIVATE, accent_color_id=1, max_reaction_count=2, + accepted_gift_types=self.accepted_gift_types, ) assert isinstance(cfi.active_usernames, tuple) assert cfi.active_usernames == () diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index c4a011808de..0b54c28c1d5 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -152,6 +152,7 @@ class ParamTypeCheckingExceptions: # backwards compatibility for api 9.0 changes # tags: deprecated NEXT.VERSION, bot api 9.0 "BusinessConnection": {"can_reply"}, + "ChatFullInfo": {"can_send_gift"}, } @@ -198,6 +199,7 @@ def ptb_ignored_params(object_name: str) -> set[str]: # backwards compatibility for api 9.0 changes # tags: deprecated NEXT.VERSION, bot api 9.0 "BusinessConnection": {"is_enabled"}, + "ChatFullInfo": {"accepted_gift_types"}, } From 2929bf841dba61ab6254f76ae069d0d78ea92933 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Fri, 18 Apr 2025 01:33:50 +0300 Subject: [PATCH 04/10] Add `OwnedGift*` classes and `getBusinessAccountGifts` method. plus tests. Business Accounts - Added the classes `OwnedGiftRegular`, `OwnedGiftUnique`, `OwnedGifts` and the method `getBusinessAccountGifts`, allowing bots to fetch the list of gifts owned by a managed business account. --- docs/source/inclusions/bot_methods.rst | 2 + docs/source/telegram.at-tree.rst | 4 + docs/source/telegram.ownedgift.rst | 6 + docs/source/telegram.ownedgiftregular.rst | 6 + docs/source/telegram.ownedgifts.rst | 6 + docs/source/telegram.ownedgiftunique.rst | 6 + telegram/__init__.py | 5 + telegram/_bot.py | 77 ++++ telegram/_ownedgift.py | 415 ++++++++++++++++++++ telegram/constants.py | 26 ++ telegram/ext/_extbot.py | 38 ++ tests/auxil/dummy_objects.py | 18 + tests/test_business_methods.py | 53 +++ tests/test_official/exceptions.py | 2 + tests/test_ownedgift.py | 449 ++++++++++++++++++++++ 15 files changed, 1113 insertions(+) create mode 100644 docs/source/telegram.ownedgift.rst create mode 100644 docs/source/telegram.ownedgiftregular.rst create mode 100644 docs/source/telegram.ownedgifts.rst create mode 100644 docs/source/telegram.ownedgiftunique.rst create mode 100644 telegram/_ownedgift.py create mode 100644 tests/test_ownedgift.py diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 3123490dd63..3a5c7a963cf 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -411,6 +411,8 @@ * - :meth:`~telegram.Bot.get_business_connection` - Used for getting information about the business account. + * - :meth:`~telegram.Bot.get_business_account_gifts` + - Used for getting gifts owned by the business account. * - :meth:`~telegram.Bot.read_business_message` - Used for marking a message as read. * - :meth:`~telegram.Bot.delete_business_messages` diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index acea2d106ff..16895e7a392 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -119,6 +119,10 @@ Available Types telegram.messageoriginuser telegram.messagereactioncountupdated telegram.messagereactionupdated + telegram.ownedgift.rst + telegram.ownedgiftregular.rst + telegram.ownedgifts.rst + telegram.ownedgiftunique.rst telegram.paidmedia telegram.paidmediainfo telegram.paidmediaphoto diff --git a/docs/source/telegram.ownedgift.rst b/docs/source/telegram.ownedgift.rst new file mode 100644 index 00000000000..0c726895c07 --- /dev/null +++ b/docs/source/telegram.ownedgift.rst @@ -0,0 +1,6 @@ +OwnedGift +========= + +.. autoclass:: telegram.OwnedGift + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgiftregular.rst b/docs/source/telegram.ownedgiftregular.rst new file mode 100644 index 00000000000..eb4f3641ed6 --- /dev/null +++ b/docs/source/telegram.ownedgiftregular.rst @@ -0,0 +1,6 @@ +OwnedGiftRegular +================ + +.. autoclass:: telegram.OwnedGiftRegular + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgifts.rst b/docs/source/telegram.ownedgifts.rst new file mode 100644 index 00000000000..71a1c51b86f --- /dev/null +++ b/docs/source/telegram.ownedgifts.rst @@ -0,0 +1,6 @@ +OwnedGifts +========== + +.. autoclass:: telegram.OwnedGifts + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgiftunique.rst b/docs/source/telegram.ownedgiftunique.rst new file mode 100644 index 00000000000..cc114fecc49 --- /dev/null +++ b/docs/source/telegram.ownedgiftunique.rst @@ -0,0 +1,6 @@ +OwnedGiftUnique +=============== + +.. autoclass:: telegram.OwnedGiftUnique + :members: + :show-inheritance: diff --git a/telegram/__init__.py b/telegram/__init__.py index 14c70cd9608..cd73967d4f0 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -183,6 +183,10 @@ "MessageReactionCountUpdated", "MessageReactionUpdated", "OrderInfo", + "OwnedGift", + "OwnedGiftRegular", + "OwnedGiftUnique", + "OwnedGifts", "PaidMedia", "PaidMediaInfo", "PaidMediaPhoto", @@ -453,6 +457,7 @@ MessageOriginUser, ) from ._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated +from ._ownedgift import OwnedGift, OwnedGiftRegular, OwnedGifts, OwnedGiftUnique from ._paidmedia import ( PaidMedia, PaidMediaInfo, diff --git a/telegram/_bot.py b/telegram/_bot.py index 68cb791fdc7..47cda1bb7e2 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -81,6 +81,7 @@ from telegram._menubutton import MenuButton from telegram._message import Message from telegram._messageid import MessageId +from telegram._ownedgift import OwnedGifts from telegram._payment.stars.startransactions import StarTransactions from telegram._poll import InputPollOption, Poll from telegram._reaction import ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji @@ -9401,6 +9402,80 @@ async def get_business_connection( bot=self, ) + async def get_business_account_gifts( + self, + business_connection_id: str, + exclude_unsaved: Optional[bool] = None, + exclude_saved: Optional[bool] = None, + exclude_unlimited: Optional[bool] = None, + exclude_limited: Optional[bool] = None, + exclude_unique: Optional[bool] = None, + sort_by_price: Optional[bool] = None, + offset: Optional[str] = None, + limit: Optional[int] = 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, + ) -> OwnedGifts: + """ + Returns the gifts received and owned by a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_view_gifts_and_stars` business bot right. + + .. versionadded:: NEXT.VERSION + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + exclude_unsaved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that aren't + saved to the account's profile page. + exclude_saved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that are saved + to the account's profile page. + exclude_unlimited (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that can + be purchased an unlimited number of times. + exclude_limited (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that can be + purchased a limited number of times. + exclude_unique (:obj:`bool`, optional): Pass :obj:`True` to exclude unique gifts. + sort_by_price (:obj:`bool`, optional): Pass :obj:`True` to sort results by gift price + instead of send date. Sorting is applied before pagination. + offset (:obj:`str`, optional): Offset of the first entry to return as received from + the previous request; use empty string to get the first chunk of results. + limit (:obj:`int`, optional): The maximum number of gifts to be returned; + :tg-const:`telegram.constants.BusinessLimit.MIN_GIFT_RESULTS`-\ + :tg-const:`telegram.constants.BusinessLimit.MAX_GIFT_RESULTS`. + Defaults to :tg-const:`telegram.constants.BusinessLimit.MAX_GIFT_RESULTS`. + + Returns: + :class:`telegram.OwnedGifts` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "exclude_unsaved": exclude_unsaved, + "exclude_saved": exclude_saved, + "exclude_unlimited": exclude_unlimited, + "exclude_limited": exclude_limited, + "exclude_unique": exclude_unique, + "sort_by_price": sort_by_price, + "offset": offset, + "limit": limit, + } + + return OwnedGifts.de_json( + await self._post( + "getBusinessAccountGifts", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + async def read_business_message( self, business_connection_id: str, @@ -10565,6 +10640,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`get_user_chat_boosts`""" setMessageReaction = set_message_reaction """Alias for :meth:`set_message_reaction`""" + getBusinessAccountGifts = get_business_account_gifts + """Alias for :meth:`get_business_account_gifts`""" getBusinessConnection = get_business_connection """Alias for :meth:`get_business_connection`""" readBusinessMessage = read_business_message diff --git a/telegram/_ownedgift.py b/telegram/_ownedgift.py new file mode 100644 index 00000000000..52f9ff45d87 --- /dev/null +++ b/telegram/_ownedgift.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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 objects that represent paid media in Telegram.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final, Optional + +from telegram import constants +from telegram._gifts import Gift +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._uniquegift import UniqueGift +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class OwnedGift(TelegramObject): + """This object describes a gift received and owned by a user or a chat. Currently, it + can be one of: + + * :class:`telegram.OwnedGiftRegular` + * :class:`telegram.OwnedGiftUnique` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + type (:obj:`str`): Type of the owned gift. + + Attributes: + type (:obj:`str`): Type of the owned gift. + """ + + __slots__ = ("type",) + + REGULAR: Final[str] = constants.OwnedGiftType.REGULAR + """:const:`telegram.constants.OwnedGiftType.REGULAR`""" + UNIQUE: Final[str] = constants.OwnedGiftType.UNIQUE + """:const:`telegram.constants.OwnedGiftType.UNIQUE`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.OwnedGiftType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "OwnedGift": + """Converts JSON data to the appropriate :class:`OwnedGift` object, i.e. takes + care of selecting the correct subclass. + + Args: + data (dict[:obj:`str`, ...]): The JSON data. + bot (:class:`telegram.Bot`, optional): The bot associated with this object. + + Returns: + The Telegram object. + + """ + data = cls._parse_data(data) + + _class_mapping: dict[str, type[OwnedGift]] = { + cls.REGULAR: OwnedGiftRegular, + cls.UNIQUE: OwnedGiftUnique, + } + + if cls is OwnedGift and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + return super().de_json(data=data, bot=bot) + + +class OwnedGifts(TelegramObject): + """Contains the list of gifts received and owned by a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`total_count` and :attr:`gifts` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + total_count (:obj:`int`): The total number of gifts owned by the user or the chat. + gifts (Sequence[:class:`telegram.OwnedGift`]): The list of gifts. + next_offset (:obj:`str`, optional): Offset for the next request. If empty, + then there are no more results. + + Attributes: + total_count (:obj:`int`): The total number of gifts owned by the user or the chat. + gifts (Sequence[:class:`telegram.OwnedGift`]): The list of gifts. + next_offset (:obj:`str`): Optional. Offset for the next request. If empty, + then there are no more results. + """ + + __slots__ = ( + "gifts", + "next_offset", + "total_count", + ) + + def __init__( + self, + total_count: int, + gifts: Sequence[OwnedGift], + next_offset: Optional[str] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.total_count: int = total_count + self.gifts: tuple[OwnedGift, ...] = parse_sequence_arg(gifts) + self.next_offset: Optional[str] = next_offset + + self._id_attrs = (self.total_count, self.gifts) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "OwnedGifts": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gifts"] = de_list_optional(data.get("gifts"), OwnedGift, bot) + return super().de_json(data=data, bot=bot) + + +class OwnedGiftRegular(OwnedGift): + """Describes a regular gift owned by a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`gift` and :attr:`send_date` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + gift (:class:`telegram.Gift`): Information about the regular gift. + owned_gift_id (:obj:`str`, optional): Unique identifier of the gift for the bot; for + gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`, optional): Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent in Unix time. + text (:obj:`str`, optional): Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities that + appear in the text. + is_private (:obj:`bool`, optional): :obj:`True`, if the sender and gift text are shown + only to the gift receiver; otherwise, everyone will be able to see them. + is_saved (:obj:`bool`, optional): :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_upgraded (:obj:`bool`, optional): :obj:`True`, if the gift can be upgraded to a + unique gift; for gifts received on behalf of business accounts only. + was_refunded (:obj:`bool`, optional): :obj:`True`, if the gift was refunded and isn't + available anymore. + convert_star_count (:obj:`int`, optional): Number of Telegram Stars that can be + claimed by the receiver instead of the gift; omitted if the gift cannot be converted + to Telegram Stars. + prepaid_upgrade_star_count (:obj:`int`, optional): Number of Telegram Stars that were + paid by the sender for the ability to upgrade the gift. + + Attributes: + type (:obj:`str`): Type of the gift, always :attr:`~telegram.OwnedGift.REGULAR`. + gift (:class:`telegram.Gift`): Information about the regular gift. + owned_gift_id (:obj:`str`): Optional. Unique identifier of the gift for the bot; for + gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`): Optional. Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent in Unix time. + text (:obj:`str`): Optional. Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`]): Optional. Special entities that + appear in the text. + is_private (:obj:`bool`): Optional. :obj:`True`, if the sender and gift text are shown + only to the gift receiver; otherwise, everyone will be able to see them. + is_saved (:obj:`bool`): Optional. :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_upgraded (:obj:`bool`): Optional. :obj:`True`, if the gift can be upgraded to a + unique gift; for gifts received on behalf of business accounts only. + was_refunded (:obj:`bool`): Optional. :obj:`True`, if the gift was refunded and isn't + available anymore. + convert_star_count (:obj:`int`): Optional. Number of Telegram Stars that can be + claimed by the receiver instead of the gift; omitted if the gift cannot be converted + to Telegram Stars. + prepaid_upgrade_star_count (:obj:`int`): Optional. Number of Telegram Stars that were + paid by the sender for the ability to upgrade the gift. + + """ + + __slots__ = ( + "can_be_upgraded", + "convert_star_count", + "entities", + "gift", + "is_private", + "is_saved", + "owned_gift_id", + "prepaid_upgrade_star_count", + "send_date", + "sender_user", + "text", + "was_refunded", + ) + + def __init__( + self, + gift: Gift, + send_date: dtm.datetime, + owned_gift_id: Optional[str] = None, + sender_user: Optional[User] = None, + text: Optional[str] = None, + entities: Optional[Sequence[MessageEntity]] = None, + is_private: Optional[bool] = None, + is_saved: Optional[bool] = None, + can_be_upgraded: Optional[bool] = None, + was_refunded: Optional[bool] = None, + convert_star_count: Optional[int] = None, + prepaid_upgrade_star_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=OwnedGift.REGULAR, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.gift: Gift = gift + self.send_date: dtm.datetime = send_date + self.owned_gift_id: Optional[str] = owned_gift_id + self.sender_user: Optional[User] = sender_user + self.text: Optional[str] = text + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.is_private: Optional[bool] = is_private + self.is_saved: Optional[bool] = is_saved + self.can_be_upgraded: Optional[bool] = can_be_upgraded + self.was_refunded: Optional[bool] = was_refunded + self.convert_star_count: Optional[int] = convert_star_count + self.prepaid_upgrade_star_count: Optional[int] = prepaid_upgrade_star_count + + self._id_attrs = (self.type, self.gift, self.send_date) + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "OwnedGiftRegular": + """See :meth:`telegram.OwnedGift.de_json`.""" + data = cls._parse_data(data) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + data["sender_user"] = de_json_optional(data.get("sender_user"), User, bot) + data["gift"] = de_json_optional(data.get("gift"), Gift, bot) + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`entities`. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the owned gift has no text. + + """ + if not self.text: + raise RuntimeError("This OwnedGiftRegular has no 'text'.") + + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this owned gift's text filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + Raises: + RuntimeError: If the owned gift has no text. + + """ + if not self.text: + raise RuntimeError("This OwnedGifRegular has no 'text'.") + + return parse_message_entities(self.text, self.entities, types) + + +class OwnedGiftUnique(OwnedGift): + """ + Describes a unique gift received and owned by a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`gift` and :attr:`send_date` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + gift (:class:`telegram.UniqueGift`): Information about the unique gift. + owned_gift_id (:obj:`str`, optional): Unique identifier of the received gift for the + bot; for gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`, optional): Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent in Unix time. + is_saved (:obj:`bool`, optional): :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_transferred (:obj:`bool`, optional): :obj:`True`, if the gift can be transferred to + another owner; for gifts received on behalf of business accounts only. + transfer_star_count (:obj:`int`, optional): Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + + Attributes: + type (:obj:`str`): Type of the owned gift, always :tg-const:`~telegram.OwnedGift.UNIQUE`. + gift (:class:`telegram.UniqueGift`): Information about the unique gift. + owned_gift_id (:obj:`str`): Optional. Unique identifier of the received gift for the + bot; for gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`): Optional. Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent in Unix time. + is_saved (:obj:`bool`): Optional. :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_transferred (:obj:`bool`): Optional. :obj:`True`, if the gift can be transferred to + another owner; for gifts received on behalf of business accounts only. + transfer_star_count (:obj:`int`): Optional. Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + """ + + __slots__ = ( + "can_be_transferred", + "gift", + "is_saved", + "owned_gift_id", + "send_date", + "sender_user", + "transfer_star_count", + ) + + def __init__( + self, + gift: UniqueGift, + send_date: dtm.datetime, + owned_gift_id: Optional[str] = None, + sender_user: Optional[User] = None, + is_saved: Optional[bool] = None, + can_be_transferred: Optional[bool] = None, + transfer_star_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=OwnedGift.UNIQUE, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.gift: UniqueGift = gift + self.send_date: dtm.datetime = send_date + self.owned_gift_id: Optional[str] = owned_gift_id + self.sender_user: Optional[User] = sender_user + self.is_saved: Optional[bool] = is_saved + self.can_be_transferred: Optional[bool] = can_be_transferred + self.transfer_star_count: Optional[int] = transfer_star_count + + self._id_attrs = (self.type, self.gift, self.send_date) + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "OwnedGiftUnique": + """See :meth:`telegram.OwnedGift.de_json`.""" + data = cls._parse_data(data) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + data["sender_user"] = de_json_optional(data.get("sender_user"), User, bot) + data["gift"] = de_json_optional(data.get("gift"), UniqueGift, bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] diff --git a/telegram/constants.py b/telegram/constants.py index 85fbceeda3a..f5f02536c2a 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -86,6 +86,7 @@ "MessageLimit", "MessageOriginType", "MessageType", + "OwnedGiftType", "PaidMediaType", "ParseMode", "PollLimit", @@ -736,6 +737,16 @@ class BusinessLimit(IntEnum): :paramref:`~telegram.Bot.set_business_account_bio.bio` of :meth:`telegram.Bot.set_business_account_bio`. """ + MIN_GIFT_RESULTS = 1 + """:obj:`int`: Minimum number of gifts to be returned. Relevant for + :paramref:`~telegram.Bot.get_business_account_gifts.limit` of + :meth:`telegram.Bot.get_business_account_gifts`. + """ + MAX_GIFT_RESULTS = 100 + """:obj:`int`: Maximum number of gifts to be returned. Relevant for + :paramref:`~telegram.Bot.get_business_account_gifts.limit` of + :meth:`telegram.Bot.get_business_account_gifts`. + """ class CallbackQueryLimit(IntEnum): @@ -2111,6 +2122,21 @@ class MessageType(StringEnum): """ +class OwnedGiftType(StringEnum): + """This enum contains the available types of :class:`telegram.OwnedGift`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + REGULAR = "regular" + """:obj:`str`: a regular owned gift.""" + UNIQUE = "unique" + """:obj:`str`: a unique owned gift.""" + + class PaidMediaType(StringEnum): """ This enum contains the available types of :class:`telegram.PaidMedia`. The enum diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 49308bc1534..8e61e468d99 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -69,6 +69,7 @@ MenuButton, Message, MessageId, + OwnedGifts, PhotoSize, Poll, PreparedInlineMessage, @@ -4262,6 +4263,42 @@ async def get_business_connection( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def get_business_account_gifts( + self, + business_connection_id: str, + exclude_unsaved: Optional[bool] = None, + exclude_saved: Optional[bool] = None, + exclude_unlimited: Optional[bool] = None, + exclude_limited: Optional[bool] = None, + exclude_unique: Optional[bool] = None, + sort_by_price: Optional[bool] = None, + offset: Optional[str] = None, + limit: Optional[int] = 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, + ) -> OwnedGifts: + return await super().get_business_account_gifts( + business_connection_id=business_connection_id, + exclude_unsaved=exclude_unsaved, + exclude_saved=exclude_saved, + exclude_unlimited=exclude_unlimited, + exclude_limited=exclude_limited, + exclude_unique=exclude_unique, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, + 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 read_business_message( self, business_connection_id: str, @@ -4826,6 +4863,7 @@ async def remove_user_verification( getUserChatBoosts = get_user_chat_boosts setMessageReaction = set_message_reaction getBusinessConnection = get_business_connection + getBusinessAccountGifts = get_business_account_gifts readBusinessMessage = read_business_message deleteBusinessMessages = delete_business_messages setBusinessAccountName = set_business_account_name diff --git a/tests/auxil/dummy_objects.py b/tests/auxil/dummy_objects.py index 8c198359a9a..fc15e789c44 100644 --- a/tests/auxil/dummy_objects.py +++ b/tests/auxil/dummy_objects.py @@ -23,6 +23,8 @@ Gifts, MenuButton, MessageId, + OwnedGiftRegular, + OwnedGifts, Poll, PollOption, PreparedInlineMessage, @@ -95,6 +97,22 @@ "MenuButton": MenuButton(type="dummy_type"), "Message": make_message("dummy_text"), "MessageId": MessageId(123456), + "OwnedGifts": OwnedGifts( + total_count=1, + gifts=[ + OwnedGiftRegular( + gift=Gift( + id="id1", + sticker=Sticker( + "file_id", "file_unique_id", 512, 512, False, False, "regular" + ), + star_count=5, + ), + send_date=_DUMMY_DATE, + owned_gift_id="some_id_1", + ) + ], + ), "Poll": Poll( id="dummy_id", question="dummy_question", diff --git a/tests/test_business_methods.py b/tests/test_business_methods.py index 1617553bba3..74edeb41133 100644 --- a/tests/test_business_methods.py +++ b/tests/test_business_methods.py @@ -21,6 +21,10 @@ import pytest from telegram import BusinessConnection, User +from telegram._files.sticker import Sticker +from telegram._gifts import Gift +from telegram._ownedgift import OwnedGiftRegular, OwnedGifts +from telegram._utils.datetime import UTC class BusinessMethodsTestBase: @@ -49,6 +53,55 @@ async def do_request(*args, **kwargs): obj = await offline_bot.get_business_connection(business_connection_id=self.bci) assert isinstance(obj, BusinessConnection) + @pytest.mark.parametrize("bool_param", [True, False, None]) + async def test_get_business_account_gifts(self, offline_bot, monkeypatch, bool_param): + offset = 50 + limit = 50 + owned_gifts = OwnedGifts( + total_count=1, + gifts=[ + OwnedGiftRegular( + gift=Gift( + id="id1", + sticker=Sticker( + "file_id", "file_unique_id", 512, 512, False, False, "regular" + ), + star_count=5, + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_1", + ) + ], + ).to_json() + + async def do_request_and_make_assertions(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("exclude_unsaved") is bool_param + assert data.get("exclude_saved") is bool_param + assert data.get("exclude_unlimited") is bool_param + assert data.get("exclude_limited") is bool_param + assert data.get("exclude_unique") is bool_param + assert data.get("sort_by_price") is bool_param + assert data.get("offset") == offset + assert data.get("limit") == limit + + return 200, f'{{"ok": true, "result": {owned_gifts}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request_and_make_assertions) + obj = await offline_bot.get_business_account_gifts( + business_connection_id=self.bci, + exclude_unsaved=bool_param, + exclude_saved=bool_param, + exclude_unlimited=bool_param, + exclude_limited=bool_param, + exclude_unique=bool_param, + sort_by_price=bool_param, + offset=offset, + limit=limit, + ) + assert isinstance(obj, OwnedGifts) + async def test_read_business_message(self, offline_bot, monkeypatch): chat_id = 43 message_id = 44 diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 0b54c28c1d5..715268620cf 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -144,6 +144,7 @@ class ParamTypeCheckingExceptions: "ReactionType": {"type"}, # attributes common to all subclasses "BackgroundType": {"type"}, # attributes common to all subclasses "BackgroundFill": {"type"}, # attributes common to all subclasses + "OwnedGift": {"type"}, # attributes common to all subclasses "InputTextMessageContent": {"disable_web_page_preview"}, # convenience arg, here for bw compat "RevenueWithdrawalState": {"type"}, # attributes common to all subclasses "TransactionPartner": {"type"}, # attributes common to all subclasses @@ -181,6 +182,7 @@ def ptb_extra_params(object_name: str) -> set[str]: r"TransactionPartner\w+": {"type"}, r"PaidMedia\w+": {"type"}, r"InputPaidMedia\w+": {"type"}, + r"OwnedGift\w+": {"type"}, } diff --git a/tests/test_ownedgift.py b/tests/test_ownedgift.py new file mode 100644 index 00000000000..805a4878f3b --- /dev/null +++ b/tests/test_ownedgift.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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 +from collections.abc import Sequence +from copy import deepcopy + +import pytest + +from telegram import Dice, PaidMedia, User +from telegram._files.sticker import Sticker +from telegram._gifts import Gift +from telegram._messageentity import MessageEntity +from telegram._ownedgift import OwnedGift, OwnedGiftRegular, OwnedGifts, OwnedGiftUnique +from telegram._uniquegift import ( + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftModel, + UniqueGiftSymbol, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import OwnedGiftType +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def owned_gift(): + return OwnedGift(type=OwnedGiftTestBase.type) + + +class OwnedGiftTestBase: + type = OwnedGiftType.REGULAR + 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, + ) + unique_gift = UniqueGift( + base_name="human_readable", + name="unique_name", + number=10, + model=UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ), + symbol=UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + backdrop=UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=30, + ), + ) + send_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + owned_gift_id = "not_real_id" + sender_user = User(1, "test user", False) + text = "test text" + entities = ( + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ) + is_private = True + is_saved = True + can_be_upgraded = True + was_refunded = False + convert_star_count = 100 + prepaid_upgrade_star_count = 200 + can_be_transferred = True + transfer_star_count = 300 + + +class TestOwnedGiftWithoutRequest(OwnedGiftTestBase): + def test_slot_behaviour(self, owned_gift): + inst = owned_gift + 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_type_enum_conversion(self, owned_gift): + assert type(OwnedGift("regular").type) is OwnedGiftType + assert PaidMedia("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + paid_media = OwnedGift.de_json(data, offline_bot) + assert paid_media.api_kwargs == {} + assert paid_media.type == "unknown" + + @pytest.mark.parametrize( + ("og_type", "subclass", "gift"), + [ + ("regular", OwnedGiftRegular, OwnedGiftTestBase.gift), + ("unique", OwnedGiftUnique, OwnedGiftTestBase.unique_gift), + ], + ) + def test_de_json_subclass(self, offline_bot, og_type, subclass, gift): + json_dict = { + "type": og_type, + "gift": gift.to_dict(), + "send_date": to_timestamp(self.send_date), + "owned_gift_id": self.owned_gift_id, + "sender_user": self.sender_user.to_dict(), + "text": self.text, + "entities": [e.to_dict() for e in self.entities], + "is_private": self.is_private, + "is_saved": self.is_saved, + "can_be_upgraded": self.can_be_upgraded, + "was_refunded": self.was_refunded, + "convert_star_count": self.convert_star_count, + "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, + "can_be_transferred": self.can_be_transferred, + "transfer_star_count": self.transfer_star_count, + } + og = OwnedGift.de_json(json_dict, offline_bot) + + assert type(og) is subclass + assert set(og.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert og.type == og_type + + def test_to_dict(self, owned_gift): + assert owned_gift.to_dict() == {"type": owned_gift.type} + + def test_equality(self, owned_gift): + a = owned_gift + b = OwnedGift(self.type) + c = PaidMedia("unknown") + d = Dice(5, "test") + + 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 owned_gift_regular(): + return OwnedGiftRegular( + gift=TestOwnedGiftRegularWithoutRequest.gift, + send_date=TestOwnedGiftRegularWithoutRequest.send_date, + owned_gift_id=TestOwnedGiftRegularWithoutRequest.owned_gift_id, + sender_user=TestOwnedGiftRegularWithoutRequest.sender_user, + text=TestOwnedGiftRegularWithoutRequest.text, + entities=TestOwnedGiftRegularWithoutRequest.entities, + is_private=TestOwnedGiftRegularWithoutRequest.is_private, + is_saved=TestOwnedGiftRegularWithoutRequest.is_saved, + can_be_upgraded=TestOwnedGiftRegularWithoutRequest.can_be_upgraded, + was_refunded=TestOwnedGiftRegularWithoutRequest.was_refunded, + convert_star_count=TestOwnedGiftRegularWithoutRequest.convert_star_count, + prepaid_upgrade_star_count=TestOwnedGiftRegularWithoutRequest.prepaid_upgrade_star_count, + ) + + +class TestOwnedGiftRegularWithoutRequest(OwnedGiftTestBase): + type = OwnedGiftType.REGULAR + + def test_slot_behaviour(self, owned_gift_regular): + inst = owned_gift_regular + 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_de_json(self, offline_bot): + json_dict = { + "gift": self.gift.to_dict(), + "send_date": to_timestamp(self.send_date), + "owned_gift_id": self.owned_gift_id, + "sender_user": self.sender_user.to_dict(), + "text": self.text, + "entities": [e.to_dict() for e in self.entities], + "is_private": self.is_private, + "is_saved": self.is_saved, + "can_be_upgraded": self.can_be_upgraded, + "was_refunded": self.was_refunded, + "convert_star_count": self.convert_star_count, + "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, + } + ogr = OwnedGiftRegular.de_json(json_dict, offline_bot) + assert ogr.gift == self.gift + assert ogr.send_date == self.send_date + assert ogr.owned_gift_id == self.owned_gift_id + assert ogr.sender_user == self.sender_user + assert ogr.text == self.text + assert ogr.entities == self.entities + assert ogr.is_private == self.is_private + assert ogr.is_saved == self.is_saved + assert ogr.can_be_upgraded == self.can_be_upgraded + assert ogr.was_refunded == self.was_refunded + assert ogr.convert_star_count == self.convert_star_count + assert ogr.prepaid_upgrade_star_count == self.prepaid_upgrade_star_count + assert ogr.api_kwargs == {} + + def test_to_dict(self, owned_gift_regular): + json_dict = owned_gift_regular.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["gift"] == self.gift.to_dict() + assert json_dict["send_date"] == to_timestamp(self.send_date) + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["sender_user"] == self.sender_user.to_dict() + assert json_dict["text"] == self.text + assert json_dict["entities"] == [e.to_dict() for e in self.entities] + assert json_dict["is_private"] == self.is_private + assert json_dict["is_saved"] == self.is_saved + assert json_dict["can_be_upgraded"] == self.can_be_upgraded + assert json_dict["was_refunded"] == self.was_refunded + assert json_dict["convert_star_count"] == self.convert_star_count + assert json_dict["prepaid_upgrade_star_count"] == self.prepaid_upgrade_star_count + + def test_parse_entity(self, owned_gift_regular): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + + assert owned_gift_regular.parse_entity(entity) == "test" + + def test_parse_entities(self, owned_gift_regular): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 8) + + assert owned_gift_regular.parse_entities(MessageEntity.BOLD) == {entity: "test"} + assert owned_gift_regular.parse_entities() == {entity: "test", entity_2: "text"} + + def test_equality(self, owned_gift_regular): + a = owned_gift_regular + b = OwnedGiftRegular(deepcopy(self.gift), deepcopy(self.send_date)) + c = OwnedGiftRegular(self.gift, self.send_date + dtm.timedelta(seconds=1)) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def owned_gift_unique(): + return OwnedGiftUnique( + gift=TestOwnedGiftUniqueWithoutRequest.unique_gift, + send_date=TestOwnedGiftUniqueWithoutRequest.send_date, + owned_gift_id=TestOwnedGiftUniqueWithoutRequest.owned_gift_id, + sender_user=TestOwnedGiftUniqueWithoutRequest.sender_user, + is_saved=TestOwnedGiftUniqueWithoutRequest.is_saved, + can_be_transferred=TestOwnedGiftUniqueWithoutRequest.can_be_transferred, + transfer_star_count=TestOwnedGiftUniqueWithoutRequest.transfer_star_count, + ) + + +class TestOwnedGiftUniqueWithoutRequest(OwnedGiftTestBase): + type = OwnedGiftType.UNIQUE + + def test_slot_behaviour(self, owned_gift_unique): + inst = owned_gift_unique + 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_de_json(self, offline_bot): + json_dict = { + "gift": self.unique_gift.to_dict(), + "send_date": to_timestamp(self.send_date), + "owned_gift_id": self.owned_gift_id, + "sender_user": self.sender_user.to_dict(), + "is_saved": self.is_saved, + "can_be_transferred": self.can_be_transferred, + "transfer_star_count": self.transfer_star_count, + } + ogu = OwnedGiftUnique.de_json(json_dict, offline_bot) + assert ogu.gift == self.unique_gift + assert ogu.send_date == self.send_date + assert ogu.owned_gift_id == self.owned_gift_id + assert ogu.sender_user == self.sender_user + assert ogu.is_saved == self.is_saved + assert ogu.can_be_transferred == self.can_be_transferred + assert ogu.transfer_star_count == self.transfer_star_count + assert ogu.api_kwargs == {} + + def test_to_dict(self, owned_gift_unique): + json_dict = owned_gift_unique.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["gift"] == self.unique_gift.to_dict() + assert json_dict["send_date"] == to_timestamp(self.send_date) + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["sender_user"] == self.sender_user.to_dict() + assert json_dict["is_saved"] == self.is_saved + assert json_dict["can_be_transferred"] == self.can_be_transferred + assert json_dict["transfer_star_count"] == self.transfer_star_count + + def test_equality(self, owned_gift_unique): + a = owned_gift_unique + b = OwnedGiftUnique(deepcopy(self.unique_gift), deepcopy(self.send_date)) + c = OwnedGiftUnique(self.unique_gift, self.send_date + dtm.timedelta(seconds=1)) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def owned_gifts(request): + return OwnedGifts( + total_count=OwnedGiftsTestBase.total_count, + gifts=OwnedGiftsTestBase.gifts, + next_offset=OwnedGiftsTestBase.next_offset, + ) + + +class OwnedGiftsTestBase: + total_count = 2 + next_offset = "next_offset_str" + gifts: Sequence[OwnedGifts] = [ + OwnedGiftRegular( + 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, + upgrade_star_count=5, + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_1", + ), + OwnedGiftUnique( + gift=UniqueGift( + base_name="human_readable", + name="unique_name", + number=10, + model=UniqueGiftModel( + name="model_name", + sticker=Sticker( + "file_id1", "file_unique_id1", 512, 512, False, False, "regular" + ), + rarity_per_mille=10, + ), + symbol=UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + backdrop=UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=30, + ), + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_2", + ), + ] + + +class TestOwnedGiftsWithoutRequest(OwnedGiftsTestBase): + def test_slot_behaviour(self, owned_gifts): + for attr in owned_gifts.__slots__: + assert getattr(owned_gifts, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(owned_gifts)) == len(set(mro_slots(owned_gifts))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "total_count": self.total_count, + "gifts": [gift.to_dict() for gift in self.gifts], + "next_offset": self.next_offset, + } + owned_gifts = OwnedGifts.de_json(json_dict, offline_bot) + assert owned_gifts.api_kwargs == {} + + assert owned_gifts.total_count == self.total_count + assert owned_gifts.gifts == tuple(self.gifts) + assert type(owned_gifts.gifts[0]) is OwnedGiftRegular + assert type(owned_gifts.gifts[1]) is OwnedGiftUnique + assert owned_gifts.next_offset == self.next_offset + + def test_to_dict(self, owned_gifts): + gifts_dict = owned_gifts.to_dict() + + assert isinstance(gifts_dict, dict) + assert gifts_dict["total_count"] == self.total_count + assert gifts_dict["gifts"] == [gift.to_dict() for gift in self.gifts] + assert gifts_dict["next_offset"] == self.next_offset + + def test_equality(self, owned_gifts): + a = owned_gifts + b = OwnedGifts(self.total_count, self.gifts) + c = OwnedGifts(self.total_count - 1, self.gifts[:1]) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) From cb0f92c7fb282ff226a7e4658f86da13d097644e Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 19 Apr 2025 04:15:22 +0300 Subject: [PATCH 05/10] Add gift methods for business accounts, and tests. - Added the method `setBusinessAccountGiftSettings`, allowing bots to change the privacy settings pertaining to incoming gifts in a managed business account. - Added the method `convertGiftToStars`, allowing bots to convert gifts received by a managed business account to Telegram Stars. - Added the method `upgradeGift`, allowing bots to upgrade regular gifts received by a managed business account to unique gifts. - Added the method `transferGift`, allowing bots to transfer unique gifts owned by a managed business account. --- docs/source/inclusions/bot_methods.rst | 8 + telegram/_bot.py | 217 ++++++++++++++++++++++++- telegram/constants.py | 9 +- telegram/ext/_extbot.py | 103 ++++++++++++ tests/test_business_methods.py | 79 ++++++++- 5 files changed, 410 insertions(+), 6 deletions(-) diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 3a5c7a963cf..4703de28d7f 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -423,6 +423,14 @@ - Used for setting the business account username. * - :meth:`~telegram.Bot.set_business_account_bio` - Used for setting the business account bio. + * - :meth:`~telegram.Bot.set_business_account_gift_settings` + - Used for setting the business account gift settings. + * - :meth:`~telegram.Bot.convert_gift_to_stars` + - Used for converting owned reqular gifts to stars. + * - :meth:`~telegram.Bot.upgrade_gift` + - Used for upgrading owned regular gifts to unique ones. + * - :meth:`~telegram.Bot.transfer_gift` + - Used for transferring owned unique gifts to another user. .. raw:: html diff --git a/telegram/_bot.py b/telegram/_bot.py index 47cda1bb7e2..8e911abe281 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -75,7 +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._gifts import AcceptedGiftTypes, Gift, Gifts from telegram._inline.inlinequeryresultsbutton import InlineQueryResultsButton from telegram._inline.preparedinlinemessage import PreparedInlineMessage from telegram._menubutton import MenuButton @@ -9500,7 +9500,7 @@ async def read_business_message( chat_id (:obj:`int`): Unique identifier of the chat in which the message was received. The chat must have been active in the last :tg-const:`~telegram.constants.BusinessLimit.\ -READ_BUSINESS_MESSAGE_ACTIVITY_TIMEOUT` seconds. +CHAT_ACTIVITY_TIMEOUT` seconds. message_id (:obj:`int`): Unique identifier of the message to mark as read. Returns: @@ -9705,6 +9705,211 @@ async def set_business_account_bio( api_kwargs=api_kwargs, ) + async def set_business_account_gift_settings( + self, + business_connection_id: str, + show_gift_button: bool, + accepted_gift_types: AcceptedGiftTypes, + *, + 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 privacy settings pertaining to incoming gifts in a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_change_gift_settings` business + bot right. + + .. versionadded:: NEXT.VERSION + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + show_gift_button (:obj:`bool`): Pass :obj:`True`, if a button for sending a gift to the + user or by the business account must always be shown in the input field. + accepted_gift_types (:class:`telegram.AcceptedGiftTypes`): Types of gifts accepted by + the business account. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "show_gift_button": show_gift_button, + "accepted_gift_types": accepted_gift_types, + } + return await self._post( + "setBusinessAccountGiftSettings", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def convert_gift_to_stars( + self, + business_connection_id: str, + owned_gift_id: str, + *, + 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: + """ + Converts a given regular gift to Telegram Stars. Requires the + :attr:`~telegram.BusinessBotRights.can_convert_gifts_to_stars` business bot right. + + .. versionadded:: NEXT.VERSION + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + owned_gift_id (:obj:`str`): Unique identifier of the regular gift that should be + converted to Telegram Stars. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "owned_gift_id": owned_gift_id, + } + return await self._post( + "convertGiftToStars", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def upgrade_gift( + self, + business_connection_id: str, + owned_gift_id: str, + keep_original_details: Optional[bool] = None, + star_count: Optional[int] = 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: + """ + Upgrades a given regular gift to a unique gift. Requires the + :attr:`~telegram.BusinessBotRights.can_transfer_and_upgrade_gifts` business bot right. + Additionally requires the :attr:`~telegram.BusinessBotRights.can_transfer_stars` business + bot right if the upgrade is paid. + + .. versionadded:: NEXT.VERSION + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + owned_gift_id (:obj:`str`): Unique identifier of the regular gift that should be + upgraded to a unique one. + keep_original_details (:obj:`bool`, optional): Pass :obj:`True` to keep the original + gift text, sender and receiver in the upgraded gift + star_count (:obj:`int`, optional): The amount of Telegram Stars that will + be paid for the upgrade from the business account balance. If + ``gift.prepaid_upgrade_star_count > 0``, then pass ``0``, otherwise, + the :attr:`~telegram.BusinessBotRights.can_transfer_stars` + business bot right is required and :attr:`telegram.Gift.upgrade_star_count` + must be passed. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "owned_gift_id": owned_gift_id, + "keep_original_details": keep_original_details, + "star_count": star_count, + } + return await self._post( + "upgradeGift", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def transfer_gift( + self, + business_connection_id: str, + owned_gift_id: str, + new_owner_chat_id: int, + star_count: Optional[int] = 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: + """ + Transfers an owned unique gift to another user. Requires the + :attr:`~telegram.BusinessBotRights.can_transfer_and_upgrade_gifts` business bot right. + Requires :attr:`~telegram.BusinessBotRights.can_transfer_stars` business bot right if the + transfer is paid. + + .. versionadded:: NEXT.VERSION + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + owned_gift_id (:obj:`str`): Unique identifier of the regular gift that should be + transferred. + new_owner_chat_id (:obj:`int`): Unique identifier of the chat which will + own the gift. The chat must be active in the last + :tg-const:`~telegram.constants.BusinessLimit.\ +CHAT_ACTIVITY_TIMEOUT` seconds. + star_count (:obj:`int`, optional): The amount of Telegram Stars that will be paid for + the transfer from the business account balance. If positive, then + the :attr:`~telegram.BusinessBotRights.can_transfer_stars` business bot + right is required. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "owned_gift_id": owned_gift_id, + "new_owner_chat_id": new_owner_chat_id, + "star_count": star_count, + } + return await self._post( + "transferGift", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def replace_sticker_in_set( self, user_id: int, @@ -10654,6 +10859,14 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`set_business_account_username`""" setBusinessAccountBio = set_business_account_bio """Alias for :meth:`set_business_account_bio`""" + setBusinessAccountGiftSettings = set_business_account_gift_settings + """Alias for :meth:`set_business_account_gift_settings`""" + convertGiftToStars = convert_gift_to_stars + """Alias for :meth:`convert_gift_to_stars`""" + upgradeGift = upgrade_gift + """Alias for :meth:`upgrade_gift`""" + transferGift = transfer_gift + """Alias for :meth:`transfer_gift`""" replaceStickerInSet = replace_sticker_in_set """Alias for :meth:`replace_sticker_in_set`""" refundStarPayment = refund_star_payment diff --git a/telegram/constants.py b/telegram/constants.py index f5f02536c2a..bd854cb810d 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -714,9 +714,12 @@ class BusinessLimit(IntEnum): __slots__ = () - READ_BUSINESS_MESSAGE_ACTIVITY_TIMEOUT = int(dtm.timedelta(hours=24).total_seconds()) - """:obj:`int`: Time in seconds in which the chat must have been active for - :meth:`~telegram.Bot.read_business_message` to work. + CHAT_ACTIVITY_TIMEOUT = int(dtm.timedelta(hours=24).total_seconds()) + """:obj:`int`: Time in seconds in which the chat must have been active for. Relevant for + :paramref:`~telegram.Bot.read_business_message.chat_id` + of :meth:`~telegram.Bot.read_business_message` and + :paramref:`~telegram.Bot.transfer_gift.new_owner_chat_id` + of :meth:`~telegram.Bot.transfer_gift`. """ MIN_NAME_LENGTH = 1 """:obj:`int`: Minimum length of the name of a business account. Relevant only for diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 8e61e468d99..c6cccb55cbf 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -36,6 +36,7 @@ from uuid import uuid4 from telegram import ( + AcceptedGiftTypes, Animation, Audio, Bot, @@ -4413,6 +4414,104 @@ async def set_business_account_bio( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def set_business_account_gift_settings( + self, + business_connection_id: str, + show_gift_button: bool, + accepted_gift_types: AcceptedGiftTypes, + *, + 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_business_account_gift_settings( + business_connection_id=business_connection_id, + show_gift_button=show_gift_button, + accepted_gift_types=accepted_gift_types, + 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 convert_gift_to_stars( + self, + business_connection_id: str, + owned_gift_id: str, + *, + 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().convert_gift_to_stars( + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + 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 upgrade_gift( + self, + business_connection_id: str, + owned_gift_id: str, + keep_original_details: Optional[bool] = None, + star_count: Optional[int] = 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().upgrade_gift( + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + keep_original_details=keep_original_details, + star_count=star_count, + 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 transfer_gift( + self, + business_connection_id: str, + owned_gift_id: str, + new_owner_chat_id: int, + star_count: Optional[int] = 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().transfer_gift( + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + new_owner_chat_id=new_owner_chat_id, + star_count=star_count, + 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 replace_sticker_in_set( self, user_id: int, @@ -4869,6 +4968,10 @@ async def remove_user_verification( setBusinessAccountName = set_business_account_name setBusinessAccountUsername = set_business_account_username setBusinessAccountBio = set_business_account_bio + setBusinessAccountGiftSettings = set_business_account_gift_settings + convertGiftToStars = convert_gift_to_stars + upgradeGift = upgrade_gift + transferGift = transfer_gift replaceStickerInSet = replace_sticker_in_set refundStarPayment = refund_star_payment getStarTransactions = get_star_transactions diff --git a/tests/test_business_methods.py b/tests/test_business_methods.py index 74edeb41133..a2534b15c1e 100644 --- a/tests/test_business_methods.py +++ b/tests/test_business_methods.py @@ -22,7 +22,7 @@ from telegram import BusinessConnection, User from telegram._files.sticker import Sticker -from telegram._gifts import Gift +from telegram._gifts import AcceptedGiftTypes, Gift from telegram._ownedgift import OwnedGiftRegular, OwnedGifts from telegram._utils.datetime import UTC @@ -171,3 +171,80 @@ async def make_assertion(*args, **kwargs): monkeypatch.setattr(offline_bot.request, "post", make_assertion) assert await offline_bot.set_business_account_bio(business_connection_id=self.bci, bio=bio) + + async def test_set_business_account_gift_settings(self, offline_bot, monkeypatch): + show_gift_button = True + accepted_gift_types = AcceptedGiftTypes(True, True, True, True) + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").json_parameters + assert data.get("business_connection_id") == self.bci + assert data.get("show_gift_button") == "true" + assert data.get("accepted_gift_types") == accepted_gift_types.to_json() + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_business_account_gift_settings( + business_connection_id=self.bci, + show_gift_button=show_gift_button, + accepted_gift_types=accepted_gift_types, + ) + + async def test_convert_gift_to_stars(self, offline_bot, monkeypatch): + owned_gift_id = "some_id" + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("owned_gift_id") == owned_gift_id + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.convert_gift_to_stars( + business_connection_id=self.bci, + owned_gift_id=owned_gift_id, + ) + + @pytest.mark.parametrize("keep_original_details", [True, None]) + @pytest.mark.parametrize("star_count", [100, None]) + async def test_upgrade_gift(self, offline_bot, monkeypatch, keep_original_details, star_count): + owned_gift_id = "some_id" + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("owned_gift_id") == owned_gift_id + assert data.get("keep_original_details") is keep_original_details + assert data.get("star_count") == star_count + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.upgrade_gift( + business_connection_id=self.bci, + owned_gift_id=owned_gift_id, + keep_original_details=keep_original_details, + star_count=star_count, + ) + + @pytest.mark.parametrize("star_count", [100, None]) + async def test_transfer_gift(self, offline_bot, monkeypatch, star_count): + owned_gift_id = "some_id" + new_owner_chat_id = 123 + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("owned_gift_id") == owned_gift_id + assert data.get("new_owner_chat_id") == new_owner_chat_id + assert data.get("star_count") == star_count + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.transfer_gift( + business_connection_id=self.bci, + owned_gift_id=owned_gift_id, + new_owner_chat_id=new_owner_chat_id, + star_count=star_count, + ) From 680a4aa3390cdd500e1486168e598ff2c6f37c14 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 19 Apr 2025 05:22:41 +0300 Subject: [PATCH 06/10] Add a shortcut to method `bot.transfer_gift()` hooray. --- telegram/_chat.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_chat.py | 22 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/telegram/_chat.py b/telegram/_chat.py index 281940f467a..ab77b6d5a67 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -3506,6 +3506,41 @@ async def send_gift( **{"chat_id" if self.type == Chat.CHANNEL else "user_id": self.id}, ) + async def transfer_gift( + self, + business_connection_id: str, + owned_gift_id: str, + star_count: Optional[int] = 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.transfer_gift(new_owner_chat_id=update.effective_chat.id, *args, **kwargs ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.transfer_gift`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().transfer_gift( + new_owner_chat_id=self.id, + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + star_count=star_count, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def verify( self, custom_description: Optional[str] = None, diff --git a/tests/test_chat.py b/tests/test_chat.py index c241b080392..8e901fb91bf 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1353,6 +1353,28 @@ async def make_assertion_channel(*_, **kwargs): text_entities="text_entities", ) + @pytest.mark.parametrize("star_count", [100, None]) + async def test_instance_method_transfer_gift(self, monkeypatch, chat, star_count): + async def make_assertion(*_, **kwargs): + return ( + kwargs["new_owner_chat_id"] == chat.id + and kwargs["owned_gift_id"] == "owned_gift_id" + and kwargs["star_count"] == star_count + ) + + assert check_shortcut_signature( + Chat.transfer_gift, Bot.transfer_gift, ["new_owner_chat_id"], [] + ) + assert await check_shortcut_call(chat.transfer_gift, chat.get_bot(), "transfer_gift") + assert await check_defaults_handling(chat.transfer_gift, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "transfer_gift", make_assertion) + assert await chat.transfer_gift( + owned_gift_id="owned_gift_id", + star_count=star_count, + business_connection_id="business_connection_id", + ) + async def test_instance_method_verify_chat(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return ( From d0d6bc85c2e136c0be9a9143f5c38ea15e267c0c Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:58:17 +0000 Subject: [PATCH 07/10] Add chango fragment for PR #4763 --- changes/unreleased/4763.LWVYYtGdy74f47FNxr4HmG.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changes/unreleased/4763.LWVYYtGdy74f47FNxr4HmG.toml diff --git a/changes/unreleased/4763.LWVYYtGdy74f47FNxr4HmG.toml b/changes/unreleased/4763.LWVYYtGdy74f47FNxr4HmG.toml new file mode 100644 index 00000000000..43e6e72b18f --- /dev/null +++ b/changes/unreleased/4763.LWVYYtGdy74f47FNxr4HmG.toml @@ -0,0 +1,5 @@ +other = "Api 9.0 gifts" +[[pull_requests]] +uid = "4763" +author_uid = "aelkheir" +closes_threads = [] From 017286adf4e93a4d12d2cdf1335319e99cf891c7 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Mon, 21 Apr 2025 21:58:38 +0200 Subject: [PATCH 08/10] Address review comments. --- .../4756.JT5nmUmGRG6qDEh5ScMn5f.toml | 4 ++++ .../4763.LWVYYtGdy74f47FNxr4HmG.toml | 5 ----- docs/source/telegram.at-tree.rst | 22 +++++++++---------- telegram/_ownedgift.py | 14 +++++++----- 4 files changed, 24 insertions(+), 21 deletions(-) delete mode 100644 changes/unreleased/4763.LWVYYtGdy74f47FNxr4HmG.toml diff --git a/changes/unreleased/4756.JT5nmUmGRG6qDEh5ScMn5f.toml b/changes/unreleased/4756.JT5nmUmGRG6qDEh5ScMn5f.toml index 32dee0a580b..6e2ae692a28 100644 --- a/changes/unreleased/4756.JT5nmUmGRG6qDEh5ScMn5f.toml +++ b/changes/unreleased/4756.JT5nmUmGRG6qDEh5ScMn5f.toml @@ -11,3 +11,7 @@ closes_threads = [] uid = "4759" author_uid = "Bibo-Joshi" closes_threads = [] +[[pull_requests]] +uid = "4763" +author_uid = "aelkheir" +closes_threads = [] diff --git a/changes/unreleased/4763.LWVYYtGdy74f47FNxr4HmG.toml b/changes/unreleased/4763.LWVYYtGdy74f47FNxr4HmG.toml deleted file mode 100644 index 43e6e72b18f..00000000000 --- a/changes/unreleased/4763.LWVYYtGdy74f47FNxr4HmG.toml +++ /dev/null @@ -1,5 +0,0 @@ -other = "Api 9.0 gifts" -[[pull_requests]] -uid = "4763" -author_uid = "aelkheir" -closes_threads = [] diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 16895e7a392..6928ee40f47 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -77,7 +77,7 @@ Available Types telegram.forumtopicreopened telegram.generalforumtopichidden telegram.generalforumtopicunhidden - telegram.giftinfo.rst + telegram.giftinfo telegram.giveaway telegram.giveawaycompleted telegram.giveawaycreated @@ -119,10 +119,10 @@ Available Types telegram.messageoriginuser telegram.messagereactioncountupdated telegram.messagereactionupdated - telegram.ownedgift.rst - telegram.ownedgiftregular.rst - telegram.ownedgifts.rst - telegram.ownedgiftunique.rst + telegram.ownedgift + telegram.ownedgiftregular + telegram.ownedgifts + telegram.ownedgiftunique telegram.paidmedia telegram.paidmediainfo telegram.paidmediaphoto @@ -148,12 +148,12 @@ Available Types telegram.switchinlinequerychosenchat telegram.telegramobject telegram.textquote - telegram.uniquegift.rst - telegram.uniquegiftbackdrop.rst - telegram.uniquegiftbackdropcolors.rst - telegram.uniquegiftinfo.rst - telegram.uniquegiftmodel.rst - telegram.uniquegiftsymbol.rst + telegram.uniquegift + telegram.uniquegiftbackdrop + telegram.uniquegiftbackdropcolors + telegram.uniquegiftinfo + telegram.uniquegiftmodel + telegram.uniquegiftsymbol telegram.update telegram.user telegram.userchatboosts diff --git a/telegram/_ownedgift.py b/telegram/_ownedgift.py index 52f9ff45d87..089777898ae 100644 --- a/telegram/_ownedgift.py +++ b/telegram/_ownedgift.py @@ -168,7 +168,8 @@ class OwnedGiftRegular(OwnedGift): owned_gift_id (:obj:`str`, optional): Unique identifier of the gift for the bot; for gifts received on behalf of business accounts only. sender_user (:class:`telegram.User`, optional): Sender of the gift if it is a known user. - send_date (:obj:`datetime.datetime`): Date the gift was sent in Unix time. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization|. text (:obj:`str`, optional): Text of the message that was added to the gift. entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities that appear in the text. @@ -192,7 +193,8 @@ class OwnedGiftRegular(OwnedGift): owned_gift_id (:obj:`str`): Optional. Unique identifier of the gift for the bot; for gifts received on behalf of business accounts only. sender_user (:class:`telegram.User`): Optional. Sender of the gift if it is a known user. - send_date (:obj:`datetime.datetime`): Date the gift was sent in Unix time. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization|. text (:obj:`str`): Optional. Text of the message that was added to the gift. entities (Sequence[:class:`telegram.MessageEntity`]): Optional. Special entities that appear in the text. @@ -282,7 +284,7 @@ def parse_entity(self, entity: MessageEntity) -> str: Note: This method is present because Telegram calculates the offset and length in UTF-16 codepoint pairs, which some versions of Python don't handle automatically. - (That is, you can't just slice ``Message.text`` with the offset and length.) + (That is, you can't just slice ``OwnedGiftRegular.text`` with the offset and length.) Args: entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must @@ -344,7 +346,8 @@ class OwnedGiftUnique(OwnedGift): owned_gift_id (:obj:`str`, optional): Unique identifier of the received gift for the bot; for gifts received on behalf of business accounts only. sender_user (:class:`telegram.User`, optional): Sender of the gift if it is a known user. - send_date (:obj:`datetime.datetime`): Date the gift was sent in Unix time. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization|. is_saved (:obj:`bool`, optional): :obj:`True`, if the gift is displayed on the account's profile page; for gifts received on behalf of business accounts only. can_be_transferred (:obj:`bool`, optional): :obj:`True`, if the gift can be transferred to @@ -358,7 +361,8 @@ class OwnedGiftUnique(OwnedGift): owned_gift_id (:obj:`str`): Optional. Unique identifier of the received gift for the bot; for gifts received on behalf of business accounts only. sender_user (:class:`telegram.User`): Optional. Sender of the gift if it is a known user. - send_date (:obj:`datetime.datetime`): Date the gift was sent in Unix time. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization|. is_saved (:obj:`bool`): Optional. :obj:`True`, if the gift is displayed on the account's profile page; for gifts received on behalf of business accounts only. can_be_transferred (:obj:`bool`): Optional. :obj:`True`, if the gift can be transferred to From e034b825b98ff66daf86de5f765f8bc90517d72e Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Mon, 21 Apr 2025 22:41:29 +0200 Subject: [PATCH 09/10] Improve test coverage target and fix typo in exception message. --- telegram/_ownedgift.py | 2 +- tests/test_gifts.py | 10 ++++++++++ tests/test_ownedgift.py | 12 ++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/telegram/_ownedgift.py b/telegram/_ownedgift.py index 089777898ae..516c4a9f3b4 100644 --- a/telegram/_ownedgift.py +++ b/telegram/_ownedgift.py @@ -327,7 +327,7 @@ def parse_entities(self, types: Optional[list[str]] = None) -> dict[MessageEntit """ if not self.text: - raise RuntimeError("This OwnedGifRegular has no 'text'.") + raise RuntimeError("This OwnedGiftRegular has no 'text'.") return parse_message_entities(self.text, self.entities, types) diff --git a/tests/test_gifts.py b/tests/test_gifts.py index a7257185f96..2b676a6ee89 100644 --- a/tests/test_gifts.py +++ b/tests/test_gifts.py @@ -373,6 +373,11 @@ def test_parse_entity(self, gift_info): assert gift_info.parse_entity(entity) == "test" + with pytest.raises(RuntimeError, match="GiftInfo has no"): + GiftInfo( + gift=self.gift, + ).parse_entity(entity) + def test_parse_entities(self, gift_info): entity = MessageEntity(MessageEntity.BOLD, 0, 4) entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 8) @@ -380,6 +385,11 @@ def test_parse_entities(self, gift_info): assert gift_info.parse_entities(MessageEntity.BOLD) == {entity: "test"} assert gift_info.parse_entities() == {entity: "test", entity_2: "text"} + with pytest.raises(RuntimeError, match="GiftInfo has no"): + GiftInfo( + gift=self.gift, + ).parse_entities() + def test_equality(self, gift_info): a = gift_info b = GiftInfo(gift=self.gift) diff --git a/tests/test_ownedgift.py b/tests/test_ownedgift.py index 805a4878f3b..281f119cafd 100644 --- a/tests/test_ownedgift.py +++ b/tests/test_ownedgift.py @@ -246,6 +246,12 @@ def test_parse_entity(self, owned_gift_regular): assert owned_gift_regular.parse_entity(entity) == "test" + with pytest.raises(RuntimeError, match="OwnedGiftRegular has no"): + OwnedGiftRegular( + gift=self.gift, + send_date=self.send_date, + ).parse_entity(entity) + def test_parse_entities(self, owned_gift_regular): entity = MessageEntity(MessageEntity.BOLD, 0, 4) entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 8) @@ -253,6 +259,12 @@ def test_parse_entities(self, owned_gift_regular): assert owned_gift_regular.parse_entities(MessageEntity.BOLD) == {entity: "test"} assert owned_gift_regular.parse_entities() == {entity: "test", entity_2: "text"} + with pytest.raises(RuntimeError, match="OwnedGiftRegular has no"): + OwnedGiftRegular( + gift=self.gift, + send_date=self.send_date, + ).parse_entities() + def test_equality(self, owned_gift_regular): a = owned_gift_regular b = OwnedGiftRegular(deepcopy(self.gift), deepcopy(self.send_date)) From f1bbb32f95fabbc49f1bc9fabaaeef1c8eddd607 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Wed, 23 Apr 2025 00:34:13 +0200 Subject: [PATCH 10/10] Fix module `_ownedgift.py` docstring. --- telegram/_ownedgift.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/telegram/_ownedgift.py b/telegram/_ownedgift.py index 516c4a9f3b4..6481eb33de3 100644 --- a/telegram/_ownedgift.py +++ b/telegram/_ownedgift.py @@ -16,7 +16,7 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -"""This module contains objects that represent paid media in Telegram.""" +"""This module contains objects that represent owned gifts.""" import datetime as dtm from collections.abc import Sequence pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy