From f5bf1afafd577179882c5ab35261f2207206f4a2 Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Fri, 27 Feb 2026 19:45:12 +0300 Subject: [PATCH 01/12] add MediaGroupAggregatorMiddleware --- CHANGES/1697.feature.rst | 1 + aiogram/dispatcher/middlewares/media_group.py | 151 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 CHANGES/1697.feature.rst create mode 100644 aiogram/dispatcher/middlewares/media_group.py diff --git a/CHANGES/1697.feature.rst b/CHANGES/1697.feature.rst new file mode 100644 index 00000000..99cf7cc4 --- /dev/null +++ b/CHANGES/1697.feature.rst @@ -0,0 +1 @@ +Added ``MediaGroupAggregatorMiddleware`` to aggregate media groups into a single event with an album list in the handler data. diff --git a/aiogram/dispatcher/middlewares/media_group.py b/aiogram/dispatcher/middlewares/media_group.py new file mode 100644 index 00000000..9ba860b1 --- /dev/null +++ b/aiogram/dispatcher/middlewares/media_group.py @@ -0,0 +1,151 @@ +import asyncio +import time +from abc import ABC, abstractmethod +from collections import defaultdict +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, cast + +from aiogram.dispatcher.middlewares.base import BaseMiddleware +from aiogram.types import Message, TelegramObject + +if TYPE_CHECKING: + from redis.asyncio.client import Redis + +DELAY_SEC = 1.0 +TIMEOUT_SEC = 10 +TTL_SEC = 600 + + +class BaseMediaGroupAggregator(ABC): + @abstractmethod + async def add_into_group(self, media_group_id: str, media: Message) -> int: + raise NotImplementedError + + @abstractmethod + async def acquire_lock(self, media_group_id: str) -> bool: + raise NotImplementedError + + @abstractmethod + async def get_and_delete_group(self, media_group_id: str) -> list[Message]: + raise NotImplementedError + + @abstractmethod + async def get_last_message_time(self, media_group_id: str) -> float | None: + raise NotImplementedError + + +class RedisMediaGroupAggregator(BaseMediaGroupAggregator): + redis: "Redis" + + def __init__(self, redis: "Redis") -> None: + self.redis = redis + + @staticmethod + def get_group_key(media_group_id: str) -> str: + return f"media_group:{media_group_id}:album" + + @staticmethod + def get_last_message_time_key(media_group_id: str) -> str: + return f"media_group:{media_group_id}:last_message_time" + + @staticmethod + def get_group_lock_key(media_group_id: str) -> str: + return f"media_group:{media_group_id}:lock" + + async def add_into_group(self, media_group_id: str, media: Message) -> int: + async with self.redis.pipeline(transaction=True) as pipe: + pipe.set(self.get_last_message_time_key(media_group_id), time.time(), ex=TTL_SEC) + pipe.rpush(self.get_group_key(media_group_id), media.model_dump_json()) + pipe.expire(self.get_group_key(media_group_id), TTL_SEC) + res = await pipe.execute() + return cast(int, res[1]) + + async def acquire_lock(self, media_group_id: str) -> bool: + return cast( + bool, + await self.redis.set( + self.get_group_lock_key(media_group_id), "1", nx=True, ex=TIMEOUT_SEC + ), + ) + + async def get_and_delete_group(self, media_group_id: str) -> list[Message]: + async with self.redis.pipeline(transaction=True) as pipe: + pipe.lrange(self.get_group_key(media_group_id), 0, -1) + pipe.delete(self.get_group_key(media_group_id)) + pipe.delete(self.get_last_message_time_key(media_group_id)) + result = await pipe.execute() + return [Message.model_validate_json(msg) for msg in result[0]] + + async def get_last_message_time(self, media_group_id: str) -> float | None: + result = await self.redis.get(self.get_last_message_time_key(media_group_id)) + if result is None: + return None + return float(result) + + +class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): + def __init__(self) -> None: + self.groups: dict[str, list[Message]] = defaultdict(list) + self.last_message_timers: dict[str, float] = {} + self.locks: dict[str, bool] = {} + + async def add_into_group(self, media_group_id: str, media: Message) -> int: + self.groups[media_group_id].append(media) + self.last_message_timers[media_group_id] = time.time() + return len(self.groups[media_group_id]) + + async def acquire_lock(self, media_group_id: str) -> bool: + if self.locks.get(media_group_id): + return False + self.locks[media_group_id] = True + return True + + async def get_and_delete_group(self, media_group_id: str) -> list[Message]: + group = self.groups[media_group_id] + self.groups.pop(media_group_id, None) + self.last_message_timers.pop(media_group_id, None) + self.locks.pop(media_group_id, None) + return group + + async def get_last_message_time(self, media_group_id: str) -> float | None: + return self.last_message_timers.get(media_group_id) + + +class MediaGroupAggregatorMiddleware(BaseMiddleware): + def __init__( + self, + media_group_aggregator: BaseMediaGroupAggregator | None = None, + ) -> None: + self.media_group_aggregator = media_group_aggregator or MemoryMediaGroupAggregator() + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + if not isinstance(event, Message) or not event.media_group_id: + return await handler(event, data) + await self.media_group_aggregator.add_into_group(event.media_group_id, event) + if not await self.media_group_aggregator.acquire_lock(event.media_group_id): + return None + last_message_time = time.time() + while True: + delta = DELAY_SEC - (time.time() - last_message_time) + if delta <= 0: + album = await self.media_group_aggregator.get_and_delete_group( + event.media_group_id + ) + if not album: + return None + album.sort(key=lambda msg: msg.message_id) + data.update(album=album) + return await handler(album[0], data) + + await asyncio.sleep(delta) + new_last_message_time = await self.media_group_aggregator.get_last_message_time( + event.media_group_id + ) + if not new_last_message_time: + return None + last_message_time = new_last_message_time From 8420f8d53dded134d3b6a02bdd340f2397630574 Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sat, 28 Feb 2026 12:53:35 +0300 Subject: [PATCH 02/12] fix: instantly release lock if handler failed --- aiogram/dispatcher/middlewares/media_group.py | 86 +++++++++++++------ 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/aiogram/dispatcher/middlewares/media_group.py b/aiogram/dispatcher/middlewares/media_group.py index 9ba860b1..eca9f931 100644 --- a/aiogram/dispatcher/middlewares/media_group.py +++ b/aiogram/dispatcher/middlewares/media_group.py @@ -26,13 +26,32 @@ class BaseMediaGroupAggregator(ABC): raise NotImplementedError @abstractmethod - async def get_and_delete_group(self, media_group_id: str) -> list[Message]: + async def release_lock(self, media_group_id: str) -> None: + raise NotImplementedError + + @abstractmethod + async def get_group(self, media_group_id: str) -> list[Message]: + raise NotImplementedError + + @abstractmethod + async def delete_group(self, media_group_id: str) -> None: raise NotImplementedError @abstractmethod async def get_last_message_time(self, media_group_id: str) -> float | None: raise NotImplementedError + @staticmethod + def deduplicate_messages(messages: list[Message]) -> list[Message]: + message_ids = set() + result = [] + for message in messages: + if message.message_id in message_ids: + continue + result.append(message) + message_ids.add(message.message_id) + return result + class RedisMediaGroupAggregator(BaseMediaGroupAggregator): redis: "Redis" @@ -68,13 +87,18 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): ), ) - async def get_and_delete_group(self, media_group_id: str) -> list[Message]: + async def release_lock(self, media_group_id: str) -> None: + await self.redis.delete(self.get_group_lock_key(media_group_id)) + + async def get_group(self, media_group_id: str) -> list[Message]: + result = await self.redis.lrange(self.get_group_key(media_group_id), 0, -1) + return self.deduplicate_messages([Message.model_validate_json(msg) for msg in result]) + + async def delete_group(self, media_group_id: str) -> None: async with self.redis.pipeline(transaction=True) as pipe: - pipe.lrange(self.get_group_key(media_group_id), 0, -1) pipe.delete(self.get_group_key(media_group_id)) pipe.delete(self.get_last_message_time_key(media_group_id)) - result = await pipe.execute() - return [Message.model_validate_json(msg) for msg in result[0]] + await pipe.execute() async def get_last_message_time(self, media_group_id: str) -> float | None: result = await self.redis.get(self.get_last_message_time_key(media_group_id)) @@ -90,7 +114,8 @@ class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): self.locks: dict[str, bool] = {} async def add_into_group(self, media_group_id: str, media: Message) -> int: - self.groups[media_group_id].append(media) + if media.message_id not in (msg.message_id for msg in self.groups[media_group_id]): + self.groups[media_group_id].append(media) self.last_message_timers[media_group_id] = time.time() return len(self.groups[media_group_id]) @@ -100,12 +125,15 @@ class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): self.locks[media_group_id] = True return True - async def get_and_delete_group(self, media_group_id: str) -> list[Message]: - group = self.groups[media_group_id] + async def release_lock(self, media_group_id: str) -> None: + self.locks.pop(media_group_id, None) + + async def get_group(self, media_group_id: str) -> list[Message]: + return self.groups.get(media_group_id, []) + + async def delete_group(self, media_group_id: str) -> None: self.groups.pop(media_group_id, None) self.last_message_timers.pop(media_group_id, None) - self.locks.pop(media_group_id, None) - return group async def get_last_message_time(self, media_group_id: str) -> float | None: return self.last_message_timers.get(media_group_id) @@ -115,8 +143,10 @@ class MediaGroupAggregatorMiddleware(BaseMiddleware): def __init__( self, media_group_aggregator: BaseMediaGroupAggregator | None = None, + delay: float = DELAY_SEC, ) -> None: self.media_group_aggregator = media_group_aggregator or MemoryMediaGroupAggregator() + self.delay = delay async def __call__( self, @@ -129,23 +159,25 @@ class MediaGroupAggregatorMiddleware(BaseMiddleware): await self.media_group_aggregator.add_into_group(event.media_group_id, event) if not await self.media_group_aggregator.acquire_lock(event.media_group_id): return None - last_message_time = time.time() - while True: - delta = DELAY_SEC - (time.time() - last_message_time) - if delta <= 0: - album = await self.media_group_aggregator.get_and_delete_group( + try: + last_message_time = time.time() + while True: + delta = self.delay - (time.time() - last_message_time) + if delta <= 0: + album = await self.media_group_aggregator.get_group(event.media_group_id) + if not album: + return None + album.sort(key=lambda msg: msg.message_id) + data.update(album=album) + result = await handler(album[0], data) + await self.media_group_aggregator.delete_group(event.media_group_id) + return result + await asyncio.sleep(delta) + new_last_message_time = await self.media_group_aggregator.get_last_message_time( event.media_group_id ) - if not album: + if not new_last_message_time: return None - album.sort(key=lambda msg: msg.message_id) - data.update(album=album) - return await handler(album[0], data) - - await asyncio.sleep(delta) - new_last_message_time = await self.media_group_aggregator.get_last_message_time( - event.media_group_id - ) - if not new_last_message_time: - return None - last_message_time = new_last_message_time + last_message_time = new_last_message_time + finally: + await self.media_group_aggregator.release_lock(event.media_group_id) From 80db313c16140621206e0f2a501463e6cf601628 Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sat, 28 Feb 2026 12:53:59 +0300 Subject: [PATCH 03/12] add tests for MediaGroup middleware --- .../test_middlewares/test_media_group.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/test_dispatcher/test_middlewares/test_media_group.py diff --git a/tests/test_dispatcher/test_middlewares/test_media_group.py b/tests/test_dispatcher/test_middlewares/test_media_group.py new file mode 100644 index 00000000..07ce6941 --- /dev/null +++ b/tests/test_dispatcher/test_middlewares/test_media_group.py @@ -0,0 +1,86 @@ +import asyncio + +from aiogram.dispatcher.middlewares.media_group import MediaGroupAggregatorMiddleware +from aiogram.types import Message, Chat +from datetime import datetime +from typing import Any +import pytest + +class TestMediaGroupAggregatorMiddleware: + def _get_message(self, message_id: int, **kwargs): + chat = Chat(id=1, type="private", title="Test") + return Message(message_id=message_id, date=datetime.now(), chat=chat, **kwargs) + + + def get_middleware(self): + return MediaGroupAggregatorMiddleware(delay=0.1) + + async def test_skip_non_media_group(self): + is_called = False + async def next_handler(*args, **kwargs): + nonlocal is_called + is_called = True + await self.get_middleware()(next_handler, self._get_message(1), {}) + assert is_called + + async def test_called_once_for_album(self): + middleware = self.get_middleware() + counter = 0 + album = None + async def next_handler(_, data: dict[str, Any]): + nonlocal counter, album + counter += 1 + album = data.get("album") + await asyncio.gather( + middleware(next_handler, self._get_message(1, media_group_id="42"), {}), + middleware(next_handler, self._get_message(2, media_group_id="42"), {}) + ) + assert album is not None + assert len(album) == 2 + assert counter == 1 + + async def test_propagate_first_media_in_album(self): + middleware = self.get_middleware() + first_message = None + async def next_handler(message: Message, _): + nonlocal first_message + first_message = message + await asyncio.gather( + middleware(next_handler, self._get_message(2, media_group_id="42"), {}), + middleware(next_handler, self._get_message(1, media_group_id="42"), {}) + ) + assert isinstance(first_message, Message) + assert first_message.message_id == 1 + + async def test_different_albums_non_interfere(self): + middleware = self.get_middleware() + counter = 0 + albums = [] + async def next_handler(_, data: dict[str, Any]): + nonlocal counter, albums + counter += 1 + albums.append(data.get("album")) + await asyncio.gather( + middleware(next_handler, self._get_message(1, media_group_id="1"), {}), + middleware(next_handler, self._get_message(2, media_group_id="2"), {}) + ) + assert counter == 2 + assert len(albums) == 2 + + async def test_retry_handling(self): + middleware = self.get_middleware() + album = None + async def failed_handler(*args, **kwargs): + raise Exception("Failed") + async def working_handler(_, data: dict[str, Any]): + nonlocal album + album = data.get("album") + first_message = self._get_message(1, media_group_id="42") + second_message = self._get_message(2, media_group_id="42") + with pytest.raises(Exception): + await asyncio.gather( + middleware(failed_handler, first_message, {}), + middleware(failed_handler, second_message, {}) + ) + await middleware(working_handler, first_message, {}) + assert len(album) == 2 From 998d6c37428ee4118b4c28116dd57480769169dd Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sat, 28 Feb 2026 14:54:30 +0300 Subject: [PATCH 04/12] feat: add TTL to MemoryMediaGroupAggregator --- aiogram/dispatcher/middlewares/media_group.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/aiogram/dispatcher/middlewares/media_group.py b/aiogram/dispatcher/middlewares/media_group.py index eca9f931..7ca2ac6b 100644 --- a/aiogram/dispatcher/middlewares/media_group.py +++ b/aiogram/dispatcher/middlewares/media_group.py @@ -91,7 +91,9 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): await self.redis.delete(self.get_group_lock_key(media_group_id)) async def get_group(self, media_group_id: str) -> list[Message]: - result = await self.redis.lrange(self.get_group_key(media_group_id), 0, -1) + result = await cast( + Awaitable[list[str]], self.redis.lrange(self.get_group_key(media_group_id), 0, -1) + ) return self.deduplicate_messages([Message.model_validate_json(msg) for msg in result]) async def delete_group(self, media_group_id: str) -> None: @@ -113,9 +115,25 @@ class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): self.last_message_timers: dict[str, float] = {} self.locks: dict[str, bool] = {} + def remove_expired_objects(self) -> None: + expired_group_ids = [] + current_time = time.time() + for group_id, last_message_time in self.last_message_timers.items(): + if current_time - last_message_time > TTL_SEC: + expired_group_ids.append(group_id) + else: + break # the list is sorted in ascending order + # because python 3.7+ save dict in insertion order + for group_id in expired_group_ids: + self.groups.pop(group_id, None) + self.last_message_timers.pop(group_id, None) + self.locks.pop(group_id, None) + async def add_into_group(self, media_group_id: str, media: Message) -> int: + self.remove_expired_objects() if media.message_id not in (msg.message_id for msg in self.groups[media_group_id]): self.groups[media_group_id].append(media) + self.last_message_timers.pop(media_group_id, None) self.last_message_timers[media_group_id] = time.time() return len(self.groups[media_group_id]) @@ -145,6 +163,9 @@ class MediaGroupAggregatorMiddleware(BaseMiddleware): media_group_aggregator: BaseMediaGroupAggregator | None = None, delay: float = DELAY_SEC, ) -> None: + """ + :param delay: delay between last received message in media group and processing it + """ self.media_group_aggregator = media_group_aggregator or MemoryMediaGroupAggregator() self.delay = delay From a8bd68eb358bbe3c26f1dc0966a6260d27989cd2 Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sat, 28 Feb 2026 16:43:54 +0300 Subject: [PATCH 05/12] feat: add MediaGroupFilter --- aiogram/filters/media_group.py | 60 ++++++++++++++++++ .../test_middlewares/test_media_group.py | 21 +++++-- tests/test_filters/test_media_group.py | 61 +++++++++++++++++++ 3 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 aiogram/filters/media_group.py create mode 100644 tests/test_filters/test_media_group.py diff --git a/aiogram/filters/media_group.py b/aiogram/filters/media_group.py new file mode 100644 index 00000000..bc146e01 --- /dev/null +++ b/aiogram/filters/media_group.py @@ -0,0 +1,60 @@ +from typing import Any, Literal + +from aiogram.filters.base import Filter +from aiogram.types import Message + +MIN_MEDIA_COUNT = 2 +DEFAULT_MAX_MEDIA_COUNT = 10 + + +class MediaGroupFilter(Filter): + """ + This filter helps to handle media groups. + + Works only with :class:`aiogram.types.message.Message` events which have the :code:`album` + in the handler context. + """ + + __slots__ = ("min_media_count", "max_media_count") + + def __init__( + self, + count: int | None = None, + min_media_count: int | None = None, + max_media_count: int | None = None, + ): + """ + :param count: expected count of media in the group. + :param min_media_count: min count of media in the group, inclusively + :param max_media_count: max count of media in the group, inclusively + """ + if count is None: + min_media_count = min_media_count or MIN_MEDIA_COUNT + max_media_count = max_media_count or DEFAULT_MAX_MEDIA_COUNT + else: + if min_media_count is not None or max_media_count is not None: + raise ValueError( + "count and min_media_count or max_media_count can not be used together" + ) + if count < MIN_MEDIA_COUNT: + raise ValueError(f"count should be greater or equal to {MIN_MEDIA_COUNT}") + min_media_count = max_media_count = count + if min_media_count < MIN_MEDIA_COUNT: + raise ValueError(f"min_media_count should be greater or equal to {MIN_MEDIA_COUNT}") + if max_media_count < min_media_count: + raise ValueError("max_media_count should be greater or equal to min_media_count") + self.min_media_count = min_media_count + self.max_media_count = max_media_count + + def __str__(self) -> str: + return self._signature_to_string( + min_media_count=self.min_media_count, max_media_count=self.max_media_count + ) + + async def __call__( + self, message: Message, album: list[Message] = None + ) -> Literal[False] | dict[str, Any]: + media_count = len(album or []) + if not (self.min_media_count <= media_count <= self.max_media_count): + return False + return {"media_count": media_count} diff --git a/tests/test_dispatcher/test_middlewares/test_media_group.py b/tests/test_dispatcher/test_middlewares/test_media_group.py index 07ce6941..f9fd7373 100644 --- a/tests/test_dispatcher/test_middlewares/test_media_group.py +++ b/tests/test_dispatcher/test_middlewares/test_media_group.py @@ -6,20 +6,22 @@ from datetime import datetime from typing import Any import pytest + class TestMediaGroupAggregatorMiddleware: def _get_message(self, message_id: int, **kwargs): chat = Chat(id=1, type="private", title="Test") return Message(message_id=message_id, date=datetime.now(), chat=chat, **kwargs) - def get_middleware(self): return MediaGroupAggregatorMiddleware(delay=0.1) async def test_skip_non_media_group(self): is_called = False + async def next_handler(*args, **kwargs): nonlocal is_called is_called = True + await self.get_middleware()(next_handler, self._get_message(1), {}) assert is_called @@ -27,13 +29,15 @@ class TestMediaGroupAggregatorMiddleware: middleware = self.get_middleware() counter = 0 album = None + async def next_handler(_, data: dict[str, Any]): nonlocal counter, album counter += 1 album = data.get("album") + await asyncio.gather( middleware(next_handler, self._get_message(1, media_group_id="42"), {}), - middleware(next_handler, self._get_message(2, media_group_id="42"), {}) + middleware(next_handler, self._get_message(2, media_group_id="42"), {}), ) assert album is not None assert len(album) == 2 @@ -42,12 +46,14 @@ class TestMediaGroupAggregatorMiddleware: async def test_propagate_first_media_in_album(self): middleware = self.get_middleware() first_message = None + async def next_handler(message: Message, _): nonlocal first_message first_message = message + await asyncio.gather( middleware(next_handler, self._get_message(2, media_group_id="42"), {}), - middleware(next_handler, self._get_message(1, media_group_id="42"), {}) + middleware(next_handler, self._get_message(1, media_group_id="42"), {}), ) assert isinstance(first_message, Message) assert first_message.message_id == 1 @@ -56,13 +62,15 @@ class TestMediaGroupAggregatorMiddleware: middleware = self.get_middleware() counter = 0 albums = [] + async def next_handler(_, data: dict[str, Any]): nonlocal counter, albums counter += 1 albums.append(data.get("album")) + await asyncio.gather( middleware(next_handler, self._get_message(1, media_group_id="1"), {}), - middleware(next_handler, self._get_message(2, media_group_id="2"), {}) + middleware(next_handler, self._get_message(2, media_group_id="2"), {}), ) assert counter == 2 assert len(albums) == 2 @@ -70,17 +78,20 @@ class TestMediaGroupAggregatorMiddleware: async def test_retry_handling(self): middleware = self.get_middleware() album = None + async def failed_handler(*args, **kwargs): raise Exception("Failed") + async def working_handler(_, data: dict[str, Any]): nonlocal album album = data.get("album") + first_message = self._get_message(1, media_group_id="42") second_message = self._get_message(2, media_group_id="42") with pytest.raises(Exception): await asyncio.gather( middleware(failed_handler, first_message, {}), - middleware(failed_handler, second_message, {}) + middleware(failed_handler, second_message, {}), ) await middleware(working_handler, first_message, {}) assert len(album) == 2 diff --git a/tests/test_filters/test_media_group.py b/tests/test_filters/test_media_group.py new file mode 100644 index 00000000..e2471210 --- /dev/null +++ b/tests/test_filters/test_media_group.py @@ -0,0 +1,61 @@ +from aiogram.filters.media_group import MediaGroupFilter, MIN_MEDIA_COUNT, DEFAULT_MAX_MEDIA_COUNT +import pytest +import datetime +from aiogram.types import Message, Chat + + +class TestMediaGroupFilter: + @pytest.mark.parametrize( + "args,min_count,max_count", + [ + ((), MIN_MEDIA_COUNT, DEFAULT_MAX_MEDIA_COUNT), + ((3,), 3, 3), + ((None, 3), 3, DEFAULT_MAX_MEDIA_COUNT), + ((None, None, 3), MIN_MEDIA_COUNT, 3), + ], + ) + def test_init_range(self, args, min_count, max_count): + filter = MediaGroupFilter(*args) + assert filter.max_media_count == max_count + assert filter.min_media_count == min_count + + @pytest.mark.parametrize( + "count,min_count,max_count", + [ + (1, None, 1), + (1, 1, None), + (None, 1, None), + (None, None, 1), + (1, None, None), + (None, 5, 3), + ], + ) + def test_raise_error(self, count, min_count, max_count): + with pytest.raises(ValueError): + MediaGroupFilter(count, min_count, max_count) + + @pytest.mark.parametrize( + "min_count,max_count,media_count,result", + [ + [2, 2, 1, False], + [2, 2, 2, True], + [2, 2, 3, False], + [2, 5, 2, True], + [2, 5, 5, True], + [2, 5, 6, False], + ], + ) + async def test_call(self, min_count, max_count, media_count, result): + filter = MediaGroupFilter(min_media_count=min_count, max_media_count=max_count) + album = [ + Message( + message_id=i, + date=datetime.datetime.now(), + chat=Chat(id=42, type="private"), + ) + for i in range(media_count) + ] + response = await filter(album[0], album) + assert bool(response) is result + if result: + assert response.get("media_count") == media_count From 7f21762fce860d1db833fc3f6d63ea61611edb47 Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sat, 28 Feb 2026 17:53:12 +0300 Subject: [PATCH 06/12] fix string representation for MediaGroupFilter --- aiogram/filters/media_group.py | 2 ++ tests/test_filters/test_media_group.py | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/aiogram/filters/media_group.py b/aiogram/filters/media_group.py index bc146e01..3c8422f4 100644 --- a/aiogram/filters/media_group.py +++ b/aiogram/filters/media_group.py @@ -47,6 +47,8 @@ class MediaGroupFilter(Filter): self.max_media_count = max_media_count def __str__(self) -> str: + if self.min_media_count == self.max_media_count: + return self._signature_to_string(count=self.min_media_count) return self._signature_to_string( min_media_count=self.min_media_count, max_media_count=self.max_media_count ) diff --git a/tests/test_filters/test_media_group.py b/tests/test_filters/test_media_group.py index e2471210..3d1af49b 100644 --- a/tests/test_filters/test_media_group.py +++ b/tests/test_filters/test_media_group.py @@ -1,7 +1,9 @@ -from aiogram.filters.media_group import MediaGroupFilter, MIN_MEDIA_COUNT, DEFAULT_MAX_MEDIA_COUNT -import pytest import datetime -from aiogram.types import Message, Chat + +import pytest + +from aiogram.filters.media_group import DEFAULT_MAX_MEDIA_COUNT, MIN_MEDIA_COUNT, MediaGroupFilter +from aiogram.types import Chat, Message class TestMediaGroupFilter: @@ -59,3 +61,11 @@ class TestMediaGroupFilter: assert bool(response) is result if result: assert response.get("media_count") == media_count + + def test_str_count(self): + filter = MediaGroupFilter(5) + assert str(filter) == "MediaGroupFilter(count=5)" + + def test_str_range(self): + filter = MediaGroupFilter(min_media_count=2, max_media_count=5) + assert str(filter) == "MediaGroupFilter(min_media_count=2, max_media_count=5)" From 16b718ca07e5d76621dd521ac5f58425b417aed4 Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sat, 28 Feb 2026 17:58:59 +0300 Subject: [PATCH 07/12] docs: describe processing media groups --- docs/utils/media_group.rst | 53 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/utils/media_group.rst b/docs/utils/media_group.rst index c9501a66..dbf33a49 100644 --- a/docs/utils/media_group.rst +++ b/docs/utils/media_group.rst @@ -1,8 +1,13 @@ =================== -Media group builder +Media group =================== -This module provides a builder for media groups, it can be used to build media groups +This module provides tools for media groups. + +Building media groups +===================== + +Media group builder 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`. @@ -39,8 +44,52 @@ it will be used as ``caption`` for first media in group. await bot.send_media_group(chat_id=chat_id, media=media_group.build()) +Handling media groups +====================== + +By default each media in the group is processed separately. + +You can use :class:`aiogram.dispatcher.middlewares.media_group.MediaGroupAggregatorMiddleware` +to process media groups as one. If you do, only one message from the group will be processed, and updates for +other messages with the same media group ID will be suppressed. + +You also can use :class:`aiogram.filters.media_group.MediaGroupFilter` +to filter media groups. + +Usage +===== + +.. code-block:: python + + from aiogram import F + from aiogram.types import Message + + # register middleware + from aiogram.dispatcher.middlewares.media_group import MediaGroupAggregatorMiddleware + from aiogram.filters.media_group import MediaGroupFilter + + router.message.outer_middleware(MediaGroupAggregatorMiddleware()) + + # use middleware + @router.message( + MediaGroupFilter(max_count=5), + F.caption == "album_caption" # other filters will be applied to the first message in the group + ) + async def start(message: Message, album: list[Message]): + # message is the first media in this group + # album is list of all messages with the same mediaGroupId, including current message + await message.answer( + f"You sent {len(album)} media in the group. " + f"Media group ID: {message.media_group_id}. " + f"Album messages: {', '.join(str(m.message_id) for m in album)}" + ) + References ========== .. autoclass:: aiogram.utils.media_group.MediaGroupBuilder :members: +.. autoclass:: aiogram.dispatcher.middlewares.media_group.MediaGroupAggregatorMiddleware + :members: +.. autoclass:: aiogram.filters.media_group.MediaGroupFilter + :members: From b7f2da391b5619765e8ebf7ca5346256bbcc3885 Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sat, 28 Feb 2026 20:32:36 +0300 Subject: [PATCH 08/12] update tests for media group aggregator --- aiogram/dispatcher/middlewares/media_group.py | 18 ++- aiogram/filters/media_group.py | 2 +- .../test_middlewares/test_media_group.py | 151 +++++++++++++++--- 3 files changed, 144 insertions(+), 27 deletions(-) diff --git a/aiogram/dispatcher/middlewares/media_group.py b/aiogram/dispatcher/middlewares/media_group.py index 7ca2ac6b..5efcd0f8 100644 --- a/aiogram/dispatcher/middlewares/media_group.py +++ b/aiogram/dispatcher/middlewares/media_group.py @@ -5,6 +5,7 @@ from collections import defaultdict from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any, cast +from aiogram import Bot from aiogram.dispatcher.middlewares.base import BaseMiddleware from aiogram.types import Message, TelegramObject @@ -56,8 +57,9 @@ class BaseMediaGroupAggregator(ABC): class RedisMediaGroupAggregator(BaseMediaGroupAggregator): redis: "Redis" - def __init__(self, redis: "Redis") -> None: + def __init__(self, redis: "Redis", ttl_sec: int = TTL_SEC) -> None: self.redis = redis + self.ttl_sec = ttl_sec @staticmethod def get_group_key(media_group_id: str) -> str: @@ -73,9 +75,9 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): async def add_into_group(self, media_group_id: str, media: Message) -> int: async with self.redis.pipeline(transaction=True) as pipe: - pipe.set(self.get_last_message_time_key(media_group_id), time.time(), ex=TTL_SEC) + pipe.set(self.get_last_message_time_key(media_group_id), time.time(), ex=self.ttl_sec) pipe.rpush(self.get_group_key(media_group_id), media.model_dump_json()) - pipe.expire(self.get_group_key(media_group_id), TTL_SEC) + pipe.expire(self.get_group_key(media_group_id), self.ttl_sec) res = await pipe.execute() return cast(int, res[1]) @@ -110,16 +112,17 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): - def __init__(self) -> None: + def __init__(self, ttl_sec: int = TTL_SEC) -> None: self.groups: dict[str, list[Message]] = defaultdict(list) self.last_message_timers: dict[str, float] = {} self.locks: dict[str, bool] = {} + self.ttl_sec = ttl_sec def remove_expired_objects(self) -> None: expired_group_ids = [] current_time = time.time() for group_id, last_message_time in self.last_message_timers.items(): - if current_time - last_message_time > TTL_SEC: + if current_time - last_message_time > self.ttl_sec: expired_group_ids.append(group_id) else: break # the list is sorted in ascending order @@ -188,7 +191,10 @@ class MediaGroupAggregatorMiddleware(BaseMiddleware): album = await self.media_group_aggregator.get_group(event.media_group_id) if not album: return None - album.sort(key=lambda msg: msg.message_id) + album = sorted( + (msg.as_(cast(Bot, data.get("bot"))) for msg in album), + key=lambda msg: msg.message_id, + ) data.update(album=album) result = await handler(album[0], data) await self.media_group_aggregator.delete_group(event.media_group_id) diff --git a/aiogram/filters/media_group.py b/aiogram/filters/media_group.py index 3c8422f4..a685bb23 100644 --- a/aiogram/filters/media_group.py +++ b/aiogram/filters/media_group.py @@ -54,7 +54,7 @@ class MediaGroupFilter(Filter): ) async def __call__( - self, message: Message, album: list[Message] = None + self, message: Message, album: list[Message] | None = None ) -> Literal[False] | dict[str, Any]: media_count = len(album or []) if not (self.min_media_count <= media_count <= self.max_media_count): diff --git a/tests/test_dispatcher/test_middlewares/test_media_group.py b/tests/test_dispatcher/test_middlewares/test_media_group.py index f9fd7373..91b4bdba 100644 --- a/tests/test_dispatcher/test_middlewares/test_media_group.py +++ b/tests/test_dispatcher/test_middlewares/test_media_group.py @@ -1,17 +1,41 @@ import asyncio - -from aiogram.dispatcher.middlewares.media_group import MediaGroupAggregatorMiddleware -from aiogram.types import Message, Chat +import time from datetime import datetime -from typing import Any +from typing import Any, Awaitable, Callable +from unittest import mock + import pytest +from redis.asyncio.client import Redis + +from aiogram.dispatcher.middlewares.media_group import ( + BaseMediaGroupAggregator, + MediaGroupAggregatorMiddleware, + MemoryMediaGroupAggregator, + RedisMediaGroupAggregator, +) +from aiogram.types import Chat, Message + + +def _get_message(message_id: int, **kwargs): + chat = Chat(id=1, type="private", title="Test") + return Message(message_id=message_id, date=datetime.now(), chat=chat, **kwargs) + + +async def wait_until_func_call_sleep(func: Callable[..., Awaitable[Any]], *args, **kwargs) -> Any: + start_sleep = asyncio.Event() + real_sleep = asyncio.sleep + + async def mock_sleep(*args, **kwargs): + start_sleep.set() + await real_sleep(0) + + with mock.patch("asyncio.sleep", mock_sleep): + task1 = func(*args, **kwargs) + await start_sleep.wait() + return task1 class TestMediaGroupAggregatorMiddleware: - def _get_message(self, message_id: int, **kwargs): - chat = Chat(id=1, type="private", title="Test") - return Message(message_id=message_id, date=datetime.now(), chat=chat, **kwargs) - def get_middleware(self): return MediaGroupAggregatorMiddleware(delay=0.1) @@ -22,7 +46,7 @@ class TestMediaGroupAggregatorMiddleware: nonlocal is_called is_called = True - await self.get_middleware()(next_handler, self._get_message(1), {}) + await self.get_middleware()(next_handler, _get_message(1), {}) assert is_called async def test_called_once_for_album(self): @@ -36,13 +60,26 @@ class TestMediaGroupAggregatorMiddleware: album = data.get("album") await asyncio.gather( - middleware(next_handler, self._get_message(1, media_group_id="42"), {}), - middleware(next_handler, self._get_message(2, media_group_id="42"), {}), + middleware(next_handler, _get_message(1, media_group_id="42"), {}), + middleware(next_handler, _get_message(2, media_group_id="42"), {}), ) assert album is not None assert len(album) == 2 assert counter == 1 + async def test_bot_object_saved(self, bot): + middleware = self.get_middleware() + event = album = None + + async def next_handler(message: Message, data: dict[str, Any]): + nonlocal event, album + event = message + album = data.get("album") + + await middleware(next_handler, _get_message(1, media_group_id="42"), {"bot": bot}) + assert event.bot is bot + assert all(msg.bot is bot for msg in album) + async def test_propagate_first_media_in_album(self): middleware = self.get_middleware() first_message = None @@ -51,13 +88,29 @@ class TestMediaGroupAggregatorMiddleware: nonlocal first_message first_message = message - await asyncio.gather( - middleware(next_handler, self._get_message(2, media_group_id="42"), {}), - middleware(next_handler, self._get_message(1, media_group_id="42"), {}), + task1 = await wait_until_func_call_sleep( + asyncio.create_task, middleware(next_handler, _get_message(2, media_group_id="42"), {}) ) + await middleware(next_handler, _get_message(1, media_group_id="42"), {}) + await task1 assert isinstance(first_message, Message) assert first_message.message_id == 1 + async def test_skip_propagating_if_data_deleted(self): + middleware = self.get_middleware() + counter = 0 + + async def next_handler(*args, **kwargs): + nonlocal counter + counter += 1 + + task1 = await wait_until_func_call_sleep( + asyncio.create_task, middleware(next_handler, _get_message(1, media_group_id="42"), {}) + ) + await middleware.media_group_aggregator.delete_group("42") + await task1 + assert counter == 0 + async def test_different_albums_non_interfere(self): middleware = self.get_middleware() counter = 0 @@ -69,8 +122,8 @@ class TestMediaGroupAggregatorMiddleware: albums.append(data.get("album")) await asyncio.gather( - middleware(next_handler, self._get_message(1, media_group_id="1"), {}), - middleware(next_handler, self._get_message(2, media_group_id="2"), {}), + middleware(next_handler, _get_message(1, media_group_id="1"), {}), + middleware(next_handler, _get_message(2, media_group_id="2"), {}), ) assert counter == 2 assert len(albums) == 2 @@ -80,18 +133,76 @@ class TestMediaGroupAggregatorMiddleware: album = None async def failed_handler(*args, **kwargs): - raise Exception("Failed") + raise RuntimeError("Failed") async def working_handler(_, data: dict[str, Any]): nonlocal album album = data.get("album") - first_message = self._get_message(1, media_group_id="42") - second_message = self._get_message(2, media_group_id="42") - with pytest.raises(Exception): + first_message = _get_message(1, media_group_id="42") + second_message = _get_message(2, media_group_id="42") + with pytest.raises(RuntimeError): await asyncio.gather( middleware(failed_handler, first_message, {}), middleware(failed_handler, second_message, {}), ) await middleware(working_handler, first_message, {}) assert len(album) == 2 + + +def test_message_deduplication(): + message_1, message_2 = _get_message(1), _get_message(2) + res = [message_1, message_2] + assert BaseMediaGroupAggregator.deduplicate_messages([message_1, message_2]) == res + assert BaseMediaGroupAggregator.deduplicate_messages([message_1, message_2, message_2]) == res + assert BaseMediaGroupAggregator.deduplicate_messages([message_1, message_2, message_1]) == res + + +@pytest.fixture(params=["memory", "redis"], scope="function") +async def aggregator(request): + if request.param == "memory": + yield MemoryMediaGroupAggregator() + else: + redis = Redis.from_url(request.getfixturevalue("redis_server")) + yield RedisMediaGroupAggregator(redis) + keys = await redis.keys("media_group:*") + if keys: + await redis.delete(*keys) + await redis.aclose() + + +class TestMediaGroupAggregator: + async def test_group_creating(self, aggregator: BaseMediaGroupAggregator): + msg1 = _get_message(1) + msg2 = _get_message(2) + assert await aggregator.add_into_group("42", msg1) == 1 + assert await aggregator.add_into_group("42", msg2) == 2 + assert {msg.message_id for msg in await aggregator.get_group("42")} == { + msg1.message_id, + msg2.message_id, + } + await aggregator.delete_group("42") + assert await aggregator.get_group("42") == [] + + async def test_acquire_lock(self, aggregator: BaseMediaGroupAggregator): + for _ in range(2): + assert await aggregator.acquire_lock("42") + assert not await aggregator.acquire_lock("42") + await aggregator.release_lock("42") + + async def test_expired_objects_removed(self): + aggregator = MemoryMediaGroupAggregator() + await aggregator.add_into_group("42", _get_message(1)) + with mock.patch("time.time", return_value=time.time() + aggregator.ttl_sec + 1): + new_msg = _get_message(2) + await aggregator.add_into_group("24", new_msg) + assert await aggregator.get_group("42") == [] + assert await aggregator.get_group("24") == [new_msg] + + async def test_last_message_time(self, aggregator: BaseMediaGroupAggregator): + assert await aggregator.get_last_message_time("42") is None + await aggregator.add_into_group("42", _get_message(1)) + time_before_second_message = time.time() + assert await aggregator.get_last_message_time("42") <= time_before_second_message + await aggregator.add_into_group("42", _get_message(2)) + assert await aggregator.get_last_message_time("42") >= time_before_second_message From 10fa7315dafea3c39ae2b2585c9a05dd2b1b14ab Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sun, 1 Mar 2026 11:21:44 +0300 Subject: [PATCH 09/12] update lock TTL --- aiogram/dispatcher/middlewares/media_group.py | 37 +++++++++++++++---- aiogram/filters/media_group.py | 6 ++- docs/utils/media_group.rst | 12 +++++- .../test_middlewares/test_media_group.py | 18 ++++++++- tests/test_filters/test_media_group.py | 2 + 5 files changed, 62 insertions(+), 13 deletions(-) diff --git a/aiogram/dispatcher/middlewares/media_group.py b/aiogram/dispatcher/middlewares/media_group.py index 5efcd0f8..83158f4a 100644 --- a/aiogram/dispatcher/middlewares/media_group.py +++ b/aiogram/dispatcher/middlewares/media_group.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from redis.asyncio.client import Redis DELAY_SEC = 1.0 -TIMEOUT_SEC = 10 +LOCK_TTL_SEC = 30 TTL_SEC = 600 @@ -53,13 +53,29 @@ class BaseMediaGroupAggregator(ABC): message_ids.add(message.message_id) return result + async def get_current_time(self) -> float: + return time.time() + class RedisMediaGroupAggregator(BaseMediaGroupAggregator): + """ + Aggregates media groups in Redis. + """ + redis: "Redis" - def __init__(self, redis: "Redis", ttl_sec: int = TTL_SEC) -> None: + def __init__( + self, redis: "Redis", ttl_sec: int = TTL_SEC, lock_ttl_sec: int = LOCK_TTL_SEC + ) -> None: + """ + :param ttl_sec: ttl for media group data in seconds + :param lock_ttl_sec: ttl for lock in seconds. Value should be too big to prevent lock + releasing until handler finished and too small to expire until telegram send retry if + handler failed. + """ self.redis = redis self.ttl_sec = ttl_sec + self.lock_ttl_sec = lock_ttl_sec @staticmethod def get_group_key(media_group_id: str) -> str: @@ -74,8 +90,9 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): return f"media_group:{media_group_id}:lock" async def add_into_group(self, media_group_id: str, media: Message) -> int: + current_time = await self.get_current_time() async with self.redis.pipeline(transaction=True) as pipe: - pipe.set(self.get_last_message_time_key(media_group_id), time.time(), ex=self.ttl_sec) + pipe.set(self.get_last_message_time_key(media_group_id), current_time, ex=self.ttl_sec) pipe.rpush(self.get_group_key(media_group_id), media.model_dump_json()) pipe.expire(self.get_group_key(media_group_id), self.ttl_sec) res = await pipe.execute() @@ -85,7 +102,7 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): return cast( bool, await self.redis.set( - self.get_group_lock_key(media_group_id), "1", nx=True, ex=TIMEOUT_SEC + self.get_group_lock_key(media_group_id), "1", nx=True, ex=self.lock_ttl_sec ), ) @@ -96,7 +113,7 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): result = await cast( Awaitable[list[str]], self.redis.lrange(self.get_group_key(media_group_id), 0, -1) ) - return self.deduplicate_messages([Message.model_validate_json(msg) for msg in result]) + return self.deduplicate_messages([Message.model_validate_json(msg) for msg in set(result)]) async def delete_group(self, media_group_id: str) -> None: async with self.redis.pipeline(transaction=True) as pipe: @@ -110,6 +127,10 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): return None return float(result) + async def get_current_time(self) -> float: + seconds, microseconds = cast(tuple[int, int], await self.redis.time()) + return seconds + microseconds / 1e6 + class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): def __init__(self, ttl_sec: int = TTL_SEC) -> None: @@ -184,9 +205,11 @@ class MediaGroupAggregatorMiddleware(BaseMiddleware): if not await self.media_group_aggregator.acquire_lock(event.media_group_id): return None try: - last_message_time = time.time() + last_message_time = await self.media_group_aggregator.get_current_time() while True: - delta = self.delay - (time.time() - last_message_time) + delta = self.delay - ( + await self.media_group_aggregator.get_current_time() - last_message_time + ) if delta <= 0: album = await self.media_group_aggregator.get_group(event.media_group_id) if not album: diff --git a/aiogram/filters/media_group.py b/aiogram/filters/media_group.py index a685bb23..9e9b7d3f 100644 --- a/aiogram/filters/media_group.py +++ b/aiogram/filters/media_group.py @@ -29,8 +29,10 @@ class MediaGroupFilter(Filter): :param max_media_count: max count of media in the group, inclusively """ if count is None: - min_media_count = min_media_count or MIN_MEDIA_COUNT - max_media_count = max_media_count or DEFAULT_MAX_MEDIA_COUNT + if min_media_count is None: + min_media_count = MIN_MEDIA_COUNT + if max_media_count is None: + max_media_count = max(DEFAULT_MAX_MEDIA_COUNT, min_media_count) else: if min_media_count is not None or max_media_count is not None: raise ValueError( diff --git a/docs/utils/media_group.rst b/docs/utils/media_group.rst index dbf33a49..51f018d0 100644 --- a/docs/utils/media_group.rst +++ b/docs/utils/media_group.rst @@ -51,7 +51,10 @@ By default each media in the group is processed separately. You can use :class:`aiogram.dispatcher.middlewares.media_group.MediaGroupAggregatorMiddleware` to process media groups as one. If you do, only one message from the group will be processed, and updates for -other messages with the same media group ID will be suppressed. +other messages with the same media group ID will be suppressed. There are two options to store media groups: + +- :class:`aiogram.dispatcher.middlewares.media_group.MemoryMediaGroupAggregator` - simple in-memory storage, used by default +- :class:`aiogram.dispatcher.middlewares.media_group.RedisMediaGroupAggregator` - support distributed environment You also can use :class:`aiogram.filters.media_group.MediaGroupFilter` to filter media groups. @@ -72,7 +75,7 @@ Usage # use middleware @router.message( - MediaGroupFilter(max_count=5), + MediaGroupFilter(max_media_count=5), F.caption == "album_caption" # other filters will be applied to the first message in the group ) async def start(message: Message, album: list[Message]): @@ -93,3 +96,8 @@ References :members: .. autoclass:: aiogram.filters.media_group.MediaGroupFilter :members: +.. autoclass:: aiogram.dispatcher.middlewares.media_group.MemoryMediaGroupAggregator + :members: +.. autoclass:: aiogram.dispatcher.middlewares.media_group.RedisMediaGroupAggregator + :members: + :special-members: __init__ diff --git a/tests/test_dispatcher/test_middlewares/test_media_group.py b/tests/test_dispatcher/test_middlewares/test_media_group.py index 91b4bdba..b3f83978 100644 --- a/tests/test_dispatcher/test_middlewares/test_media_group.py +++ b/tests/test_dispatcher/test_middlewares/test_media_group.py @@ -96,7 +96,8 @@ class TestMediaGroupAggregatorMiddleware: assert isinstance(first_message, Message) assert first_message.message_id == 1 - async def test_skip_propagating_if_data_deleted(self): + @pytest.mark.parametrize("deleted_object", ["album", "last_message_time"]) + async def test_skip_propagating_if_data_deleted(self, deleted_object): middleware = self.get_middleware() counter = 0 @@ -107,7 +108,10 @@ class TestMediaGroupAggregatorMiddleware: task1 = await wait_until_func_call_sleep( asyncio.create_task, middleware(next_handler, _get_message(1, media_group_id="42"), {}) ) - await middleware.media_group_aggregator.delete_group("42") + if deleted_object == "album": + middleware.media_group_aggregator.groups.pop("42") + else: + middleware.media_group_aggregator.last_message_timers.pop("42") await task1 assert counter == 0 @@ -199,6 +203,16 @@ class TestMediaGroupAggregator: assert await aggregator.get_group("42") == [] assert await aggregator.get_group("24") == [new_msg] + async def test_get_current_time_memory_aggregator(self): + aggregator = MemoryMediaGroupAggregator() + with mock.patch("time.time", return_value=1.1): + assert await aggregator.get_current_time() == 1.1 + + async def test_get_current_time_redis_aggregator(self): + aggregator = RedisMediaGroupAggregator(mock.Mock(spec=Redis)) + aggregator.redis.time = mock.AsyncMock(return_value=(1, 123456)) + assert await aggregator.get_current_time() == 1.123456 + async def test_last_message_time(self, aggregator: BaseMediaGroupAggregator): assert await aggregator.get_last_message_time("42") is None await aggregator.add_into_group("42", _get_message(1)) diff --git a/tests/test_filters/test_media_group.py b/tests/test_filters/test_media_group.py index 3d1af49b..a436d062 100644 --- a/tests/test_filters/test_media_group.py +++ b/tests/test_filters/test_media_group.py @@ -12,6 +12,8 @@ class TestMediaGroupFilter: [ ((), MIN_MEDIA_COUNT, DEFAULT_MAX_MEDIA_COUNT), ((3,), 3, 3), + ((11,), 11, 11), + ((None, 11, None), 11, 11), ((None, 3), 3, DEFAULT_MAX_MEDIA_COUNT), ((None, None, 3), MIN_MEDIA_COUNT, 3), ], From 80b4abd3b5c2262cab32fdd987c8efcdced3a9c0 Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sun, 1 Mar 2026 15:25:59 +0300 Subject: [PATCH 10/12] fix lock releasing --- aiogram/dispatcher/middlewares/media_group.py | 59 ++++++++++++------- .../test_middlewares/test_media_group.py | 17 +++--- 2 files changed, 47 insertions(+), 29 deletions(-) diff --git a/aiogram/dispatcher/middlewares/media_group.py b/aiogram/dispatcher/middlewares/media_group.py index 83158f4a..e53213ca 100644 --- a/aiogram/dispatcher/middlewares/media_group.py +++ b/aiogram/dispatcher/middlewares/media_group.py @@ -1,5 +1,6 @@ import asyncio import time +import uuid from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Awaitable, Callable @@ -20,27 +21,27 @@ TTL_SEC = 600 class BaseMediaGroupAggregator(ABC): @abstractmethod async def add_into_group(self, media_group_id: str, media: Message) -> int: - raise NotImplementedError + pass @abstractmethod - async def acquire_lock(self, media_group_id: str) -> bool: - raise NotImplementedError + async def acquire_lock(self, media_group_id: str, lock_id: str) -> bool: + pass @abstractmethod - async def release_lock(self, media_group_id: str) -> None: - raise NotImplementedError + async def release_lock(self, media_group_id: str, lock_id: str) -> None: + pass @abstractmethod async def get_group(self, media_group_id: str) -> list[Message]: - raise NotImplementedError + pass @abstractmethod async def delete_group(self, media_group_id: str) -> None: - raise NotImplementedError + pass @abstractmethod async def get_last_message_time(self, media_group_id: str) -> float | None: - raise NotImplementedError + pass @staticmethod def deduplicate_messages(messages: list[Message]) -> list[Message]: @@ -53,8 +54,9 @@ class BaseMediaGroupAggregator(ABC): message_ids.add(message.message_id) return result + @abstractmethod async def get_current_time(self) -> float: - return time.time() + pass class RedisMediaGroupAggregator(BaseMediaGroupAggregator): @@ -98,16 +100,24 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): res = await pipe.execute() return cast(int, res[1]) - async def acquire_lock(self, media_group_id: str) -> bool: + async def acquire_lock(self, media_group_id: str, lock_id: str) -> bool: return cast( bool, await self.redis.set( - self.get_group_lock_key(media_group_id), "1", nx=True, ex=self.lock_ttl_sec + self.get_group_lock_key(media_group_id), lock_id, nx=True, ex=self.lock_ttl_sec ), ) - async def release_lock(self, media_group_id: str) -> None: - await self.redis.delete(self.get_group_lock_key(media_group_id)) + async def release_lock(self, media_group_id: str, lock_id: str) -> None: + release_script = ( + 'if redis.call("get", KEYS[1]) == ARGV[1] then ' + 'return redis.call("del", KEYS[1]) ' + "else return 0 end" + ) + await cast( + Awaitable[str], + self.redis.eval(release_script, 1, self.get_group_lock_key(media_group_id), lock_id), + ) async def get_group(self, media_group_id: str) -> list[Message]: result = await cast( @@ -136,12 +146,12 @@ class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): def __init__(self, ttl_sec: int = TTL_SEC) -> None: self.groups: dict[str, list[Message]] = defaultdict(list) self.last_message_timers: dict[str, float] = {} - self.locks: dict[str, bool] = {} + self.locks: dict[str, str] = {} self.ttl_sec = ttl_sec def remove_expired_objects(self) -> None: expired_group_ids = [] - current_time = time.time() + current_time = time.monotonic() for group_id, last_message_time in self.last_message_timers.items(): if current_time - last_message_time > self.ttl_sec: expired_group_ids.append(group_id) @@ -158,17 +168,18 @@ class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): if media.message_id not in (msg.message_id for msg in self.groups[media_group_id]): self.groups[media_group_id].append(media) self.last_message_timers.pop(media_group_id, None) - self.last_message_timers[media_group_id] = time.time() + self.last_message_timers[media_group_id] = time.monotonic() return len(self.groups[media_group_id]) - async def acquire_lock(self, media_group_id: str) -> bool: + async def acquire_lock(self, media_group_id: str, lock_id: str) -> bool: if self.locks.get(media_group_id): return False - self.locks[media_group_id] = True + self.locks[media_group_id] = lock_id return True - async def release_lock(self, media_group_id: str) -> None: - self.locks.pop(media_group_id, None) + async def release_lock(self, media_group_id: str, lock_id: str) -> None: + if self.locks.get(media_group_id) == lock_id: + self.locks.pop(media_group_id) async def get_group(self, media_group_id: str) -> list[Message]: return self.groups.get(media_group_id, []) @@ -180,6 +191,9 @@ class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): async def get_last_message_time(self, media_group_id: str) -> float | None: return self.last_message_timers.get(media_group_id) + async def get_current_time(self) -> float: + return time.monotonic() + class MediaGroupAggregatorMiddleware(BaseMiddleware): def __init__( @@ -202,7 +216,8 @@ class MediaGroupAggregatorMiddleware(BaseMiddleware): if not isinstance(event, Message) or not event.media_group_id: return await handler(event, data) await self.media_group_aggregator.add_into_group(event.media_group_id, event) - if not await self.media_group_aggregator.acquire_lock(event.media_group_id): + lock_id = str(uuid.uuid4()) + if not await self.media_group_aggregator.acquire_lock(event.media_group_id, lock_id): return None try: last_message_time = await self.media_group_aggregator.get_current_time() @@ -230,4 +245,4 @@ class MediaGroupAggregatorMiddleware(BaseMiddleware): return None last_message_time = new_last_message_time finally: - await self.media_group_aggregator.release_lock(event.media_group_id) + await self.media_group_aggregator.release_lock(event.media_group_id, lock_id) diff --git a/tests/test_dispatcher/test_middlewares/test_media_group.py b/tests/test_dispatcher/test_middlewares/test_media_group.py index b3f83978..a5c65d9e 100644 --- a/tests/test_dispatcher/test_middlewares/test_media_group.py +++ b/tests/test_dispatcher/test_middlewares/test_media_group.py @@ -189,15 +189,18 @@ class TestMediaGroupAggregator: assert await aggregator.get_group("42") == [] async def test_acquire_lock(self, aggregator: BaseMediaGroupAggregator): - for _ in range(2): - assert await aggregator.acquire_lock("42") - assert not await aggregator.acquire_lock("42") - await aggregator.release_lock("42") + await aggregator.acquire_lock("42", "key1") + assert not await aggregator.acquire_lock("42", "key2") + await aggregator.release_lock("42", "key1") + for i in ("key2", "key3"): + assert await aggregator.acquire_lock("42", i) + assert not await aggregator.acquire_lock("42", i) + await aggregator.release_lock("42", i) async def test_expired_objects_removed(self): aggregator = MemoryMediaGroupAggregator() await aggregator.add_into_group("42", _get_message(1)) - with mock.patch("time.time", return_value=time.time() + aggregator.ttl_sec + 1): + with mock.patch("time.monotonic", return_value=time.time() + aggregator.ttl_sec + 1): new_msg = _get_message(2) await aggregator.add_into_group("24", new_msg) assert await aggregator.get_group("42") == [] @@ -205,7 +208,7 @@ class TestMediaGroupAggregator: async def test_get_current_time_memory_aggregator(self): aggregator = MemoryMediaGroupAggregator() - with mock.patch("time.time", return_value=1.1): + with mock.patch("time.monotonic", return_value=1.1): assert await aggregator.get_current_time() == 1.1 async def test_get_current_time_redis_aggregator(self): @@ -216,7 +219,7 @@ class TestMediaGroupAggregator: async def test_last_message_time(self, aggregator: BaseMediaGroupAggregator): assert await aggregator.get_last_message_time("42") is None await aggregator.add_into_group("42", _get_message(1)) - time_before_second_message = time.time() + time_before_second_message = await aggregator.get_current_time() assert await aggregator.get_last_message_time("42") <= time_before_second_message await aggregator.add_into_group("42", _get_message(2)) assert await aggregator.get_last_message_time("42") >= time_before_second_message From 24fdd285fde7da638610e157c1378742990c8e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9?= <128831423+Vitaly312@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:16:47 +0300 Subject: [PATCH 11/12] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- aiogram/dispatcher/middlewares/media_group.py | 24 +++++++++---------- aiogram/filters/__init__.py | 2 ++ docs/utils/media_group.rst | 4 ++-- .../test_middlewares/test_media_group.py | 10 ++++---- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/aiogram/dispatcher/middlewares/media_group.py b/aiogram/dispatcher/middlewares/media_group.py index e53213ca..63765949 100644 --- a/aiogram/dispatcher/middlewares/media_group.py +++ b/aiogram/dispatcher/middlewares/media_group.py @@ -71,9 +71,9 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): ) -> None: """ :param ttl_sec: ttl for media group data in seconds - :param lock_ttl_sec: ttl for lock in seconds. Value should be too big to prevent lock - releasing until handler finished and too small to expire until telegram send retry if - handler failed. + :param lock_ttl_sec: ttl for lock in seconds. Value should be large enough to prevent the + lock from expiring before the handler finishes, but small enough to expire before + Telegram retries a failed delivery. """ self.redis = redis self.ttl_sec = ttl_sec @@ -115,7 +115,7 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): "else return 0 end" ) await cast( - Awaitable[str], + Awaitable[int], self.redis.eval(release_script, 1, self.get_group_lock_key(media_group_id), lock_id), ) @@ -123,7 +123,7 @@ class RedisMediaGroupAggregator(BaseMediaGroupAggregator): result = await cast( Awaitable[list[str]], self.redis.lrange(self.get_group_key(media_group_id), 0, -1) ) - return self.deduplicate_messages([Message.model_validate_json(msg) for msg in set(result)]) + return self.deduplicate_messages([Message.model_validate_json(msg) for msg in result]) async def delete_group(self, media_group_id: str) -> None: async with self.redis.pipeline(transaction=True) as pipe: @@ -172,7 +172,7 @@ class MemoryMediaGroupAggregator(BaseMediaGroupAggregator): return len(self.groups[media_group_id]) async def acquire_lock(self, media_group_id: str, lock_id: str) -> bool: - if self.locks.get(media_group_id): + if self.locks.get(media_group_id) is not None: return False self.locks[media_group_id] = lock_id return True @@ -220,8 +220,12 @@ class MediaGroupAggregatorMiddleware(BaseMiddleware): if not await self.media_group_aggregator.acquire_lock(event.media_group_id, lock_id): return None try: - last_message_time = await self.media_group_aggregator.get_current_time() while True: + last_message_time = await self.media_group_aggregator.get_last_message_time( + event.media_group_id + ) + if not last_message_time: + return None delta = self.delay - ( await self.media_group_aggregator.get_current_time() - last_message_time ) @@ -238,11 +242,5 @@ class MediaGroupAggregatorMiddleware(BaseMiddleware): await self.media_group_aggregator.delete_group(event.media_group_id) return result await asyncio.sleep(delta) - new_last_message_time = await self.media_group_aggregator.get_last_message_time( - event.media_group_id - ) - if not new_last_message_time: - return None - last_message_time = new_last_message_time finally: await self.media_group_aggregator.release_lock(event.media_group_id, lock_id) diff --git a/aiogram/filters/__init__.py b/aiogram/filters/__init__.py index e2668830..42f1f740 100644 --- a/aiogram/filters/__init__.py +++ b/aiogram/filters/__init__.py @@ -18,6 +18,7 @@ from .command import Command, CommandObject, CommandStart from .exception import ExceptionMessageFilter, ExceptionTypeFilter from .logic import and_f, invert_f, or_f from .magic_data import MagicData +from .media_group import MediaGroupFilter from .state import StateFilter BaseFilter = Filter @@ -44,6 +45,7 @@ __all__ = ( "ExceptionTypeFilter", "Filter", "MagicData", + "MediaGroupFilter", "StateFilter", "and_f", "invert_f", diff --git a/docs/utils/media_group.rst b/docs/utils/media_group.rst index 51f018d0..97c6ab31 100644 --- a/docs/utils/media_group.rst +++ b/docs/utils/media_group.rst @@ -60,7 +60,7 @@ You also can use :class:`aiogram.filters.media_group.MediaGroupFilter` to filter media groups. Usage -===== +----- .. code-block:: python @@ -69,7 +69,7 @@ Usage # register middleware from aiogram.dispatcher.middlewares.media_group import MediaGroupAggregatorMiddleware - from aiogram.filters.media_group import MediaGroupFilter + from aiogram.filters import MediaGroupFilter router.message.outer_middleware(MediaGroupAggregatorMiddleware()) diff --git a/tests/test_dispatcher/test_middlewares/test_media_group.py b/tests/test_dispatcher/test_middlewares/test_media_group.py index a5c65d9e..ef2fd33f 100644 --- a/tests/test_dispatcher/test_middlewares/test_media_group.py +++ b/tests/test_dispatcher/test_middlewares/test_media_group.py @@ -189,14 +189,16 @@ class TestMediaGroupAggregator: assert await aggregator.get_group("42") == [] async def test_acquire_lock(self, aggregator: BaseMediaGroupAggregator): - await aggregator.acquire_lock("42", "key1") - assert not await aggregator.acquire_lock("42", "key2") - await aggregator.release_lock("42", "key1") - for i in ("key2", "key3"): + for i in ("key1", "key2"): assert await aggregator.acquire_lock("42", i) assert not await aggregator.acquire_lock("42", i) await aggregator.release_lock("42", i) + async def test_lock_not_acquired_with_wrong_key(self, aggregator: BaseMediaGroupAggregator): + await aggregator.acquire_lock("42", "key1") + await aggregator.release_lock("42", "key2") + assert not await aggregator.acquire_lock("42", "key1") + async def test_expired_objects_removed(self): aggregator = MemoryMediaGroupAggregator() await aggregator.add_into_group("42", _get_message(1)) From 28626d124ad1a23e0bcb7af5c1a47423ef86024e Mon Sep 17 00:00:00 2001 From: Vitaly312 Date: Sun, 5 Apr 2026 18:15:59 +0300 Subject: [PATCH 12/12] refactor: remove MediaGroupFilter and fix rst heading --- aiogram/filters/__init__.py | 2 - aiogram/filters/media_group.py | 64 ---------------------- docs/utils/media_group.rst | 14 +++-- tests/test_filters/test_media_group.py | 73 -------------------------- 4 files changed, 6 insertions(+), 147 deletions(-) delete mode 100644 aiogram/filters/media_group.py delete mode 100644 tests/test_filters/test_media_group.py diff --git a/aiogram/filters/__init__.py b/aiogram/filters/__init__.py index 42f1f740..e2668830 100644 --- a/aiogram/filters/__init__.py +++ b/aiogram/filters/__init__.py @@ -18,7 +18,6 @@ from .command import Command, CommandObject, CommandStart from .exception import ExceptionMessageFilter, ExceptionTypeFilter from .logic import and_f, invert_f, or_f from .magic_data import MagicData -from .media_group import MediaGroupFilter from .state import StateFilter BaseFilter = Filter @@ -45,7 +44,6 @@ __all__ = ( "ExceptionTypeFilter", "Filter", "MagicData", - "MediaGroupFilter", "StateFilter", "and_f", "invert_f", diff --git a/aiogram/filters/media_group.py b/aiogram/filters/media_group.py deleted file mode 100644 index 9e9b7d3f..00000000 --- a/aiogram/filters/media_group.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Any, Literal - -from aiogram.filters.base import Filter -from aiogram.types import Message - -MIN_MEDIA_COUNT = 2 -DEFAULT_MAX_MEDIA_COUNT = 10 - - -class MediaGroupFilter(Filter): - """ - This filter helps to handle media groups. - - Works only with :class:`aiogram.types.message.Message` events which have the :code:`album` - in the handler context. - """ - - __slots__ = ("min_media_count", "max_media_count") - - def __init__( - self, - count: int | None = None, - min_media_count: int | None = None, - max_media_count: int | None = None, - ): - """ - :param count: expected count of media in the group. - :param min_media_count: min count of media in the group, inclusively - :param max_media_count: max count of media in the group, inclusively - """ - if count is None: - if min_media_count is None: - min_media_count = MIN_MEDIA_COUNT - if max_media_count is None: - max_media_count = max(DEFAULT_MAX_MEDIA_COUNT, min_media_count) - else: - if min_media_count is not None or max_media_count is not None: - raise ValueError( - "count and min_media_count or max_media_count can not be used together" - ) - if count < MIN_MEDIA_COUNT: - raise ValueError(f"count should be greater or equal to {MIN_MEDIA_COUNT}") - min_media_count = max_media_count = count - if min_media_count < MIN_MEDIA_COUNT: - raise ValueError(f"min_media_count should be greater or equal to {MIN_MEDIA_COUNT}") - if max_media_count < min_media_count: - raise ValueError("max_media_count should be greater or equal to min_media_count") - self.min_media_count = min_media_count - self.max_media_count = max_media_count - - def __str__(self) -> str: - if self.min_media_count == self.max_media_count: - return self._signature_to_string(count=self.min_media_count) - return self._signature_to_string( - min_media_count=self.min_media_count, max_media_count=self.max_media_count - ) - - async def __call__( - self, message: Message, album: list[Message] | None = None - ) -> Literal[False] | dict[str, Any]: - media_count = len(album or []) - if not (self.min_media_count <= media_count <= self.max_media_count): - return False - return {"media_count": media_count} diff --git a/docs/utils/media_group.rst b/docs/utils/media_group.rst index 97c6ab31..cab5962b 100644 --- a/docs/utils/media_group.rst +++ b/docs/utils/media_group.rst @@ -1,6 +1,6 @@ -=================== +=========== Media group -=================== +=========== This module provides tools for media groups. @@ -45,7 +45,7 @@ it will be used as ``caption`` for first media in group. Handling media groups -====================== +===================== By default each media in the group is processed separately. @@ -56,7 +56,7 @@ other messages with the same media group ID will be suppressed. There are two op - :class:`aiogram.dispatcher.middlewares.media_group.MemoryMediaGroupAggregator` - simple in-memory storage, used by default - :class:`aiogram.dispatcher.middlewares.media_group.RedisMediaGroupAggregator` - support distributed environment -You also can use :class:`aiogram.filters.media_group.MediaGroupFilter` +You also can use :class:`aiogram.filters.magic_data.MagicData` with ``F.album`` to filter media groups. Usage @@ -69,13 +69,13 @@ Usage # register middleware from aiogram.dispatcher.middlewares.media_group import MediaGroupAggregatorMiddleware - from aiogram.filters import MediaGroupFilter + from aiogram.filters import MagicData router.message.outer_middleware(MediaGroupAggregatorMiddleware()) # use middleware @router.message( - MediaGroupFilter(max_media_count=5), + MagicData(F.album.len() <= 5), F.caption == "album_caption" # other filters will be applied to the first message in the group ) async def start(message: Message, album: list[Message]): @@ -94,8 +94,6 @@ References :members: .. autoclass:: aiogram.dispatcher.middlewares.media_group.MediaGroupAggregatorMiddleware :members: -.. autoclass:: aiogram.filters.media_group.MediaGroupFilter - :members: .. autoclass:: aiogram.dispatcher.middlewares.media_group.MemoryMediaGroupAggregator :members: .. autoclass:: aiogram.dispatcher.middlewares.media_group.RedisMediaGroupAggregator diff --git a/tests/test_filters/test_media_group.py b/tests/test_filters/test_media_group.py deleted file mode 100644 index a436d062..00000000 --- a/tests/test_filters/test_media_group.py +++ /dev/null @@ -1,73 +0,0 @@ -import datetime - -import pytest - -from aiogram.filters.media_group import DEFAULT_MAX_MEDIA_COUNT, MIN_MEDIA_COUNT, MediaGroupFilter -from aiogram.types import Chat, Message - - -class TestMediaGroupFilter: - @pytest.mark.parametrize( - "args,min_count,max_count", - [ - ((), MIN_MEDIA_COUNT, DEFAULT_MAX_MEDIA_COUNT), - ((3,), 3, 3), - ((11,), 11, 11), - ((None, 11, None), 11, 11), - ((None, 3), 3, DEFAULT_MAX_MEDIA_COUNT), - ((None, None, 3), MIN_MEDIA_COUNT, 3), - ], - ) - def test_init_range(self, args, min_count, max_count): - filter = MediaGroupFilter(*args) - assert filter.max_media_count == max_count - assert filter.min_media_count == min_count - - @pytest.mark.parametrize( - "count,min_count,max_count", - [ - (1, None, 1), - (1, 1, None), - (None, 1, None), - (None, None, 1), - (1, None, None), - (None, 5, 3), - ], - ) - def test_raise_error(self, count, min_count, max_count): - with pytest.raises(ValueError): - MediaGroupFilter(count, min_count, max_count) - - @pytest.mark.parametrize( - "min_count,max_count,media_count,result", - [ - [2, 2, 1, False], - [2, 2, 2, True], - [2, 2, 3, False], - [2, 5, 2, True], - [2, 5, 5, True], - [2, 5, 6, False], - ], - ) - async def test_call(self, min_count, max_count, media_count, result): - filter = MediaGroupFilter(min_media_count=min_count, max_media_count=max_count) - album = [ - Message( - message_id=i, - date=datetime.datetime.now(), - chat=Chat(id=42, type="private"), - ) - for i in range(media_count) - ] - response = await filter(album[0], album) - assert bool(response) is result - if result: - assert response.get("media_count") == media_count - - def test_str_count(self): - filter = MediaGroupFilter(5) - assert str(filter) == "MediaGroupFilter(count=5)" - - def test_str_range(self): - filter = MediaGroupFilter(min_media_count=2, max_media_count=5) - assert str(filter) == "MediaGroupFilter(min_media_count=2, max_media_count=5)"