From 6f9f8873f410fc7fa245477c0b8cd6fd55577205 Mon Sep 17 00:00:00 2001 From: Harshil <37377066+harshil21@users.noreply.github.com> Date: Mon, 1 Jul 2024 19:22:00 -0400 Subject: [PATCH 1/6] Add new bot method and InputPaidMedia classes --- docs/source/inclusions/bot_methods.rst | 2 + docs/source/telegram.at-tree.rst | 3 + docs/source/telegram.inputpaidmedia.rst | 6 + docs/source/telegram.inputpaidmediaphoto.rst | 6 + docs/source/telegram.inputpaidmediavideo.rst | 6 + telegram/__init__.py | 6 + telegram/_bot.py | 84 +++++++++- telegram/_files/inputmedia.py | 154 ++++++++++++++++++- telegram/constants.py | 16 ++ telegram/ext/_extbot.py | 43 ++++++ telegram/request/_requestparameter.py | 4 +- tests/_files/test_inputmedia.py | 124 +++++++++++++++ 12 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 docs/source/telegram.inputpaidmedia.rst create mode 100644 docs/source/telegram.inputpaidmediaphoto.rst create mode 100644 docs/source/telegram.inputpaidmediavideo.rst diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index f79f5bd959c..3189de1c1d3 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -33,6 +33,8 @@ - Used for sending media grouped together * - :meth:`~telegram.Bot.send_message` - Used for sending text messages + * - :meth:`~telegram.Bot.send_paid_media` + - Used for sending paid media to channels * - :meth:`~telegram.Bot.send_photo` - Used for sending photos * - :meth:`~telegram.Bot.send_poll` diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 077b124aba4..f77de5eb039 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -88,6 +88,9 @@ Available Types telegram.inputmediadocument telegram.inputmediaphoto telegram.inputmediavideo + telegram.inputpaidmedia + telegram.inputpaidmediaphoto + telegram.inputpaidmediavideo telegram.inputpolloption telegram.inputsticker telegram.keyboardbutton diff --git a/docs/source/telegram.inputpaidmedia.rst b/docs/source/telegram.inputpaidmedia.rst new file mode 100644 index 00000000000..ecb45d35f6d --- /dev/null +++ b/docs/source/telegram.inputpaidmedia.rst @@ -0,0 +1,6 @@ +InputPaidMedia +============== + +.. autoclass:: telegram.InputPaidMedia + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputpaidmediaphoto.rst b/docs/source/telegram.inputpaidmediaphoto.rst new file mode 100644 index 00000000000..f8df55823a2 --- /dev/null +++ b/docs/source/telegram.inputpaidmediaphoto.rst @@ -0,0 +1,6 @@ +InputPaidMediaPhoto +=================== + +.. autoclass:: telegram.InputPaidMediaPhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputpaidmediavideo.rst b/docs/source/telegram.inputpaidmediavideo.rst new file mode 100644 index 00000000000..8a3789f5028 --- /dev/null +++ b/docs/source/telegram.inputpaidmediavideo.rst @@ -0,0 +1,6 @@ +InputPaidMediaVideo +=================== + +.. autoclass:: telegram.InputPaidMediaVideo + :members: + :show-inheritance: \ No newline at end of file diff --git a/telegram/__init__.py b/telegram/__init__.py index 48ad57298c6..b3612b39c8c 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -142,6 +142,9 @@ "InputMediaPhoto", "InputMediaVideo", "InputMessageContent", + "InputPaidMedia", + "InputPaidMediaPhoto", + "InputPaidMediaVideo", "InputPollOption", "InputSticker", "InputTextMessageContent", @@ -333,6 +336,9 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMedia, + InputPaidMediaPhoto, + InputPaidMediaVideo, ) from ._files.inputsticker import InputSticker from ._files.location import Location diff --git a/telegram/_bot.py b/telegram/_bot.py index 85cf417911d..d9882f34da1 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -118,6 +118,8 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMediaPhoto, + InputPaidMediaVideo, InputSticker, LabeledPrice, LinkPreviewOptions, @@ -582,8 +584,9 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: # Copy objects as not to edit them in-place copy_list = [copy.copy(media) for media in val] for media in copy_list: - with media._unfrozen(): - media.parse_mode = DefaultValue.get_value(media.parse_mode) + if hasattr(media, "parse_mode"): # InputPaidMedia objects don't have this + with media._unfrozen(): + media.parse_mode = DefaultValue.get_value(media.parse_mode) data[key] = copy_list # 2) @@ -9154,6 +9157,81 @@ async def get_star_transactions( bot=self, ) + async def send_paid_media( + self, + chat_id: Union[str, int], + star_count: int, + media: Sequence[Union["InputPaidMediaPhoto", "InputPaidMediaVideo"]], + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + show_caption_above_media: Optional[bool] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, + reply_markup: Optional[ReplyMarkup] = 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, + ) -> Message: + """Use this method to send paid media to channel chats. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + star_count (:obj:`int`): The number of Telegram Stars that must be paid to buy access + to the media. + media (Sequence[:class:`telegram.InputPaidMedia`]): A list describing the media to be + sent; up to :tg-const:`telegram.constants.MediaGroupLimit.MAX_MEDIA_LENGTH` items. + caption (:obj:`str`, optional): Caption of the media to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters. + parse_mode (:obj:`str`, optional): |parse_mode| + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ + :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): + Additional interface options. An object for an inline keyboard, custom reply + keyboard, instructions to remove reply keyboard or to force a reply from the user. + + Returns: + :class:`telegram.Message`: On success, the sent message is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + + data: JSONDict = { + "chat_id": chat_id, + "star_count": star_count, + "media": media, + "show_caption_above_media": show_caption_above_media, + } + + return await self._send_message( + "sendPaidMedia", + data, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + disable_notification=disable_notification, + protect_content=protect_content, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -9408,3 +9486,5 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`refund_star_payment`""" getStarTransactions = get_star_transactions """Alias for :meth:`get_star_transactions`""" + sendPaidMedia = send_paid_media + """Alias for :meth:`send_paid_media`""" diff --git a/telegram/_files/inputmedia.py b/telegram/_files/inputmedia.py index 0cf5955a4d3..7222cee0bd4 100644 --- a/telegram/_files/inputmedia.py +++ b/telegram/_files/inputmedia.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Base class for Telegram InputMedia Objects.""" -from typing import Optional, Sequence, Tuple, Union +from typing import Final, Optional, Sequence, Tuple, Union from telegram import constants from telegram._files.animation import Animation @@ -115,13 +115,161 @@ def _parse_thumbnail_input(thumbnail: Optional[FileInput]) -> Optional[Union[str ) +class InputPaidMedia(TelegramObject): + """ + Base class for Telegram InputPaidMedia Objects. Currently, it can be one of: + + * :class:`telegram.InputMediaPhoto` + * :class:`telegram.InputMediaVideo` + + .. versionadded:: NEXT.VERSION + + .. seealso:: :wiki:`Working with Files and Media ` + + Args: + media_type (:obj:`str`): Type of media that the instance represents. + media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ + :class:`telegram.PhotoSize` | :class:`telegram.Video`): File to send. |fileinputnopath| + Lastly you can pass an existing telegram media object of the corresponding type + to send. + + Attributes: + type (:obj:`str`): Type of the input media. + media (:obj:`str` | :class:`telegram.InputFile`): Media to send. + """ + + PHOTO: Final[str] = constants.InputPaidMediaType.PHOTO + """:const:`telegram.constants.InputPaidMediaType.PHOTO`""" + VIDEO: Final[str] = constants.InputPaidMediaType.VIDEO + """:const:`telegram.constants.InputPaidMediaType.VIDEO`""" + + __slots__ = ("media", "type") + + def __init__( + self, + media_type: str, + media: Union[str, InputFile, PhotoSize, Video], + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.InputPaidMediaType, media_type, media_type) + self.media: Union[str, InputFile, PhotoSize, Video] = media + + self._freeze() + + +class InputPaidMediaPhoto(InputPaidMedia): + """The paid media to send is a photo. + + .. versionadded:: NEXT.VERSION + + .. seealso:: :wiki:`Working with Files and Media ` + + Args: + media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ + :class:`telegram.PhotoSize`): File to send. |fileinputnopath| + Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. + + Attributes: + type (:obj:`str`): Type of the media, always + :tg-const:`telegram.constants.InputPaidMediaType.PHOTO`. + media (:obj:`str` | :class:`telegram.InputFile`): Photo to send. + """ + + __slots__ = () + + def __init__( + self, + media: Union[FileInput, PhotoSize], + *, + api_kwargs: Optional[JSONDict] = None, + ): + media = parse_file_input(media, PhotoSize, attach=True, local_mode=True) + super().__init__(media_type=InputPaidMedia.PHOTO, media=media, api_kwargs=api_kwargs) + + +class InputPaidMediaVideo(InputPaidMedia): + """ + The paid media to send is a video. + + .. versionadded:: NEXT.VERSION + + .. seealso:: :wiki:`Working with Files and Media ` + + Note: + * When using a :class:`telegram.Video` for the :attr:`media` attribute, it will take the + width, height and duration from that video, unless otherwise specified with the optional + arguments. + * :paramref:`thumbnail` will be ignored for small video files, for which Telegram can + easily generate thumbnails. However, this behaviour is undocumented and might be + changed by Telegram. + + Args: + media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ + :class:`telegram.Video`): File to send. |fileinputnopath| + Lastly you can pass an existing :class:`telegram.Video` object to send. + thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): |thumbdocstringnopath| + width (:obj:`int`, optional): Video width. + height (:obj:`int`, optional): Video height. + duration (:obj:`int`, optional): Video duration in seconds. + supports_streaming (:obj:`bool`, optional): Pass :obj:`True`, if the uploaded video is + suitable for streaming. + + Attributes: + type (:obj:`str`): Type of the media, always + :tg-const:`telegram.constants.InputPaidMediaType.VIDEO`. + media (:obj:`str` | :class:`telegram.InputFile`): Video to send. + thumbnail (:class:`telegram.InputFile`): Optional. |thumbdocstringbase| + width (:obj:`int`): Optional. Video width. + height (:obj:`int`): Optional. Video height. + duration (:obj:`int`): Optional. Video duration in seconds. + supports_streaming (:obj:`bool`): Optional. :obj:`True`, if the uploaded video is + suitable for streaming. + """ + + __slots__ = ("duration", "height", "supports_streaming", "thumbnail", "width") + + def __init__( + self, + media: Union[FileInput, Video], + thumbnail: Optional[FileInput] = None, + width: Optional[int] = None, + height: Optional[int] = None, + duration: Optional[int] = None, + supports_streaming: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + if isinstance(media, Video): + width = width if width is not None else media.width + height = height if height is not None else media.height + duration = duration if duration is not None else media.duration + media = media.file_id + else: + # We use local_mode=True because we don't have access to the actual setting and want + # things to work in local mode. + media = parse_file_input(media, attach=True, local_mode=True) + + super().__init__(media_type=InputPaidMedia.VIDEO, media=media, api_kwargs=api_kwargs) + with self._unfrozen(): + self.thumbnail: Optional[Union[str, InputFile]] = InputMedia._parse_thumbnail_input( + thumbnail + ) + self.width: Optional[int] = width + self.height: Optional[int] = height + self.duration: Optional[int] = duration + self.supports_streaming: Optional[bool] = supports_streaming + + class InputMediaAnimation(InputMedia): """Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. Note: When using a :class:`telegram.Animation` for the :attr:`media` attribute, it will take the - width, height and duration from that video, unless otherwise specified with the optional - arguments. + width, height and duration from that animation, unless otherwise specified with the + optional arguments. .. seealso:: :wiki:`Working with Files and Media ` diff --git a/telegram/constants.py b/telegram/constants.py index db600bf394c..0622a5a5fb1 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -71,6 +71,7 @@ "InlineQueryResultType", "InlineQueryResultsButtonLimit", "InputMediaType", + "InputPaidMediaType", "InvoiceLimit", "KeyboardButtonRequestUsersLimit", "LocationLimit", @@ -1259,6 +1260,21 @@ class InputMediaType(StringEnum): """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" +class InputPaidMediaType(StringEnum): + """This enum contains the available types of :class:`telegram.InputPaidMedia`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + PHOTO = "photo" + """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" + VIDEO = "video" + """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + + class InlineQueryLimit(IntEnum): """This enum contains limitations for :class:`telegram.InlineQuery`/ :meth:`telegram.Bot.answer_inline_query`. The enum members of this enumeration are instances diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 3cd4ab389e7..b3a02a675b7 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -107,6 +107,8 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMediaPhoto, + InputPaidMediaVideo, InputSticker, LabeledPrice, MessageEntity, @@ -4216,6 +4218,46 @@ async def get_star_transactions( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def send_paid_media( + self, + chat_id: Union[str, int], + star_count: int, + media: Sequence[Union["InputPaidMediaPhoto", "InputPaidMediaVideo"]], + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + show_caption_above_media: Optional[bool] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, + reply_markup: Optional[ReplyMarkup] = 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, + ) -> Message: + return await super().send_paid_media( + chat_id=chat_id, + star_count=star_count, + media=media, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + disable_notification=disable_notification, + protect_content=protect_content, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + # updated camelCase aliases getMe = get_me sendMessage = send_message @@ -4339,3 +4381,4 @@ async def get_star_transactions( replaceStickerInSet = replace_sticker_in_set refundStarPayment = refund_star_payment getStarTransactions = get_star_transactions + sendPaidMedia = send_paid_media diff --git a/telegram/request/_requestparameter.py b/telegram/request/_requestparameter.py index 6b16a5cae66..2e14a8be6ba 100644 --- a/telegram/request/_requestparameter.py +++ b/telegram/request/_requestparameter.py @@ -23,7 +23,7 @@ from typing import List, Optional, Sequence, Tuple, final from telegram._files.inputfile import InputFile -from telegram._files.inputmedia import InputMedia +from telegram._files.inputmedia import InputMedia, InputPaidMedia from telegram._files.inputsticker import InputSticker from telegram._telegramobject import TelegramObject from telegram._utils.datetime import to_timestamp @@ -117,7 +117,7 @@ def _value_and_input_files_from_input( # pylint: disable=too-many-return-statem return value.attach_uri, [value] return None, [value] - if isinstance(value, InputMedia) and isinstance(value.media, InputFile): + if isinstance(value, (InputMedia, InputPaidMedia)) and isinstance(value.media, InputFile): # We call to_dict and change the returned dict instead of overriding # value.media in case the same value is reused for another request data = value.to_dict() diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index cce7fdc07a5..2a3c8bd4938 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -31,6 +31,8 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMediaPhoto, + InputPaidMediaVideo, Message, MessageEntity, ReplyParameters, @@ -134,6 +136,25 @@ def input_media_document(class_thumb_file): ) +@pytest.fixture(scope="module") +def input_paid_media_photo(): + return InputPaidMediaPhoto( + media=TestInputMediaPhotoBase.media, + ) + + +@pytest.fixture(scope="module") +def input_paid_media_video(class_thumb_file): + return InputPaidMediaVideo( + media=TestInputMediaVideoBase.media, + thumbnail=class_thumb_file, + width=TestInputMediaVideoBase.width, + height=TestInputMediaVideoBase.height, + duration=TestInputMediaVideoBase.duration, + supports_streaming=TestInputMediaVideoBase.supports_streaming, + ) + + class TestInputMediaVideoBase: type_ = "video" media = "NOTAREALFILEID" @@ -514,6 +535,91 @@ def test_with_local_files(self): assert input_media_document.thumbnail == data_file("telegram.jpg").as_uri() +class TestInputPaidMediaPhotoWithoutRequest(TestInputMediaPhotoBase): + def test_slot_behaviour(self, input_paid_media_photo): + inst = input_paid_media_photo + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_paid_media_photo): + assert input_paid_media_photo.type == self.type_ + assert input_paid_media_photo.media == self.media + + def test_to_dict(self, input_paid_media_photo): + input_paid_media_photo_dict = input_paid_media_photo.to_dict() + assert input_paid_media_photo_dict["type"] == input_paid_media_photo.type + assert input_paid_media_photo_dict["media"] == input_paid_media_photo.media + + def test_with_photo(self, photo): # noqa: F811 + # fixture found in test_photo + input_paid_media_photo = InputPaidMediaPhoto(photo) + assert input_paid_media_photo.type == self.type_ + assert input_paid_media_photo.media == photo.file_id + + def test_with_photo_file(self, photo_file): # noqa: F811 + # fixture found in test_photo + input_paid_media_photo = InputPaidMediaPhoto(photo_file) + assert input_paid_media_photo.type == self.type_ + assert isinstance(input_paid_media_photo.media, InputFile) + + def test_with_local_files(self): + input_paid_media_photo = InputPaidMediaPhoto(data_file("telegram.jpg")) + assert input_paid_media_photo.media == data_file("telegram.jpg").as_uri() + + +class TestInputPaidMediaVideoWithoutRequest(TestInputMediaVideoBase): + def test_slot_behaviour(self, input_paid_media_video): + inst = input_paid_media_video + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_paid_media_video): + assert input_paid_media_video.type == self.type_ + assert input_paid_media_video.media == self.media + assert input_paid_media_video.width == self.width + assert input_paid_media_video.height == self.height + assert input_paid_media_video.duration == self.duration + assert input_paid_media_video.supports_streaming == self.supports_streaming + assert isinstance(input_paid_media_video.thumbnail, InputFile) + + def test_to_dict(self, input_paid_media_video): + input_paid_media_video_dict = input_paid_media_video.to_dict() + assert input_paid_media_video_dict["type"] == input_paid_media_video.type + assert input_paid_media_video_dict["media"] == input_paid_media_video.media + assert input_paid_media_video_dict["width"] == input_paid_media_video.width + assert input_paid_media_video_dict["height"] == input_paid_media_video.height + assert input_paid_media_video_dict["duration"] == input_paid_media_video.duration + assert ( + input_paid_media_video_dict["supports_streaming"] + == input_paid_media_video.supports_streaming + ) + assert input_paid_media_video_dict["thumbnail"] == input_paid_media_video.thumbnail + + def test_with_video(self, video): # noqa: F811 + # fixture found in test_video + input_paid_media_video = InputPaidMediaVideo(video) + assert input_paid_media_video.type == self.type_ + assert input_paid_media_video.media == video.file_id + assert input_paid_media_video.width == video.width + assert input_paid_media_video.height == video.height + assert input_paid_media_video.duration == video.duration + + def test_with_video_file(self, video_file): # noqa: F811 + # fixture found in test_video + input_paid_media_video = InputPaidMediaVideo(video_file) + assert input_paid_media_video.type == self.type_ + assert isinstance(input_paid_media_video.media, InputFile) + + def test_with_local_files(self): + input_paid_media_video = InputPaidMediaVideo( + data_file("telegram.mp4"), thumbnail=data_file("telegram.jpg") + ) + assert input_paid_media_video.media == data_file("telegram.mp4").as_uri() + assert input_paid_media_video.thumbnail == data_file("telegram.jpg").as_uri() + + @pytest.fixture(scope="module") def media_group(photo, thumb): # noqa: F811 return [ @@ -1044,3 +1150,21 @@ def build_media(parse_mode, med_type): assert message.caption_entities == () # make sure that the media was not modified assert media.parse_mode == copied_media.parse_mode + + async def test_send_paid_media(self, bot, channel_id, photo_file, video_file): # noqa: F811 + msg = await bot.send_paid_media( + chat_id=channel_id, + star_count=20, + media=[ + InputPaidMediaPhoto(media=photo_file), + InputPaidMediaVideo(media=video_file), + ], + caption="bye onlyfans", + show_caption_above_media=True, + ) + + assert isinstance(msg, Message) + assert msg.caption == "bye onlyfans" + assert msg.show_caption_above_media + # TODO: uncomment after attributes are added to message: + # assert msg.paid_media.star_count == 20 From bbe93ba185efa2e57a4487b3e75f89ef74c77a9e Mon Sep 17 00:00:00 2001 From: Harshil <37377066+harshil21@users.noreply.github.com> Date: Mon, 1 Jul 2024 19:37:39 -0400 Subject: [PATCH 2/6] Add freeze statement Technically not needed, but this is easier than adjusting the test for it --- telegram/_files/inputmedia.py | 1 + 1 file changed, 1 insertion(+) diff --git a/telegram/_files/inputmedia.py b/telegram/_files/inputmedia.py index 7222cee0bd4..649cade772f 100644 --- a/telegram/_files/inputmedia.py +++ b/telegram/_files/inputmedia.py @@ -187,6 +187,7 @@ def __init__( ): media = parse_file_input(media, PhotoSize, attach=True, local_mode=True) super().__init__(media_type=InputPaidMedia.PHOTO, media=media, api_kwargs=api_kwargs) + self._freeze() class InputPaidMediaVideo(InputPaidMedia): From 3519bc4a9043e55cbf07af1d01be97de980aec7c Mon Sep 17 00:00:00 2001 From: Harshil <37377066+harshil21@users.noreply.github.com> Date: Mon, 1 Jul 2024 19:47:35 -0400 Subject: [PATCH 3/6] Add chat shortcut --- telegram/_chat.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_chat.py | 16 ++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/telegram/_chat.py b/telegram/_chat.py index b5e2d111f1a..726451d405e 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -48,6 +48,8 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMediaPhoto, + InputPaidMediaVideo, InputPollOption, LabeledPrice, LinkPreviewOptions, @@ -3257,6 +3259,56 @@ async def set_message_reaction( api_kwargs=api_kwargs, ) + async def send_paid_media( + self, + star_count: int, + media: Sequence[Union["InputPaidMediaPhoto", "InputPaidMediaVideo"]], + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + show_caption_above_media: Optional[bool] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, + reply_markup: Optional[ReplyMarkup] = 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, + ) -> "Message": + """Shortcut for:: + + await bot.send_paid_media(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.send_paid_media`. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + """ + return await self.get_bot().send_paid_media( + chat_id=self.id, + star_count=star_count, + media=media, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + disable_notification=disable_notification, + protect_content=protect_content, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + class Chat(_ChatBase): """This object represents a chat. diff --git a/tests/test_chat.py b/tests/test_chat.py index a11b40c647b..682bdbe514a 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1244,6 +1244,22 @@ async def make_assertion(*_, **kwargs): 123, [ReactionTypeEmoji(ReactionEmoji.THUMBS_DOWN)], True ) + async def test_instance_method_send_paid_media(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["media"] == "media" + and kwargs["star_count"] == 42 + and kwargs["caption"] == "stars" + ) + + assert check_shortcut_signature(Chat.send_paid_media, Bot.send_paid_media, ["chat_id"], []) + assert await check_shortcut_call(chat.send_paid_media, chat.get_bot(), "send_paid_media") + assert await check_defaults_handling(chat.send_paid_media, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_paid_media", make_assertion) + assert await chat.send_paid_media(media="media", star_count=42, caption="stars") + def test_mention_html(self): chat = Chat(id=1, type="foo") with pytest.raises(TypeError, match="Can not create a mention to a private group chat"): From 4d2b4fd255fba31dee1ac1bf8be72cc30d570887 Mon Sep 17 00:00:00 2001 From: Harshil <37377066+harshil21@users.noreply.github.com> Date: Wed, 3 Jul 2024 22:52:02 -0400 Subject: [PATCH 4/6] Review: optimization and type hint fixes --- telegram/_bot.py | 18 +++++++++--------- telegram/_chat.py | 5 ++--- telegram/_files/inputmedia.py | 10 +++++----- telegram/ext/_extbot.py | 5 ++--- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/telegram/_bot.py b/telegram/_bot.py index d9882f34da1..6caf3b86a5d 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -70,7 +70,7 @@ from telegram._files.contact import Contact from telegram._files.document import Document from telegram._files.file import File -from telegram._files.inputmedia import InputMedia +from telegram._files.inputmedia import InputMedia, InputPaidMedia from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import MaskPosition, Sticker, StickerSet @@ -118,8 +118,6 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, - InputPaidMediaPhoto, - InputPaidMediaVideo, InputSticker, LabeledPrice, LinkPreviewOptions, @@ -580,14 +578,16 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: with new._unfrozen(): new.parse_mode = DefaultValue.get_value(new.parse_mode) data[key] = new - elif key == "media" and isinstance(val, Sequence): + elif ( + key == "media" + and isinstance(val, Sequence) + and not isinstance(val[0], InputPaidMedia) + ): # Copy objects as not to edit them in-place copy_list = [copy.copy(media) for media in val] for media in copy_list: - if hasattr(media, "parse_mode"): # InputPaidMedia objects don't have this - with media._unfrozen(): - media.parse_mode = DefaultValue.get_value(media.parse_mode) - + with media._unfrozen(): + media.parse_mode = DefaultValue.get_value(media.parse_mode) data[key] = copy_list # 2) else: @@ -9161,7 +9161,7 @@ async def send_paid_media( self, chat_id: Union[str, int], star_count: int, - media: Sequence[Union["InputPaidMediaPhoto", "InputPaidMediaVideo"]], + media: Sequence["InputPaidMedia"], caption: Optional[str] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence["MessageEntity"]] = None, diff --git a/telegram/_chat.py b/telegram/_chat.py index 726451d405e..51de3ae4d9e 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -48,8 +48,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, - InputPaidMediaPhoto, - InputPaidMediaVideo, + InputPaidMedia, InputPollOption, LabeledPrice, LinkPreviewOptions, @@ -3262,7 +3261,7 @@ async def set_message_reaction( async def send_paid_media( self, star_count: int, - media: Sequence[Union["InputPaidMediaPhoto", "InputPaidMediaVideo"]], + media: Sequence["InputPaidMedia"], caption: Optional[str] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence["MessageEntity"]] = None, diff --git a/telegram/_files/inputmedia.py b/telegram/_files/inputmedia.py index 649cade772f..e30f283538e 100644 --- a/telegram/_files/inputmedia.py +++ b/telegram/_files/inputmedia.py @@ -127,7 +127,7 @@ class InputPaidMedia(TelegramObject): .. seealso:: :wiki:`Working with Files and Media ` Args: - media_type (:obj:`str`): Type of media that the instance represents. + type (:obj:`str`): Type of media that the instance represents. media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ :class:`telegram.PhotoSize` | :class:`telegram.Video`): File to send. |fileinputnopath| Lastly you can pass an existing telegram media object of the corresponding type @@ -147,13 +147,13 @@ class InputPaidMedia(TelegramObject): def __init__( self, - media_type: str, + type: str, # pylint: disable=redefined-builtin media: Union[str, InputFile, PhotoSize, Video], *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) - self.type: str = enum.get_member(constants.InputPaidMediaType, media_type, media_type) + self.type: str = enum.get_member(constants.InputPaidMediaType, type, type) self.media: Union[str, InputFile, PhotoSize, Video] = media self._freeze() @@ -186,7 +186,7 @@ def __init__( api_kwargs: Optional[JSONDict] = None, ): media = parse_file_input(media, PhotoSize, attach=True, local_mode=True) - super().__init__(media_type=InputPaidMedia.PHOTO, media=media, api_kwargs=api_kwargs) + super().__init__(type=InputPaidMedia.PHOTO, media=media, api_kwargs=api_kwargs) self._freeze() @@ -253,7 +253,7 @@ def __init__( # things to work in local mode. media = parse_file_input(media, attach=True, local_mode=True) - super().__init__(media_type=InputPaidMedia.VIDEO, media=media, api_kwargs=api_kwargs) + super().__init__(type=InputPaidMedia.VIDEO, media=media, api_kwargs=api_kwargs) with self._unfrozen(): self.thumbnail: Optional[Union[str, InputFile]] = InputMedia._parse_thumbnail_input( thumbnail diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index b3a02a675b7..9a6206d2e84 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -107,8 +107,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, - InputPaidMediaPhoto, - InputPaidMediaVideo, + InputPaidMedia, InputSticker, LabeledPrice, MessageEntity, @@ -4222,7 +4221,7 @@ async def send_paid_media( self, chat_id: Union[str, int], star_count: int, - media: Sequence[Union["InputPaidMediaPhoto", "InputPaidMediaVideo"]], + media: Sequence["InputPaidMedia"], caption: Optional[str] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence["MessageEntity"]] = None, From 9d2941655964fe4f0c08997ba6de9a36c7692fdf Mon Sep 17 00:00:00 2001 From: Harshil <37377066+harshil21@users.noreply.github.com> Date: Fri, 5 Jul 2024 00:03:34 -0400 Subject: [PATCH 5/6] Add 2 keyword args to method --- telegram/_bot.py | 13 +++++++++++++ telegram/_chat.py | 4 ++++ telegram/ext/_extbot.py | 4 ++++ 3 files changed, 21 insertions(+) diff --git a/telegram/_bot.py b/telegram/_bot.py index 6caf3b86a5d..4df4cd4955c 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -9171,6 +9171,8 @@ async def send_paid_media( reply_parameters: Optional["ReplyParameters"] = None, reply_markup: Optional[ReplyMarkup] = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: Optional[int] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -9201,6 +9203,15 @@ async def send_paid_media( Additional interface options. An object for an inline keyboard, custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user. + Keyword Args: + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + Returns: :class:`telegram.Message`: On success, the sent message is returned. @@ -9225,6 +9236,8 @@ async def send_paid_media( protect_content=protect_content, reply_parameters=reply_parameters, reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, diff --git a/telegram/_chat.py b/telegram/_chat.py index 51de3ae4d9e..2513e0ff334 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -3271,6 +3271,8 @@ async def send_paid_media( reply_parameters: Optional["ReplyParameters"] = None, reply_markup: Optional[ReplyMarkup] = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: Optional[int] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3301,6 +3303,8 @@ async def send_paid_media( protect_content=protect_content, reply_parameters=reply_parameters, reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index 9a6206d2e84..7d8d10e4902 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -4231,6 +4231,8 @@ async def send_paid_media( reply_parameters: Optional["ReplyParameters"] = None, reply_markup: Optional[ReplyMarkup] = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: Optional[int] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -4250,6 +4252,8 @@ async def send_paid_media( protect_content=protect_content, reply_parameters=reply_parameters, reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, From acc1938f375eacafde936c04c16f38fcf43847bd Mon Sep 17 00:00:00 2001 From: Hinrich Mahler <22366557+Bibo-Joshi@users.noreply.github.com> Date: Fri, 5 Jul 2024 16:11:41 +0200 Subject: [PATCH 6/6] enable commented test --- tests/_files/test_inputmedia.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index 2a3c8bd4938..d25a679ffc4 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -1166,5 +1166,4 @@ async def test_send_paid_media(self, bot, channel_id, photo_file, video_file): assert isinstance(msg, Message) assert msg.caption == "bye onlyfans" assert msg.show_caption_above_media - # TODO: uncomment after attributes are added to message: - # assert msg.paid_media.star_count == 20 + assert msg.paid_media.star_count == 20