diff --git a/AUTHORS.rst b/AUTHORS.rst index bb118e4c7d3..1efaccec0e5 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -34,6 +34,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 6cbcf254aa5..956a3d3de53 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 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: # skipcq: PYL-R020 # 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 @@ async def send_media_group( 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 @@ async def send_media_group( :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 @@ async def send_media_group( 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 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ # 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 4ac457c45b4..03b961624a4 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -1234,6 +1234,9 @@ async def send_media_group( 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 @@ async def send_media_group( 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 e0c215f84d4..86d40a1a355 100644 --- a/telegram/_message.py +++ b/telegram/_message.py @@ -1011,6 +1011,9 @@ async def reply_media_group( 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 @@ async def reply_media_group( 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 52633d9b379..01bf0d93a87 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -474,6 +474,9 @@ async def send_media_group( 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 @@ async def send_media_group( 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 b30dc28ad98..a344123f965 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -2259,6 +2259,9 @@ async def send_media_group( 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 @@ async def send_media_group( 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 8ee885c0f26..81b611b1da6 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 make_assertion( 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 95c99c199cb..77099324ddf 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 @@ async def test_send_media_group_photo(self, bot, chat_id, media_group): 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 @@ async def test_send_media_group_default_protect_content( ) 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 798c4a9bdda..9ac1907e21f 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()