diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index bb6e4c81472..f40223391fc 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -120,6 +120,7 @@ Available Types telegram.paidmediainfo telegram.paidmediaphoto telegram.paidmediapreview + telegram.paidmediapurchased telegram.paidmediavideo telegram.photosize telegram.poll diff --git a/docs/source/telegram.paidmediapurchased.rst b/docs/source/telegram.paidmediapurchased.rst new file mode 100644 index 00000000000..80568ae405c --- /dev/null +++ b/docs/source/telegram.paidmediapurchased.rst @@ -0,0 +1,6 @@ +PaidMediaPurchased +================== + +.. autoclass:: telegram.PaidMediaPurchased + :members: + :show-inheritance: diff --git a/telegram/__init__.py b/telegram/__init__.py index 7b5803a3e9f..0ff15a7a9a4 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -180,6 +180,7 @@ "PaidMediaInfo", "PaidMediaPhoto", "PaidMediaPreview", + "PaidMediaPurchased", "PaidMediaVideo", "PassportData", "PassportElementError", @@ -419,7 +420,14 @@ MessageOriginUser, ) from ._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated -from ._paidmedia import PaidMedia, PaidMediaInfo, PaidMediaPhoto, PaidMediaPreview, PaidMediaVideo +from ._paidmedia import ( + PaidMedia, + PaidMediaInfo, + PaidMediaPhoto, + PaidMediaPreview, + PaidMediaPurchased, + PaidMediaVideo, +) from ._passport.credentials import ( Credentials, DataCredentials, diff --git a/telegram/_paidmedia.py b/telegram/_paidmedia.py index fe78cca28e0..57126b13001 100644 --- a/telegram/_paidmedia.py +++ b/telegram/_paidmedia.py @@ -24,6 +24,7 @@ from telegram._files.photosize import PhotoSize from telegram._files.video import Video from telegram._telegramobject import TelegramObject +from telegram._user import User from telegram._utils import enum from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.types import JSONDict @@ -288,3 +289,52 @@ def de_json( data["paid_media"] = PaidMedia.de_list(data.get("paid_media"), bot=bot) return super().de_json(data=data, bot=bot) + + +class PaidMediaPurchased(TelegramObject): + """This object contains information about a paid media purchase. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`from_user` and :attr:`paid_media_payload` are equal. + + Note: + In Python :keyword:`from` is a reserved word. Use :paramref:`from_user` instead. + + .. versionadded:: NEXT.VERSION + + Args: + from_user (:class:`telegram.User`): User who purchased the media. + paid_media_payload (:obj:`str`): Bot-specified paid media payload. + + Attributes: + from_user (:class:`telegram.User`): User who purchased the media. + paid_media_payload (:obj:`str`): Bot-specified paid media payload. + """ + + __slots__ = ("from_user", "paid_media_payload") + + def __init__( + self, + from_user: "User", + paid_media_payload: str, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.from_user: User = from_user + self.paid_media_payload: str = paid_media_payload + + self._id_attrs = (self.from_user, self.paid_media_payload) + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: Optional["Bot"] = None + ) -> Optional["PaidMediaPurchased"]: + data = cls._parse_data(data) + + if not data: + return None + + data["from_user"] = User.de_json(data=data.pop("from"), bot=bot) + return super().de_json(data=data, bot=bot) diff --git a/telegram/_update.py b/telegram/_update.py index 579cb008580..be4f2ec5812 100644 --- a/telegram/_update.py +++ b/telegram/_update.py @@ -30,6 +30,7 @@ from telegram._inline.inlinequery import InlineQuery from telegram._message import Message from telegram._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated +from telegram._paidmedia import PaidMediaPurchased from telegram._payment.precheckoutquery import PreCheckoutQuery from telegram._payment.shippingquery import ShippingQuery from telegram._poll import Poll, PollAnswer @@ -156,6 +157,11 @@ class Update(TelegramObject): .. versionadded:: 21.1 + purchased_paid_media (:class:`telegram.PaidMediaPurchased`, optional): A user purchased + paid media with a non-empty payload sent by the bot in a non-channel chat. + + .. versionadded:: NEXT.VERSION + Attributes: update_id (:obj:`int`): The update's unique identifier. Update identifiers start from a @@ -263,6 +269,11 @@ class Update(TelegramObject): were deleted from a connected business account. .. versionadded:: 21.1 + + purchased_paid_media (:class:`telegram.PaidMediaPurchased`): Optional. A user purchased + paid media with a non-empty payload sent by the bot in a non-channel chat. + + .. versionadded:: NEXT.VERSION """ __slots__ = ( @@ -290,6 +301,7 @@ class Update(TelegramObject): "poll", "poll_answer", "pre_checkout_query", + "purchased_paid_media", "removed_chat_boost", "shipping_query", "update_id", @@ -383,6 +395,13 @@ class Update(TelegramObject): """:const:`telegram.constants.UpdateType.DELETED_BUSINESS_MESSAGES` .. versionadded:: 21.1""" + + PURCHASED_PAID_MEDIA: Final[str] = constants.UpdateType.PURCHASED_PAID_MEDIA + """:const:`telegram.constants.UpdateType.PURCHASED_PAID_MEDIA` + + .. versionadded:: NEXT.VERSION + """ + ALL_TYPES: Final[List[str]] = list(constants.UpdateType) """List[:obj:`str`]: A list of all available update types. @@ -413,6 +432,7 @@ def __init__( business_message: Optional[Message] = None, edited_business_message: Optional[Message] = None, deleted_business_messages: Optional[BusinessMessagesDeleted] = None, + purchased_paid_media: Optional[PaidMediaPurchased] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -444,6 +464,7 @@ def __init__( self.deleted_business_messages: Optional[BusinessMessagesDeleted] = ( deleted_business_messages ) + self.purchased_paid_media: Optional[PaidMediaPurchased] = purchased_paid_media self._effective_user: Optional[User] = None self._effective_sender: Optional[Union[User, Chat]] = None @@ -475,6 +496,9 @@ def effective_user(self) -> Optional["User"]: This property now also considers :attr:`business_connection`, :attr:`business_message` and :attr:`edited_business_message`. + .. versionchanged:: NEXT.VERSION + This property now also considers :attr:`purchased_paid_media`. + Example: * If :attr:`message` is present, this will give :attr:`telegram.Message.from_user`. @@ -531,6 +555,9 @@ def effective_user(self) -> Optional["User"]: elif self.business_connection: user = self.business_connection.user + elif self.purchased_paid_media: + user = self.purchased_paid_media.from_user + self._effective_user = user return user @@ -601,7 +628,8 @@ def effective_chat(self) -> Optional["Chat"]: This is the case, if :attr:`inline_query`, :attr:`chosen_inline_result`, :attr:`callback_query` from inline messages, :attr:`shipping_query`, :attr:`pre_checkout_query`, :attr:`poll`, - :attr:`poll_answer`, or :attr:`business_connection` is present. + :attr:`poll_answer`, :attr:`business_connection`, or :attr:`purchased_paid_media` + is present. .. versionchanged:: 21.1 This property now also considers :attr:`business_message`, @@ -768,5 +796,8 @@ def de_json(cls, data: Optional[JSONDict], bot: Optional["Bot"] = None) -> Optio data["deleted_business_messages"] = BusinessMessagesDeleted.de_json( data.get("deleted_business_messages"), bot ) + data["purchased_paid_media"] = PaidMediaPurchased.de_json( + data.get("purchased_paid_media"), bot + ) return super().de_json(data=data, bot=bot) diff --git a/telegram/constants.py b/telegram/constants.py index 0f179b1a34d..b76b7850c6b 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -2724,6 +2724,11 @@ class UpdateType(StringEnum): .. versionadded:: 21.1 """ + PURCHASED_PAID_MEDIA = "purchased_paid_media" + """:obj:`str`: Updates with :attr:`telegram.Update.purchased_paid_media`. + + .. versionadded:: NEXT.VERSION + """ class InvoiceLimit(IntEnum): diff --git a/telegram/ext/__init__.py b/telegram/ext/__init__.py index 82dbd1c19ad..432f742abf2 100644 --- a/telegram/ext/__init__.py +++ b/telegram/ext/__init__.py @@ -48,6 +48,7 @@ "JobQueue", "MessageHandler", "MessageReactionHandler", + "PaidMediaPurchasedHandler", "PersistenceInput", "PicklePersistence", "PollAnswerHandler", @@ -89,6 +90,7 @@ from ._handlers.inlinequeryhandler import InlineQueryHandler from ._handlers.messagehandler import MessageHandler from ._handlers.messagereactionhandler import MessageReactionHandler +from ._handlers.paidmediapurchasedhandler import PaidMediaPurchasedHandler from ._handlers.pollanswerhandler import PollAnswerHandler from ._handlers.pollhandler import PollHandler from ._handlers.precheckoutqueryhandler import PreCheckoutQueryHandler diff --git a/telegram/ext/_handlers/paidmediapurchasedhandler.py b/telegram/ext/_handlers/paidmediapurchasedhandler.py new file mode 100644 index 00000000000..fbaff4c434d --- /dev/null +++ b/telegram/ext/_handlers/paidmediapurchasedhandler.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains the PaidMediaPurchased class.""" + +from typing import Optional, TypeVar + +from telegram import Update +from telegram._utils.defaultvalue import DEFAULT_TRUE +from telegram._utils.types import SCT, DVType +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username +from telegram.ext._utils.types import CCT, HandlerCallback + +RT = TypeVar("RT") + + +class PaidMediaPurchasedHandler(BaseHandler[Update, CCT]): + """Handler class to handle Telegram + :attr:`purchased paid media `. + + .. versionadded:: NEXT.VERSION + + Args: + callback (:term:`coroutine function`): The callback function for this handler. Will be + called when :meth:`check_update` has determined that an update should be processed by + this handler. Callback signature:: + + async def callback(update: Update, context: CallbackContext) + user_id (:obj:`int` | Collection[:obj:`int`], optional): Filters requests to allow only + those which are from the specified user ID(s). + + username (:obj:`str` | Collection[:obj:`str`], optional): Filters requests to allow only + those which are from the specified username(s). + + block (:obj:`bool`, optional): Determines whether the return value of the callback should + be awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. + + .. seealso:: :wiki:`Concurrency` + Attributes: + callback (:term:`coroutine function`): The callback function for this handler. + block (:obj:`bool`): Determines whether the return value of the callback should be + awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. + """ + + __slots__ = ( + "_user_ids", + "_usernames", + ) + + def __init__( + self, + callback: HandlerCallback[Update, CCT, RT], + user_id: Optional[SCT[int]] = None, + username: Optional[SCT[str]] = None, + block: DVType[bool] = DEFAULT_TRUE, + ): + super().__init__(callback, block=block) + + self._user_ids = parse_chat_id(user_id) + self._usernames = parse_username(username) + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handler's :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + if not isinstance(update, Update) or not update.purchased_paid_media: + return False + + if not self._user_ids and not self._usernames: + return True + if update.purchased_paid_media.from_user.id in self._user_ids: + return True + return update.purchased_paid_media.from_user.username in self._usernames diff --git a/tests/ext/test_paidmediapurchasedhandler.py b/tests/ext/test_paidmediapurchasedhandler.py new file mode 100644 index 00000000000..959b9c30ce4 --- /dev/null +++ b/tests/ext/test_paidmediapurchasedhandler.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import asyncio +import datetime + +import pytest + +from telegram import ( + Bot, + CallbackQuery, + Chat, + ChosenInlineResult, + Message, + PaidMediaPurchased, + PreCheckoutQuery, + ShippingQuery, + Update, + User, +) +from telegram._utils.datetime import UTC +from telegram.ext import CallbackContext, JobQueue, PaidMediaPurchasedHandler +from tests.auxil.slots import mro_slots + +message = Message(1, None, Chat(1, ""), from_user=User(1, "", False), text="Text") + +params = [ + {"message": message}, + {"edited_message": message}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat", message=message)}, + {"channel_post": message}, + {"edited_channel_post": message}, + {"chosen_inline_result": ChosenInlineResult("id", User(1, "", False), "")}, + {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, + {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, +] + +ids = ( + "message", + "edited_message", + "callback_query", + "channel_post", + "edited_channel_post", + "chosen_inline_result", + "shipping_query", + "pre_checkout_query", + "callback_query_without_message", +) + + +@pytest.fixture(scope="class", params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +@pytest.fixture(scope="class") +def time(): + return datetime.datetime.now(tz=UTC) + + +@pytest.fixture(scope="class") +def purchased_paid_media(bot): + bc = PaidMediaPurchased( + from_user=User(1, "name", username="user_a", is_bot=False), + paid_media_payload="payload", + ) + bc.set_bot(bot) + return bc + + +@pytest.fixture +def purchased_paid_media_update(bot, purchased_paid_media): + return Update(0, purchased_paid_media=purchased_paid_media) + + +class TestPaidMediaPurchasedHandler: + test_flag = False + + def test_slot_behaviour(self): + action = PaidMediaPurchasedHandler(self.callback) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + @pytest.fixture(autouse=True) + def _reset(self): + self.test_flag = False + + async def callback(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(context.bot, Bot) + and isinstance(update, Update) + and isinstance(context.update_queue, asyncio.Queue) + and isinstance(context.job_queue, JobQueue) + and isinstance(context.user_data, dict) + and isinstance(context.bot_data, dict) + and isinstance( + update.purchased_paid_media, + PaidMediaPurchased, + ) + ) + + def test_with_user_id(self, purchased_paid_media_update): + handler = PaidMediaPurchasedHandler(self.callback, user_id=1) + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, user_id=[1]) + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, user_id=2, username="@user_a") + assert handler.check_update(purchased_paid_media_update) + + handler = PaidMediaPurchasedHandler(self.callback, user_id=2) + assert not handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, user_id=[2]) + assert not handler.check_update(purchased_paid_media_update) + + def test_with_username(self, purchased_paid_media_update): + handler = PaidMediaPurchasedHandler(self.callback, username="user_a") + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username="@user_a") + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username=["user_a"]) + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username=["@user_a"]) + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, user_id=1, username="@user_b") + assert handler.check_update(purchased_paid_media_update) + + handler = PaidMediaPurchasedHandler(self.callback, username="user_b") + assert not handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username="@user_b") + assert not handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username=["user_b"]) + assert not handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username=["@user_b"]) + assert not handler.check_update(purchased_paid_media_update) + + purchased_paid_media_update.purchased_paid_media.from_user._unfreeze() + purchased_paid_media_update.purchased_paid_media.from_user.username = None + assert not handler.check_update(purchased_paid_media_update) + + def test_other_update_types(self, false_update): + handler = PaidMediaPurchasedHandler(self.callback) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + async def test_context(self, app, purchased_paid_media_update): + handler = PaidMediaPurchasedHandler(callback=self.callback) + app.add_handler(handler) + + async with app: + await app.process_update(purchased_paid_media_update) + assert self.test_flag diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py index be9ac14905b..58f93cfddee 100644 --- a/tests/test_paidmedia.py +++ b/tests/test_paidmedia.py @@ -27,8 +27,10 @@ PaidMediaInfo, PaidMediaPhoto, PaidMediaPreview, + PaidMediaPurchased, PaidMediaVideo, PhotoSize, + User, Video, ) from telegram.constants import PaidMediaType @@ -122,6 +124,14 @@ def paid_media_info(): ) +@pytest.fixture(scope="module") +def paid_media_purchased(): + return PaidMediaPurchased( + from_user=PaidMediaPurchasedTestBase.from_user, + paid_media_payload=PaidMediaPurchasedTestBase.paid_media_payload, + ) + + class PaidMediaTestBase: width = 640 height = 480 @@ -323,3 +333,54 @@ def test_equality(self): assert pmi1 != pmi3 assert hash(pmi1) != hash(pmi3) + + +class PaidMediaPurchasedTestBase: + from_user = User(1, "user", False) + paid_media_payload = "payload" + + +class TestPaidMediaPurchasedWithoutRequest(PaidMediaPurchasedTestBase): + def test_slot_behaviour(self, paid_media_purchased): + inst = paid_media_purchased + 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, bot): + json_dict = { + "from": self.from_user.to_dict(), + "paid_media_payload": self.paid_media_payload, + } + pmp = PaidMediaPurchased.de_json(json_dict, bot) + pmp_none = PaidMediaPurchased.de_json(None, bot) + assert pmp.from_user == self.from_user + assert pmp.paid_media_payload == self.paid_media_payload + assert pmp.api_kwargs == {} + assert pmp_none is None + + def test_to_dict(self, paid_media_purchased): + assert paid_media_purchased.to_dict() == { + "from": self.from_user.to_dict(), + "paid_media_payload": self.paid_media_payload, + } + + def test_equality(self): + pmp1 = PaidMediaPurchased( + from_user=self.from_user, + paid_media_payload=self.paid_media_payload, + ) + pmp2 = PaidMediaPurchased( + from_user=self.from_user, + paid_media_payload=self.paid_media_payload, + ) + pmp3 = PaidMediaPurchased( + from_user=User(2, "user", False), + paid_media_payload="other", + ) + + assert pmp1 == pmp2 + assert hash(pmp1) == hash(pmp2) + + assert pmp1 != pmp3 + assert hash(pmp1) != hash(pmp3) diff --git a/tests/test_update.py b/tests/test_update.py index 46619fbfd9d..346c8a6e3fd 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -40,6 +40,7 @@ Message, MessageReactionCountUpdated, MessageReactionUpdated, + PaidMediaPurchased, Poll, PollAnswer, PollOption, @@ -143,6 +144,11 @@ User(1, "", False), ) +purchased_paid_media = PaidMediaPurchased( + from_user=User(1, "", False), + paid_media_payload="payload", +) + params = [ {"message": message}, @@ -178,6 +184,7 @@ {"deleted_business_messages": deleted_business_messages}, {"business_message": business_message}, {"edited_business_message": business_message}, + {"purchased_paid_media": purchased_paid_media}, # Must be last to conform with `ids` below! {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, ] @@ -205,6 +212,7 @@ "deleted_business_messages", "business_message", "edited_business_message", + "purchased_paid_media", ) ids = (*all_types, "callback_query_without_message") @@ -290,6 +298,7 @@ def test_effective_chat(self, update): or update.poll is not None or update.poll_answer is not None or update.business_connection is not None + or update.purchased_paid_media is not None ): assert chat.id == 1 else: @@ -403,6 +412,7 @@ def test_effective_message(self, update): or update.message_reaction_count is not None or update.deleted_business_messages is not None or update.business_connection is not None + or update.purchased_paid_media is not None ): assert eff_message.message_id == message.message_id else: