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>`_
- `Daniel Reed <https://github.com/nmlorg>`_
- `D David Livingston <https://github.com/daviddl9>`_
- `Dmitry Kolomatskiy <https://github.com/lemontree210>`_
- `DonalDuck004 <https://github.com/DonalDuck004>`_
- `Eana Hufwe <https://github.com/blueset>`_
- `Ehsan Online <https://github.com/ehsanonline>`_

View file

@ -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
)

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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:

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:
@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),
("<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)
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="<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)
async def test_edit_message_media(self, bot, raw_bot, chat_id, media_group):
ext_bot = bot

View file

@ -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()