Repository: aiogram/aiogram
Pull request #1293 (2023-09-02 21:23:42)

Added MediaGroupBuilder for media group construction
# Description

Implemented a MediaGroupBuilder class in 'aiogram/utils/media_group.py' to help construct media groups. The class supports addition of different media types (audio, photo, video, document) to the media group with a maximum limit of 10 files. The functionality is demonstrated and usage is documented in 'docs/utils/media_group.rst'. Added related test cases in 'tests/test_utils/test_media_group.py'. This is to streamline and simplify the process of media group creation

github-actions[bot]: # :heavy_check_mark: Changelog found.

Thank you for adding a description of the changes

Patch:
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,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://<file_attach_name>' to upload a new one using
+            multipart/form-data under <file_attach_name> name.
+             :ref:`More information on Sending Files » <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 <https://core.telegram.org/bots/api#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://<file_attach_name>' to upload a new
+            one using multipart/form-data under <file_attach_name> name.
+             :ref:`More information on Sending Files » <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 <https://core.telegram.org/bots/api#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://<file_attach_name>' to upload a new one
+            using multipart/form-data under <file_attach_name> name.
+            :ref:`More information on Sending Files » <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://<file_attach_name>' if the thumbnail was uploaded using
+            multipart/form-data under <file_attach_name>.
+            :ref:`More information on Sending Files » <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 <https://core.telegram.org/bots/api#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://<file_attach_name>' to upload a new one using
+            multipart/form-data under <file_attach_name> name.
+            :ref:`More information on Sending Files » <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://<file_attach_name>' if the thumbnail was uploaded
+            using multipart/form-data under <file_attach_name>.
+            :ref:`More information on Sending Files » <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 <https://core.telegram.org/bots/api#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:


Test patch:
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"