diff --git a/CHANGES/1293.feature.rst b/CHANGES/1293.feature.rst new file mode 100644 index 00000000..f561be61 --- /dev/null +++ b/CHANGES/1293.feature.rst @@ -0,0 +1 @@ +Added :class:`aiogram.utils.input_media.MediaGroupBuilder` for media group construction. diff --git a/aiogram/utils/media_group.py b/aiogram/utils/media_group.py new file mode 100644 index 00000000..c017c2ea --- /dev/null +++ b/aiogram/utils/media_group.py @@ -0,0 +1,366 @@ +from typing import Any, Dict, List, Literal, Optional, Union, overload + +from aiogram.enums import InputMediaType +from aiogram.types import ( + UNSET_PARSE_MODE, + InputFile, + InputMedia, + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, + MessageEntity, +) + +MediaType = Union[ + InputMediaAudio, + InputMediaPhoto, + InputMediaVideo, + InputMediaDocument, +] + +MAX_MEDIA_GROUP_SIZE = 10 + + +class MediaGroupBuilder: + # Animated media is not supported yet in Bot API to send as a media group + + def __init__( + self, + media: Optional[List[MediaType]] = None, + caption: Optional[str] = None, + caption_entities: Optional[List[MessageEntity]] = None, + ) -> None: + """ + Helper class for building media groups. + + :param media: A list of media elements to add to the media group. (optional) + :param caption: Caption for the media group. (optional) + :param caption_entities: List of special entities in the caption, + like usernames, URLs, etc. (optional) + """ + self._media: List[MediaType] = [] + self.caption = caption + self.caption_entities = caption_entities + + self._extend(media or []) + + def _add(self, media: MediaType) -> None: + if not isinstance(media, InputMedia): + raise ValueError("Media must be instance of InputMedia") + + if len(self._media) >= MAX_MEDIA_GROUP_SIZE: + raise ValueError("Media group can't contain more than 10 elements") + + self._media.append(media) + + def _extend(self, media: List[MediaType]) -> None: + for m in media: + self._add(m) + + @overload + def add( + self, + *, + type: Literal[InputMediaType.AUDIO], + media: Union[str, InputFile], + caption: Optional[str] = None, + parse_mode: Optional[str] = UNSET_PARSE_MODE, + caption_entities: Optional[List[MessageEntity]] = None, + duration: Optional[int] = None, + performer: Optional[str] = None, + title: Optional[str] = None, + **kwargs: Any, + ) -> None: + pass + + @overload + def add( + self, + *, + type: Literal[InputMediaType.PHOTO], + media: Union[str, InputFile], + caption: Optional[str] = None, + parse_mode: Optional[str] = UNSET_PARSE_MODE, + caption_entities: Optional[List[MessageEntity]] = None, + has_spoiler: Optional[bool] = None, + **kwargs: Any, + ) -> None: + pass + + @overload + def add( + self, + *, + type: Literal[InputMediaType.VIDEO], + media: Union[str, InputFile], + thumbnail: Optional[Union[InputFile, str]] = None, + caption: Optional[str] = None, + parse_mode: Optional[str] = UNSET_PARSE_MODE, + caption_entities: Optional[List[MessageEntity]] = None, + width: Optional[int] = None, + height: Optional[int] = None, + duration: Optional[int] = None, + supports_streaming: Optional[bool] = None, + has_spoiler: Optional[bool] = None, + **kwargs: Any, + ) -> None: + pass + + @overload + def add( + self, + *, + type: Literal[InputMediaType.DOCUMENT], + media: Union[str, InputFile], + thumbnail: Optional[Union[InputFile, str]] = None, + caption: Optional[str] = None, + parse_mode: Optional[str] = UNSET_PARSE_MODE, + caption_entities: Optional[List[MessageEntity]] = None, + disable_content_type_detection: Optional[bool] = None, + **kwargs: Any, + ) -> None: + pass + + def add(self, **kwargs: Any) -> None: + """ + Add a media object to the media group. + + :param kwargs: Keyword arguments for the media object. + The available keyword arguments depend on the media type. + :return: None + """ + type_ = kwargs.pop("type", None) + if type_ == InputMediaType.AUDIO: + self.add_audio(**kwargs) + elif type_ == InputMediaType.PHOTO: + self.add_photo(**kwargs) + elif type_ == InputMediaType.VIDEO: + self.add_video(**kwargs) + elif type_ == InputMediaType.DOCUMENT: + self.add_document(**kwargs) + else: + raise ValueError(f"Unknown media type: {type_!r}") + + def add_audio( + self, + media: Union[str, InputFile], + thumbnail: Optional[Union[InputFile, str]] = None, + caption: Optional[str] = None, + parse_mode: Optional[str] = UNSET_PARSE_MODE, + caption_entities: Optional[List[MessageEntity]] = None, + duration: Optional[int] = None, + performer: Optional[str] = None, + title: Optional[str] = None, + **kwargs: Any, + ) -> None: + """ + Add an audio file to the media group. + + :param media: File to send. Pass a file_id to send a file that exists on the + Telegram servers (recommended), pass an HTTP URL for Telegram to get a file from + the Internet, or pass 'attach://' to upload a new one using + multipart/form-data under name. + :ref:`More information on Sending Files » ` + :param thumbnail: *Optional*. Thumbnail of the file sent; can be ignored if + thumbnail generation for the file is supported server-side. The thumbnail should + be in JPEG format and less than 200 kB in size. A thumbnail's width and height + should not exceed 320. + :param caption: *Optional*. Caption of the audio to be sent, 0-1024 characters + after entities parsing + :param parse_mode: *Optional*. Mode for parsing entities in the audio caption. + See `formatting options `_ + for more details. + :param caption_entities: *Optional*. List of special entities that appear in the caption, + which can be specified instead of *parse_mode* + :param duration: *Optional*. Duration of the audio in seconds + :param performer: *Optional*. Performer of the audio + :param title: *Optional*. Title of the audio + :return: None + """ + self._add( + InputMediaAudio( + media=media, + thumbnail=thumbnail, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + duration=duration, + performer=performer, + title=title, + **kwargs, + ) + ) + + def add_photo( + self, + media: Union[str, InputFile], + caption: Optional[str] = None, + parse_mode: Optional[str] = UNSET_PARSE_MODE, + caption_entities: Optional[List[MessageEntity]] = None, + has_spoiler: Optional[bool] = None, + **kwargs: Any, + ) -> None: + """ + Add a photo to the media group. + + :param media: File to send. Pass a file_id to send a file that exists on the + Telegram servers (recommended), pass an HTTP URL for Telegram to get a file + from the Internet, or pass 'attach://' to upload a new + one using multipart/form-data under name. + :ref:`More information on Sending Files » ` + :param caption: *Optional*. Caption of the photo to be sent, 0-1024 characters + after entities parsing + :param parse_mode: *Optional*. Mode for parsing entities in the photo caption. + See `formatting options `_ + for more details. + :param caption_entities: *Optional*. List of special entities that appear in the caption, + which can be specified instead of *parse_mode* + :param has_spoiler: *Optional*. Pass :code:`True` if the photo needs to be covered + with a spoiler animation + :return: None + """ + self._add( + InputMediaPhoto( + media=media, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + has_spoiler=has_spoiler, + **kwargs, + ) + ) + + def add_video( + self, + media: Union[str, InputFile], + thumbnail: Optional[Union[InputFile, str]] = None, + caption: Optional[str] = None, + parse_mode: Optional[str] = UNSET_PARSE_MODE, + caption_entities: Optional[List[MessageEntity]] = None, + width: Optional[int] = None, + height: Optional[int] = None, + duration: Optional[int] = None, + supports_streaming: Optional[bool] = None, + has_spoiler: Optional[bool] = None, + **kwargs: Any, + ) -> None: + """ + Add a video to the media group. + + :param media: File to send. Pass a file_id to send a file that exists on the + Telegram servers (recommended), pass an HTTP URL for Telegram to get a file + from the Internet, or pass 'attach://' to upload a new one + using multipart/form-data under name. + :ref:`More information on Sending Files » ` + :param thumbnail: *Optional*. Thumbnail of the file sent; can be ignored if thumbnail + generation for the file is supported server-side. The thumbnail should be in JPEG + format and less than 200 kB in size. A thumbnail's width and height should + not exceed 320. Ignored if the file is not uploaded using multipart/form-data. + Thumbnails can't be reused and can be only uploaded as a new file, so you + can pass 'attach://' if the thumbnail was uploaded using + multipart/form-data under . + :ref:`More information on Sending Files » ` + :param caption: *Optional*. Caption of the video to be sent, + 0-1024 characters after entities parsing + :param parse_mode: *Optional*. Mode for parsing entities in the video caption. + See `formatting options `_ + for more details. + :param caption_entities: *Optional*. List of special entities that appear in the caption, + which can be specified instead of *parse_mode* + :param width: *Optional*. Video width + :param height: *Optional*. Video height + :param duration: *Optional*. Video duration in seconds + :param supports_streaming: *Optional*. Pass :code:`True` if the uploaded video is + suitable for streaming + :param has_spoiler: *Optional*. Pass :code:`True` if the video needs to be covered + with a spoiler animation + :return: None + """ + self._add( + InputMediaVideo( + media=media, + thumbnail=thumbnail, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + width=width, + height=height, + duration=duration, + supports_streaming=supports_streaming, + has_spoiler=has_spoiler, + **kwargs, + ) + ) + + def add_document( + self, + media: Union[str, InputFile], + thumbnail: Optional[Union[InputFile, str]] = None, + caption: Optional[str] = None, + parse_mode: Optional[str] = UNSET_PARSE_MODE, + caption_entities: Optional[List[MessageEntity]] = None, + disable_content_type_detection: Optional[bool] = None, + **kwargs: Any, + ) -> None: + """ + Add a document to the media group. + + :param media: File to send. Pass a file_id to send a file that exists on the + Telegram servers (recommended), pass an HTTP URL for Telegram to get a file + from the Internet, or pass 'attach://' to upload a new one using + multipart/form-data under name. + :ref:`More information on Sending Files » ` + :param thumbnail: *Optional*. Thumbnail of the file sent; can be ignored + if thumbnail generation for the file is supported server-side. + The thumbnail should be in JPEG format and less than 200 kB in size. + A thumbnail's width and height should not exceed 320. + Ignored if the file is not uploaded using multipart/form-data. + Thumbnails can't be reused and can be only uploaded as a new file, + so you can pass 'attach://' if the thumbnail was uploaded + using multipart/form-data under . + :ref:`More information on Sending Files » ` + :param caption: *Optional*. Caption of the document to be sent, + 0-1024 characters after entities parsing + :param parse_mode: *Optional*. Mode for parsing entities in the document caption. + See `formatting options `_ + for more details. + :param caption_entities: *Optional*. List of special entities that appear + in the caption, which can be specified instead of *parse_mode* + :param disable_content_type_detection: *Optional*. Disables automatic server-side + content type detection for files uploaded using multipart/form-data. + Always :code:`True`, if the document is sent as part of an album. + :return: None + + """ + self._add( + InputMediaDocument( + media=media, + thumbnail=thumbnail, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + disable_content_type_detection=disable_content_type_detection, + **kwargs, + ) + ) + + def build(self) -> List[MediaType]: + """ + Builds a list of media objects for a media group. + + Adds the caption to the first media object if it is present. + + :return: List of media objects. + """ + update_first_media: Dict[str, Any] = {"caption": self.caption} + if self.caption_entities is not None: + update_first_media["caption_entities"] = self.caption_entities + update_first_media["parse_mode"] = None + + return [ + media.model_copy(update=update_first_media) + if index == 0 and self.caption is not None + else media + for index, media in enumerate(self._media) + ] diff --git a/docs/utils/index.rst b/docs/utils/index.rst index fbab2e4a..034c04d8 100644 --- a/docs/utils/index.rst +++ b/docs/utils/index.rst @@ -10,3 +10,4 @@ Utils web_app callback_answer formatting + media_group diff --git a/docs/utils/media_group.rst b/docs/utils/media_group.rst new file mode 100644 index 00000000..c9501a66 --- /dev/null +++ b/docs/utils/media_group.rst @@ -0,0 +1,46 @@ +=================== +Media group builder +=================== + +This module provides a builder for media groups, it can be used to build media groups +for :class:`aiogram.types.input_media_photo.InputMediaPhoto`, :class:`aiogram.types.input_media_video.InputMediaVideo`, +:class:`aiogram.types.input_media_document.InputMediaDocument` and :class:`aiogram.types.input_media_audio.InputMediaAudio`. + +.. warning:: + + :class:`aiogram.types.input_media_animation.InputMediaAnimation` + is not supported yet in the Bot API to send as media group. + + +Usage +===== + +.. code-block:: python + + media_group = MediaGroupBuilder(caption="Media group caption") + + # Add photo + media_group.add_photo(media="https://picsum.photos/200/300") + # Dynamically add photo with known type without using separate method + media_group.add(type="photo", media="https://picsum.photos/200/300") + # ... or video + media_group.add(type="video", media=FSInputFile("media/video.mp4")) + + +To send media group use :meth:`aiogram.methods.send_media_group.SendMediaGroup` method, +but when you use :class:`aiogram.utils.media_group.MediaGroupBuilder` +you should pass ``media`` argument as ``media_group.build()``. + +If you specify ``caption`` in :class:`aiogram.utils.media_group.MediaGroupBuilder` +it will be used as ``caption`` for first media in group. + +.. code-block:: python + + await bot.send_media_group(chat_id=chat_id, media=media_group.build()) + + +References +========== + +.. autoclass:: aiogram.utils.media_group.MediaGroupBuilder + :members: diff --git a/tests/test_api/test_methods/test_base.py b/tests/test_api/test_methods/test_base.py index 498428ce..085650f3 100644 --- a/tests/test_api/test_methods/test_base.py +++ b/tests/test_api/test_methods/test_base.py @@ -3,7 +3,7 @@ from unittest.mock import sentinel import pytest from aiogram.methods import GetMe, TelegramMethod -from aiogram.types import User, TelegramObject +from aiogram.types import TelegramObject, User from tests.mocked_bot import MockedBot diff --git a/tests/test_utils/test_media_group.py b/tests/test_utils/test_media_group.py new file mode 100644 index 00000000..eae63a95 --- /dev/null +++ b/tests/test_utils/test_media_group.py @@ -0,0 +1,94 @@ +import pytest + +from aiogram.types import ( + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, + MessageEntity, +) +from aiogram.utils.media_group import MediaGroupBuilder + + +class TestMediaGroupBuilder: + def test_add_incorrect_media(self): + builder = MediaGroupBuilder() + with pytest.raises(ValueError): + builder._add("test") + + def test_add_more_than_10_media(self): + builder = MediaGroupBuilder() + for _ in range(10): + builder.add_photo("test") + with pytest.raises(ValueError): + builder.add_photo("test") + + def test_extend(self): + builder = MediaGroupBuilder() + media = InputMediaPhoto(media="test") + + builder._extend([media, media]) + assert len(builder._media) == 2 + + def test_add_audio(self): + builder = MediaGroupBuilder() + builder.add_audio("test") + assert isinstance(builder._media[0], InputMediaAudio) + + def test_add_photo(self): + builder = MediaGroupBuilder() + builder.add_photo("test") + assert isinstance(builder._media[0], InputMediaPhoto) + + def test_add_video(self): + builder = MediaGroupBuilder() + builder.add_video("test") + assert isinstance(builder._media[0], InputMediaVideo) + + def test_add_document(self): + builder = MediaGroupBuilder() + builder.add_document("test") + assert isinstance(builder._media[0], InputMediaDocument) + + @pytest.mark.parametrize( + "type,result_type", + [ + ("audio", InputMediaAudio), + ("photo", InputMediaPhoto), + ("video", InputMediaVideo), + ("document", InputMediaDocument), + ], + ) + def test_add(self, type, result_type): + builder = MediaGroupBuilder() + builder.add(type=type, media="test") + assert isinstance(builder._media[0], result_type) + + def test_add_unknown_type(self): + builder = MediaGroupBuilder() + with pytest.raises(ValueError): + builder.add(type="unknown", media="test") + + def test_build(self): + builder = MediaGroupBuilder() + builder.add_photo("test") + assert builder.build() == builder._media + + def test_build_empty(self): + builder = MediaGroupBuilder() + assert builder.build() == [] + + def test_build_with_caption(self): + builder = MediaGroupBuilder( + caption="override caption", + caption_entities=[MessageEntity(type="bold", offset=0, length=8)], + ) + builder.add_photo("test", caption="test") + builder.add_photo("test", caption="test") + builder.add_photo("test", caption="test") + + media = builder.build() + assert len(media) == 3 + assert media[0].caption == "override caption" + assert media[1].caption == "test" + assert media[2].caption == "test"