Add Shortcut Parameters caption, parse_mode and caption_entities to Bot.send_media_group (#3295)

This commit is contained in:
Dmitry Kolomatskiy 2022-11-02 10:32:40 +03:00 committed by GitHub
parent 636654cb71
commit d2c6c4b369
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 223 additions and 9 deletions

View file

@ -35,6 +35,7 @@ The following wonderful people contributed directly or indirectly to this projec
- `daimajia <https://github.com/daimajia>`_ - `daimajia <https://github.com/daimajia>`_
- `Daniel Reed <https://github.com/nmlorg>`_ - `Daniel Reed <https://github.com/nmlorg>`_
- `D David Livingston <https://github.com/daviddl9>`_ - `D David Livingston <https://github.com/daviddl9>`_
- `Dmitry Kolomatskiy <https://github.com/lemontree210>`_
- `DonalDuck004 <https://github.com/DonalDuck004>`_ - `DonalDuck004 <https://github.com/DonalDuck004>`_
- `Eana Hufwe <https://github.com/blueset>`_ - `Eana Hufwe <https://github.com/blueset>`_
- `Ehsan Online <https://github.com/ehsanonline>`_ - `Ehsan Online <https://github.com/ehsanonline>`_

View file

@ -19,11 +19,11 @@
# along with this program. If not, see [http://www.gnu.org/licenses/]. # along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains an object that represents a Telegram Bot.""" """This module contains an object that represents a Telegram Bot."""
import asyncio import asyncio
import copy
import functools import functools
import logging import logging
import pickle import pickle
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
from copy import copy
from datetime import datetime from datetime import datetime
from types import TracebackType from types import TracebackType
from typing import ( from typing import (
@ -348,12 +348,12 @@ class Bot(TelegramObject, AbstractAsyncContextManager):
# 1) # 1)
if isinstance(val, InputMedia): if isinstance(val, InputMedia):
# Copy object as not to edit it in-place # 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) val.parse_mode = DefaultValue.get_value(val.parse_mode)
data[key] = val data[key] = val
elif key == "media" and isinstance(val, list): elif key == "media" and isinstance(val, list):
# Copy objects as not to edit them in-place # 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: for media in copy_list:
media.parse_mode = DefaultValue.get_value(media.parse_mode) media.parse_mode = DefaultValue.get_value(media.parse_mode)
data[key] = copy_list data[key] = copy_list
@ -2005,9 +2005,17 @@ class Bot(TelegramObject, AbstractAsyncContextManager):
connect_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = 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]: ) -> List[Message]:
"""Use this method to send a group of photos or videos as an album. """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`, .. seealso:: :attr:`telegram.Message.reply_media_group`,
:attr:`telegram.Chat.send_media_group`, :attr:`telegram.Chat.send_media_group`,
:attr:`telegram.User.send_media_group` :attr:`telegram.User.send_media_group`
@ -2044,6 +2052,18 @@ class Bot(TelegramObject, AbstractAsyncContextManager):
:attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.
api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the
Telegram API. 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: Returns:
List[:class:`telegram.Message`]: An array of the sent Messages. List[:class:`telegram.Message`]: An array of the sent Messages.
@ -2051,6 +2071,29 @@ class Bot(TelegramObject, AbstractAsyncContextManager):
Raises: Raises:
:class:`telegram.error.TelegramError` :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 = { data: JSONDict = {
"chat_id": chat_id, "chat_id": chat_id,
"media": media, "media": media,
@ -2870,22 +2913,22 @@ class Bot(TelegramObject, AbstractAsyncContextManager):
# Copy the objects that need modification to avoid modifying the original object # Copy the objects that need modification to avoid modifying the original object
copied = False copied = False
if hasattr(res, "parse_mode"): if hasattr(res, "parse_mode"):
res = copy(res) res = copy.copy(res)
copied = True copied = True
res.parse_mode = DefaultValue.get_value(res.parse_mode) 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") and res.input_message_content:
if hasattr(res.input_message_content, "parse_mode"): if hasattr(res.input_message_content, "parse_mode"):
if not copied: if not copied:
res = copy(res) res = copy.copy(res)
copied = True 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 = DefaultValue.get_value(
res.input_message_content.parse_mode res.input_message_content.parse_mode
) )
if hasattr(res.input_message_content, "disable_web_page_preview"): if hasattr(res.input_message_content, "disable_web_page_preview"):
if not copied: if not copied:
res = copy(res) res = copy.copy(res)
res.input_message_content = copy(res.input_message_content) 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 = DefaultValue.get_value(
res.input_message_content.disable_web_page_preview res.input_message_content.disable_web_page_preview
) )

View file

@ -1234,6 +1234,9 @@ class Chat(TelegramObject):
connect_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = 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"]: ) -> List["Message"]:
"""Shortcut for:: """Shortcut for::
@ -1257,6 +1260,9 @@ class Chat(TelegramObject):
api_kwargs=api_kwargs, api_kwargs=api_kwargs,
allow_sending_without_reply=allow_sending_without_reply, allow_sending_without_reply=allow_sending_without_reply,
protect_content=protect_content, protect_content=protect_content,
caption=caption,
parse_mode=parse_mode,
caption_entities=caption_entities,
) )
async def send_chat_action( async def send_chat_action(

View file

@ -1011,6 +1011,9 @@ class Message(TelegramObject):
connect_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = 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"]: ) -> List["Message"]:
"""Shortcut for:: """Shortcut for::
@ -1043,6 +1046,9 @@ class Message(TelegramObject):
api_kwargs=api_kwargs, api_kwargs=api_kwargs,
allow_sending_without_reply=allow_sending_without_reply, allow_sending_without_reply=allow_sending_without_reply,
protect_content=protect_content, protect_content=protect_content,
caption=caption,
parse_mode=parse_mode,
caption_entities=caption_entities,
) )
async def reply_photo( async def reply_photo(

View file

@ -474,6 +474,9 @@ class User(TelegramObject):
connect_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE,
pool_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = 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"]: ) -> List["Message"]:
"""Shortcut for:: """Shortcut for::
@ -497,6 +500,9 @@ class User(TelegramObject):
api_kwargs=api_kwargs, api_kwargs=api_kwargs,
allow_sending_without_reply=allow_sending_without_reply, allow_sending_without_reply=allow_sending_without_reply,
protect_content=protect_content, protect_content=protect_content,
caption=caption,
parse_mode=parse_mode,
caption_entities=caption_entities,
) )
async def send_audio( async def send_audio(

View file

@ -2259,6 +2259,9 @@ class ExtBot(Bot, Generic[RLARGS]):
pool_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE,
api_kwargs: JSONDict = None, api_kwargs: JSONDict = None,
rate_limit_args: RLARGS = 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]: ) -> List[Message]:
return await super().send_media_group( return await super().send_media_group(
chat_id=chat_id, chat_id=chat_id,
@ -2272,6 +2275,9 @@ class ExtBot(Bot, Generic[RLARGS]):
connect_timeout=connect_timeout, connect_timeout=connect_timeout,
pool_timeout=pool_timeout, pool_timeout=pool_timeout,
api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), 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( async def send_message(

View file

@ -719,6 +719,10 @@ async def check_defaults_handling(
if isinstance(value.default, DefaultValue) and not kwarg.endswith("_timeout") 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() defaults_no_custom_defaults = Defaults()
kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters.keys()} kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters.keys()}
kwargs["tzinfo"] = pytz.timezone("America/New_York") kwargs["tzinfo"] = pytz.timezone("America/New_York")
@ -732,7 +736,7 @@ async def check_defaults_handling(
data = request_data.parameters data = request_data.parameters
# Check regular arguments that need defaults # 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 # 'None' should not be passed along to Telegram
if df_value in [None, DEFAULT_NONE]: if df_value in [None, DEFAULT_NONE]:
if arg in data: if arg in data:

View file

@ -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: class TestSendMediaGroup:
@flaky(3, 1) @flaky(3, 1)
async def test_send_media_group_photo(self, bot, chat_id, media_group): 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 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),
("<b>photo</b> 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) @flaky(3, 1)
async def test_send_media_group_all_args(self, bot, raw_bot, chat_id, media_group): async def test_send_media_group_all_args(self, bot, raw_bot, chat_id, media_group):
ext_bot = bot ext_bot = bot
@ -600,6 +694,51 @@ class TestSendMediaGroup:
) )
assert not all(msg.has_protected_content for msg in unprotected) 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="<b>photo</b> 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="<b>photo</b> 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 == "<b>photo</b> 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) @flaky(3, 1)
async def test_edit_message_media(self, bot, raw_bot, chat_id, media_group): async def test_edit_message_media(self, bot, raw_bot, chat_id, media_group):
ext_bot = bot ext_bot = bot

View file

@ -118,6 +118,9 @@ def check_method(h4):
ignored |= {"venue"} # Added for ease of use ignored |= {"venue"} # Added for ease of use
elif name == "answerInlineQuery": elif name == "answerInlineQuery":
ignored |= {"current_offset"} # Added for ease of use 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() assert (sig.parameters.keys() ^ checked) - ignored == set()