From d2c6c4b369feab6187764d4d3c3679676275472a Mon Sep 17 00:00:00 2001 From: Dmitry Kolomatskiy <58207913+lemontree210@users.noreply.github.com> Date: Wed, 2 Nov 2022 10:32:40 +0300 Subject: [PATCH] Add Shortcut Parameters `caption`, `parse_mode` and `caption_entities` to `Bot.send_media_group` (#3295) --- AUTHORS.rst | 1 + telegram/_bot.py | 59 ++++++++++++++--- telegram/_chat.py | 6 ++ telegram/_message.py | 6 ++ telegram/_user.py | 6 ++ telegram/ext/_extbot.py | 6 ++ tests/conftest.py | 6 +- tests/test_inputmedia.py | 139 +++++++++++++++++++++++++++++++++++++++ tests/test_official.py | 3 + 9 files changed, 223 insertions(+), 9 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index b0e5c07da..c4f823d8a 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -35,6 +35,7 @@ The following wonderful people contributed directly or indirectly to this projec - `daimajia `_ - `Daniel Reed `_ - `D David Livingston `_ +- `Dmitry Kolomatskiy `_ - `DonalDuck004 `_ - `Eana Hufwe `_ - `Ehsan Online `_ diff --git a/telegram/_bot.py b/telegram/_bot.py index 9d57c18e7..f52610116 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -19,11 +19,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Bot.""" import asyncio +import copy import functools import logging import pickle from contextlib import AbstractAsyncContextManager -from copy import copy from datetime import datetime from types import TracebackType from typing import ( @@ -348,12 +348,12 @@ class Bot(TelegramObject, AbstractAsyncContextManager): # 1) if isinstance(val, InputMedia): # Copy object as not to edit it in-place - val = copy(val) + val = copy.copy(val) val.parse_mode = DefaultValue.get_value(val.parse_mode) data[key] = val elif key == "media" and isinstance(val, list): # Copy objects as not to edit them in-place - copy_list = [copy(media) for media in val] + copy_list = [copy.copy(media) for media in val] for media in copy_list: media.parse_mode = DefaultValue.get_value(media.parse_mode) data[key] = copy_list @@ -2005,9 +2005,17 @@ class Bot(TelegramObject, AbstractAsyncContextManager): connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, ) -> List[Message]: """Use this method to send a group of photos or videos as an album. + Note: + If you supply a :paramref:`caption` (along with either + :paramref:`parse_mode` or :paramref:`caption_entities`), + then items in :paramref:`media` must have no captions, and vice verca. + .. seealso:: :attr:`telegram.Message.reply_media_group`, :attr:`telegram.Chat.send_media_group`, :attr:`telegram.User.send_media_group` @@ -2044,6 +2052,18 @@ class Bot(TelegramObject, AbstractAsyncContextManager): :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the Telegram API. + caption (:obj:`str`, optional): Caption that will be added to the + first element of :paramref:`media`, so that it will be used as caption for the + whole media group. + Defaults to :obj:`None`. + parse_mode (:obj:`str` | :obj:`None`, optional): + Parse mode for :paramref:`caption`. + See the constants in :class:`telegram.constants.ParseMode` for the + available modes. + caption_entities (List[:class:`telegram.MessageEntity`], optional): + List of special entities for :paramref:`caption`, + which can be specified instead of :paramref:`parse_mode`. + Defaults to :obj:`None`. Returns: List[:class:`telegram.Message`]: An array of the sent Messages. @@ -2051,6 +2071,29 @@ class Bot(TelegramObject, AbstractAsyncContextManager): Raises: :class:`telegram.error.TelegramError` """ + if caption and any( + [ + any(item.caption for item in media), + any(item.caption_entities for item in media), + # if parse_mode was set explicitly, even to None, error must be raised + any(item.parse_mode is not DEFAULT_NONE for item in media), + ] + ): + raise ValueError("You can only supply either group caption or media with captions.") + + if caption: + # Copy first item (to avoid mutation of original object), apply group caption to it. + # This will lead to the group being shown with this caption. + item_to_get_caption = copy.copy(media[0]) + item_to_get_caption.caption = caption + if parse_mode is not DEFAULT_NONE: + item_to_get_caption.parse_mode = parse_mode + item_to_get_caption.caption_entities = caption_entities + + # copy the list (just the references) to avoid mutating the original list + media = media[:] + media[0] = item_to_get_caption + data: JSONDict = { "chat_id": chat_id, "media": media, @@ -2870,22 +2913,22 @@ class Bot(TelegramObject, AbstractAsyncContextManager): # Copy the objects that need modification to avoid modifying the original object copied = False if hasattr(res, "parse_mode"): - res = copy(res) + res = copy.copy(res) copied = True res.parse_mode = DefaultValue.get_value(res.parse_mode) if hasattr(res, "input_message_content") and res.input_message_content: if hasattr(res.input_message_content, "parse_mode"): if not copied: - res = copy(res) + res = copy.copy(res) copied = True - res.input_message_content = copy(res.input_message_content) + res.input_message_content = copy.copy(res.input_message_content) res.input_message_content.parse_mode = DefaultValue.get_value( res.input_message_content.parse_mode ) if hasattr(res.input_message_content, "disable_web_page_preview"): if not copied: - res = copy(res) - res.input_message_content = copy(res.input_message_content) + res = copy.copy(res) + res.input_message_content = copy.copy(res.input_message_content) res.input_message_content.disable_web_page_preview = DefaultValue.get_value( res.input_message_content.disable_web_page_preview ) diff --git a/telegram/_chat.py b/telegram/_chat.py index 4ac457c45..03b961624 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -1234,6 +1234,9 @@ class Chat(TelegramObject): connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, ) -> List["Message"]: """Shortcut for:: @@ -1257,6 +1260,9 @@ class Chat(TelegramObject): api_kwargs=api_kwargs, allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, ) async def send_chat_action( diff --git a/telegram/_message.py b/telegram/_message.py index e0c215f84..86d40a1a3 100644 --- a/telegram/_message.py +++ b/telegram/_message.py @@ -1011,6 +1011,9 @@ class Message(TelegramObject): connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, ) -> List["Message"]: """Shortcut for:: @@ -1043,6 +1046,9 @@ class Message(TelegramObject): api_kwargs=api_kwargs, allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, ) async def reply_photo( diff --git a/telegram/_user.py b/telegram/_user.py index 52633d9b3..01bf0d93a 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -474,6 +474,9 @@ class User(TelegramObject): connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, ) -> List["Message"]: """Shortcut for:: @@ -497,6 +500,9 @@ class User(TelegramObject): api_kwargs=api_kwargs, allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, ) async def send_audio( diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index b30dc28ad..a344123f9 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -2259,6 +2259,9 @@ class ExtBot(Bot, Generic[RLARGS]): pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict = None, rate_limit_args: RLARGS = None, + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Union[List["MessageEntity"], Tuple["MessageEntity", ...]] = None, ) -> List[Message]: return await super().send_media_group( chat_id=chat_id, @@ -2272,6 +2275,9 @@ class ExtBot(Bot, Generic[RLARGS]): connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, ) async def send_message( diff --git a/tests/conftest.py b/tests/conftest.py index 8ee885c0f..81b611b1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -719,6 +719,10 @@ async def check_defaults_handling( if isinstance(value.default, DefaultValue) and not kwarg.endswith("_timeout") ] + if method.__name__.endswith("_media_group"): + # the parse_mode is applied to the first media item, and we test this elsewhere + kwargs_need_default.remove("parse_mode") + defaults_no_custom_defaults = Defaults() kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters.keys()} kwargs["tzinfo"] = pytz.timezone("America/New_York") @@ -732,7 +736,7 @@ async def check_defaults_handling( data = request_data.parameters # Check regular arguments that need defaults - for arg in (dkw for dkw in kwargs_need_default if dkw != "timeout"): + for arg in kwargs_need_default: # 'None' should not be passed along to Telegram if df_value in [None, DEFAULT_NONE]: if arg in data: diff --git a/tests/test_inputmedia.py b/tests/test_inputmedia.py index 95c99c199..77099324d 100644 --- a/tests/test_inputmedia.py +++ b/tests/test_inputmedia.py @@ -432,6 +432,27 @@ def media_group(photo, thumb): # noqa: F811 ] +@pytest.fixture(scope="function") # noqa: F811 +def media_group_no_caption_args(photo, thumb): # noqa: F811 + return [InputMediaPhoto(photo), InputMediaPhoto(thumb), InputMediaPhoto(photo)] + + +@pytest.fixture(scope="function") # noqa: F811 +def media_group_no_caption_only_caption_entities(photo, thumb): # noqa: F811 + return [ + InputMediaPhoto(photo, caption_entities=[MessageEntity(MessageEntity.BOLD, 0, 5)]), + InputMediaPhoto(photo, caption_entities=[MessageEntity(MessageEntity.BOLD, 0, 5)]), + ] + + +@pytest.fixture(scope="function") # noqa: F811 +def media_group_no_caption_only_parse_mode(photo, thumb): # noqa: F811 + return [ + InputMediaPhoto(photo, parse_mode="Markdown"), + InputMediaPhoto(thumb, parse_mode="HTML"), + ] + + class TestSendMediaGroup: @flaky(3, 1) async def test_send_media_group_photo(self, bot, chat_id, media_group): @@ -445,6 +466,79 @@ class TestSendMediaGroup: mes.caption_entities == [MessageEntity(MessageEntity.BOLD, 0, 5)] for mes in messages ) + async def test_send_media_group_throws_error_with_group_caption_and_individual_captions( + self, + bot, + chat_id, + media_group, + media_group_no_caption_only_caption_entities, + media_group_no_caption_only_parse_mode, + ): + for group in ( + media_group, + media_group_no_caption_only_caption_entities, + media_group_no_caption_only_parse_mode, + ): + with pytest.raises( + ValueError, + match="You can only supply either group caption or media with captions.", + ): + await bot.send_media_group(chat_id, group, caption="foo") + + @pytest.mark.parametrize( + "caption, parse_mode, caption_entities", + [ + # same combinations of caption options as in media_group fixture + ("*photo* 1", "Markdown", None), + ("photo 1", "HTML", None), + ("photo 1", None, [MessageEntity(MessageEntity.BOLD, 0, 5)]), + ], + ) + @flaky(3, 1) + async def test_send_media_group_with_group_caption( + self, + bot, + chat_id, + media_group_no_caption_args, + caption, + parse_mode, + caption_entities, + ): + # prepare a copy to check later on if calling the method has caused side effects + copied_media_group = media_group_no_caption_args.copy() + + messages = await bot.send_media_group( + chat_id, + media_group_no_caption_args, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + ) + + # Check that the method had no side effects: + # original group was not changed and 1st item still points to the same object + # (1st item must be copied within the method before adding the caption) + assert media_group_no_caption_args == copied_media_group + assert media_group_no_caption_args[0] is copied_media_group[0] + + assert not any(item.parse_mode for item in media_group_no_caption_args) + + assert isinstance(messages, list) + assert len(messages) == 3 + assert all(isinstance(mes, Message) for mes in messages) + + first_message, other_messages = messages[0], messages[1:] + assert all(mes.media_group_id == first_message.media_group_id for mes in messages) + + # Make sure first message got the caption, which will lead + # to Telegram displaying its caption as group caption + assert first_message.caption + assert first_message.caption_entities == [MessageEntity(MessageEntity.BOLD, 0, 5)] + + # Check that other messages have no captions + assert all(mes.caption is None for mes in other_messages) + assert not any(mes.caption_entities for mes in other_messages) + @flaky(3, 1) async def test_send_media_group_all_args(self, bot, raw_bot, chat_id, media_group): ext_bot = bot @@ -600,6 +694,51 @@ class TestSendMediaGroup: ) assert not all(msg.has_protected_content for msg in unprotected) + @flaky(3, 1) + @pytest.mark.parametrize("default_bot", [{"parse_mode": ParseMode.HTML}], indirect=True) + async def test_send_media_group_default_parse_mode( + self, chat_id, media_group_no_caption_args, default_bot + ): + default = await default_bot.send_media_group( + chat_id, media_group_no_caption_args, caption="photo 1" + ) + + # make sure no parse_mode was set as a side effect + assert not any(item.parse_mode for item in media_group_no_caption_args) + + overridden_markdown_v2 = await default_bot.send_media_group( + chat_id, + media_group_no_caption_args.copy(), + caption="*photo* 1", + parse_mode=ParseMode.MARKDOWN_V2, + ) + + overridden_none = await default_bot.send_media_group( + chat_id, + media_group_no_caption_args.copy(), + caption="photo 1", + parse_mode=None, + ) + + # Make sure first message got the caption, which will lead to Telegram + # displaying its caption as group caption + assert overridden_none[0].caption == "photo 1" + assert not overridden_none[0].caption_entities + # First messages in these two groups have to have caption "photo 1" + # because of parse mode (default or explicit) + for mes_group in (default, overridden_markdown_v2): + first_message = mes_group[0] + assert first_message.caption == "photo 1" + assert first_message.caption_entities == [MessageEntity(MessageEntity.BOLD, 0, 5)] + + # This check is valid for all 3 groups of messages + for mes_group in (default, overridden_markdown_v2, overridden_none): + first_message, other_messages = mes_group[0], mes_group[1:] + assert all(mes.media_group_id == first_message.media_group_id for mes in mes_group) + # Check that messages from 2nd message onwards have no captions + assert all(mes.caption is None for mes in other_messages) + assert not any(mes.caption_entities for mes in other_messages) + @flaky(3, 1) async def test_edit_message_media(self, bot, raw_bot, chat_id, media_group): ext_bot = bot diff --git a/tests/test_official.py b/tests/test_official.py index 798c4a9bd..9ac1907e2 100644 --- a/tests/test_official.py +++ b/tests/test_official.py @@ -118,6 +118,9 @@ def check_method(h4): ignored |= {"venue"} # Added for ease of use elif name == "answerInlineQuery": ignored |= {"current_offset"} # Added for ease of use + elif name == "sendMediaGroup": + # Added for ease of use + ignored |= {"caption", "parse_mode", "caption_entities"} assert (sig.parameters.keys() ^ checked) - ignored == set()