Merge branch 'dev-3.x' into scenes

This commit is contained in:
Alex Root Junior 2023-09-03 00:28:34 +03:00
commit ba3490363f
No known key found for this signature in database
GPG key ID: 074C1D455EBEA4AC
19 changed files with 718 additions and 41 deletions

View file

@ -16,6 +16,27 @@ Changelog
.. towncrier release notes start
3.0.0 (2023-09-01)
===================
Bugfixes
--------
- Replaced :code:`datetime.datetime` with `DateTime` type wrapper across types to make dumped JSONs object
more compatible with data that is sent by Telegram.
`#1277 <https://github.com/aiogram/aiogram/issues/1277>`_
- Fixed magic :code:`.as_(...)` operation for values that can be interpreted as `False` (e.g. `0`).
`#1281 <https://github.com/aiogram/aiogram/issues/1281>`_
- Italic markdown from utils now uses correct decorators
`#1282 <https://github.com/aiogram/aiogram/issues/1282>`_
- Fixed method :code:`Message.send_copy` for stickers.
`#1284 <https://github.com/aiogram/aiogram/issues/1284>`_
- Fixed :code:`Message.send_copy` method, which was not working properly with stories, so not you can copy stories too (forwards messages).
`#1286 <https://github.com/aiogram/aiogram/issues/1286>`_
- Fixed error overlapping when validation error is caused by remove_unset root validator in base types and methods.
`#1290 <https://github.com/aiogram/aiogram/issues/1290>`_
3.0.0rc2 (2023-08-18)
======================

1
CHANGES/1262.feature Normal file
View file

@ -0,0 +1 @@
Added support for custom encoders/decoders for payload (and also for deep-linking).

View file

@ -1,2 +0,0 @@
Replaced :code:`datetime.datetime` with `DateTime` type wrapper across types to make dumped JSONs object
more compatible with data that is sent by Telegram.

View file

@ -1 +0,0 @@
Fixed magic :code:`.as_(...)` operation for values that can be interpreted as `False` (e.g. `0`).

View file

@ -1 +0,0 @@
Italic markdown from utils now uses correct decorators

View file

@ -1 +0,0 @@
Fixed method :code:`Message.send_copy` for stickers.

View file

@ -1 +0,0 @@
Fixed :code:`Message.send_copy` method, which was not working properly with stories, so not you can copy stories too (forwards messages).

View file

@ -1 +0,0 @@
Fixed error overlapping when validation error is caused by remove_unset root validator in base types and methods.

1
CHANGES/1293.feature.rst Normal file
View file

@ -0,0 +1 @@
Added :class:`aiogram.utils.input_media.MediaGroupBuilder` for media group construction.

View file

@ -1,2 +1,2 @@
__version__ = "3.0.0"
__version__ = "3.0.1"
__api_version__ = "6.8"

View file

@ -16,7 +16,7 @@ Basic link example:
.. code-block:: python
from aiogram.utils.deep_linking import create_start_link
link = await create_start_link(bot, 'foo')
# result: 'https://t.me/MyBot?start=foo'
@ -46,19 +46,33 @@ Decode it back example:
"""
from __future__ import annotations
__all__ = [
"create_start_link",
"create_startgroup_link",
"create_deep_link",
"create_telegram_link",
"encode_payload",
"decode_payload",
]
import re
from base64 import urlsafe_b64decode, urlsafe_b64encode
from typing import TYPE_CHECKING, Literal, cast
from typing import Callable, Literal, Optional, TYPE_CHECKING, cast
from aiogram.utils.link import create_telegram_link
from aiogram.utils.payload import encode_payload, decode_payload
if TYPE_CHECKING:
from aiogram import Bot
BAD_PATTERN = re.compile(r"[^_A-z0-9-]")
BAD_PATTERN = re.compile(r"[^A-z0-9-]")
async def create_start_link(bot: Bot, payload: str, encode: bool = False) -> str:
async def create_start_link(
bot: Bot,
payload: str,
encode: bool = False,
encoder: Optional[Callable[[bytes], bytes]] = None,
) -> str:
"""
Create 'start' deep link with your payload.
@ -67,16 +81,26 @@ async def create_start_link(bot: Bot, payload: str, encode: bool = False) -> str
:param bot: bot instance
:param payload: args passed with /start
:param encode: encode payload with base64url
:param encode: encode payload with base64url or custom encoder
:param encoder: custom encoder callable
:return: link
"""
username = (await bot.me()).username
return create_deep_link(
username=cast(str, username), link_type="start", payload=payload, encode=encode
username=cast(str, username),
link_type="start",
payload=payload,
encode=encode,
encoder=encoder,
)
async def create_startgroup_link(bot: Bot, payload: str, encode: bool = False) -> str:
async def create_startgroup_link(
bot: Bot,
payload: str,
encode: bool = False,
encoder: Optional[Callable[[bytes], bytes]] = None,
) -> str:
"""
Create 'startgroup' deep link with your payload.
@ -85,17 +109,26 @@ async def create_startgroup_link(bot: Bot, payload: str, encode: bool = False) -
:param bot: bot instance
:param payload: args passed with /start
:param encode: encode payload with base64url
:param encode: encode payload with base64url or custom encoder
:param encoder: custom encoder callable
:return: link
"""
username = (await bot.me()).username
return create_deep_link(
username=cast(str, username), link_type="startgroup", payload=payload, encode=encode
username=cast(str, username),
link_type="startgroup",
payload=payload,
encode=encode,
encoder=encoder,
)
def create_deep_link(
username: str, link_type: Literal["start", "startgroup"], payload: str, encode: bool = False
username: str,
link_type: Literal["start", "startgroup"],
payload: str,
encode: bool = False,
encoder: Optional[Callable[[bytes], bytes]] = None,
) -> str:
"""
Create deep link.
@ -103,14 +136,15 @@ def create_deep_link(
:param username:
:param link_type: `start` or `startgroup`
:param payload: any string-convertible data
:param encode: pass True to encode the payload
:param encode: encode payload with base64url or custom encoder
:param encoder: custom encoder callable
:return: deeplink
"""
if not isinstance(payload, str):
payload = str(payload)
if encode:
payload = encode_payload(payload)
if encode or encoder:
payload = encode_payload(payload, encoder=encoder)
if re.search(BAD_PATTERN, payload):
raise ValueError(
@ -122,18 +156,3 @@ def create_deep_link(
raise ValueError("Payload must be up to 64 characters long.")
return create_telegram_link(username, **{cast(str, link_type): payload})
def encode_payload(payload: str) -> str:
"""Encode payload with URL-safe base64url."""
payload = str(payload)
bytes_payload: bytes = urlsafe_b64encode(payload.encode())
str_payload = bytes_payload.decode()
return str_payload.replace("=", "")
def decode_payload(payload: str) -> str:
"""Decode payload with URL-safe base64url."""
payload += "=" * (4 - len(payload) % 4)
result: bytes = urlsafe_b64decode(payload)
return result.decode()

View file

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

108
aiogram/utils/payload.py Normal file
View file

@ -0,0 +1,108 @@
"""
Payload preparing
We have added some utils to make work with payload easier.
Basic encode example:
.. code-block:: python
from aiogram.utils.payload import encode_payload
encoded = encode_payload("foo")
# result: "Zm9v"
Basic decode it back example:
.. code-block:: python
from aiogram.utils.payload import decode_payload
encoded = "Zm9v"
decoded = decode_payload(encoded)
# result: "foo"
Encoding and decoding with your own methods:
1. Create your own cryptor
.. code-block:: python
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
class Cryptor:
def __init__(self, key: str):
self.key = key.encode("utf-8")
self.mode = AES.MODE_ECB # never use ECB in strong systems obviously
self.size = 32
@property
def cipher(self):
return AES.new(self.key, self.mode)
def encrypt(self, data: bytes) -> bytes:
return self.cipher.encrypt(pad(data, self.size))
def decrypt(self, data: bytes) -> bytes:
decrypted_data = self.cipher.decrypt(data)
return unpad(decrypted_data, self.size)
2. Pass cryptor callable methods to aiogram payload tools
.. code-block:: python
cryptor = Cryptor("abcdefghijklmnop")
encoded = encode_payload("foo", encoder=cryptor.encrypt)
decoded = decode_payload(encoded_payload, decoder=cryptor.decrypt)
# result: decoded == "foo"
"""
from base64 import urlsafe_b64decode, urlsafe_b64encode
from typing import Callable, Optional
def encode_payload(
payload: str,
encoder: Optional[Callable[[bytes], bytes]] = None,
) -> str:
"""Encode payload with encoder.
Result also will be encoded with URL-safe base64url.
"""
if not isinstance(payload, str):
payload = str(payload)
payload_bytes = payload.encode("utf-8")
if encoder is not None:
payload_bytes = encoder(payload_bytes)
return _encode_b64(payload_bytes)
def decode_payload(
payload: str,
decoder: Optional[Callable[[bytes], bytes]] = None,
) -> str:
"""Decode URL-safe base64url payload with decoder."""
original_payload = _decode_b64(payload)
if decoder is None:
return original_payload.decode()
return decoder(original_payload).decode()
def _encode_b64(payload: bytes) -> str:
"""Encode with URL-safe base64url."""
bytes_payload: bytes = urlsafe_b64encode(payload)
str_payload = bytes_payload.decode()
return str_payload.replace("=", "")
def _decode_b64(payload: str) -> bytes:
"""Decode with URL-safe base64url."""
payload += "=" * (4 - len(payload) % 4)
return urlsafe_b64decode(payload.encode())

View file

@ -10,3 +10,4 @@ Utils
web_app
callback_answer
formatting
media_group

View file

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

View file

@ -78,7 +78,8 @@ test = [
"pytest-cov~=4.0.0",
"pytest-aiohttp~=1.0.4",
"aresponses~=2.1.6",
"pytz~=2022.7.1"
"pytz~=2022.7.1",
"pycryptodomex~=3.18",
]
docs = [
"Sphinx~=7.1.1",

View file

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

View file

@ -3,9 +3,8 @@ import pytest
from aiogram.utils.deep_linking import (
create_start_link,
create_startgroup_link,
decode_payload,
encode_payload,
)
from aiogram.utils.payload import decode_payload, encode_payload
from tests.mocked_bot import MockedBot
PAYLOADS = [
@ -51,6 +50,33 @@ class TestDeepLinking:
decoded = decode_payload(encoded)
assert decoded == str(payload)
async def test_custom_encode_decode(self, payload: str):
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
class Cryptor:
def __init__(self, key: str):
self.key = key.encode("utf-8")
self.mode = AES.MODE_ECB # never use ECB in strong systems obviously
self.size = 32
@property
def cipher(self):
return AES.new(self.key, self.mode)
def encrypt(self, data: bytes) -> bytes:
return self.cipher.encrypt(pad(data, self.size))
def decrypt(self, data: bytes) -> bytes:
decrypted_data = self.cipher.decrypt(data)
return unpad(decrypted_data, self.size)
cryptor = Cryptor("abcdefghijklmnop")
encoded_payload = encode_payload(payload, encoder=cryptor.encrypt)
decoded_payload = decode_payload(encoded_payload, decoder=cryptor.decrypt)
assert decoded_payload == str(payload)
async def test_get_start_link_with_encoding(self, bot: MockedBot, wrong_payload: str):
# define link
link = await create_start_link(bot, wrong_payload, encode=True)

View file

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