Хочу рассказать про библиотеку aiogram_tool, которая добавляет в написание ботов дополнительные возможности. Если точнее, то речь пойдёт про Dependency Injection, RateLimit и LongCallbackData.
Сначала хочу немного рассказать, зачем она вообще была написана. Во время разработки ботов многие сталкиваются с тем, что одну и ту же логику приходится реализовывать каждый раз, в каждом новом боте. Именно эту проблему и решает aiogram_tool: он объединяет полезные инструменты, которые делают разработку быстрее. Теперь подробнее про сами инструменты.
Регистрация инструментов
Первое, с чего я начну, — регистрация инструментов. Если их много, должна быть единая точка входа, и она есть: функция aiogram_tool_setup.
def aiogram_tool_setup( dispatcher: Dispatcher, tools: Iterable[BaseTool], ) -> None: ...
Аргументы:
dispatcher: Dispatcher— обязательный, экземпляр диспетчера aiogram;tools: Iterable[BaseTool]— обязательный, список инструментов.
Главная функция регистрации инструментов. Для DI передайте экземпляр класса DependTool в списке tools, для RateLimit — RateLimitTool.
Все инструменты наследуются от абстрактного класса BaseTool, поэтому при необходимости можно написать свой инструмент, реализовав метод setup.
import asyncio from aiogram import Bot, Dispatcher from aiogram_tool.tools.depend import DependTool from aiogram_tool.tools.limit import RateLimitTool from aiogram_tool.tools.setup import aiogram_tool_setup bot = Bot("YOUR_TOKEN_HERE") dp = Dispatcher() async def main(): aiogram_tool_setup(dp, [DependTool(), RateLimitTool()]) await dp.start_polling(bot) if __name__ == "__main__": asyncio.run(main())
Dependency Injection
Первый инструмент, с которого я начну, — Dependency Injection. При написании я вдохновлялся Depends из FastAPI. Зацикливаться на простых примерах не хочу, их можно увидеть в документации. Я хочу показать основные особенности моей реализации.
Объявление зависимости
Зависимость можно объявить двумя способами: через значение по умолчанию или через typing.Annotated.
from typing import Annotated from aiogram.filters import CommandStart from aiogram.types import Message from aiogram_tool.tools.depend import Depends async def get_user_name(context: Message) -> str: return context.from_user.full_name # Через значение по умолчанию @dp.message(CommandStart()) async def start_handler(message: Message, name: str = Depends(get_user_name)): await message.answer(f"Привет, {name}!") # Через Annotated @dp.message(CommandStart()) async def start_handler_annotated( message: Message, name: Annotated[str, Depends(get_user_name)], ): await message.answer(f"Привет, {name}!")
Аргументы зависимости без значения по умолчанию берутся из middleware_data, то есть из всех данных, которые aiogram передаёт в обработчик. Например, context здесь — текущее событие.
Поддерживаются async- и sync-функции, class-functor’ы, классы (их __init__ тоже получает зависимости), async-генераторы и @asynccontextmanager. Вложенные зависимости тоже работают, а циклические цепочки обнаруживаются и приводят к DependRecursionError.
Регистрация scopes
Время жизни зависимости задаётся через Scope. Все виды scope:
Scope |
Описание |
|---|---|
|
По умолчанию. Зависимость вызывается каждый раз |
|
Результат кэшируется на время обработки одного апдейта |
|
Результат кэшируется на всё время работы приложения |
Задавать scope можно через ScopeRegistry:
import secrets from aiogram_tool.tools.depend import Scope, ScopeRegistry, DependTool from aiogram_tool.tools.setup import aiogram_tool_setup scope_registry = ScopeRegistry() @scope_registry(Scope.SINGLETON) async def get_app_config() -> str: return "APP_CONFIG_V1" @scope_registry(Scope.REQUEST) async def get_request_id() -> int: return secrets.randbits(20) aiogram_tool_setup(dp, [DependTool(scope_registry=scope_registry)])
Или напрямую в Depends:
@dp.message(CommandStart()) async def start_handler( message: Message, config: str = Depends(get_app_config, scope=Scope.SINGLETON), ): ...
Scope, переданный в Depends, имеет приоритет над scope, зарегистрированным в ScopeRegistry.
Самое главное — передать экземпляр ScopeRegistry в DependTool, иначе регистрации не будут учтены.
DependExit — отмена вызова обработчика
Если зависимость выбрасывает исключение DependExit, обработчик не вызывается:
from aiogram_tool.tools.depend import DependExit async def verify_user_access(context: Message) -> None: if context.from_user.id != 123456789: await context.answer("You are not admin!") raise DependExit() @dp.message(CommandStart()) async def start_handler( message: Message, _=Depends(verify_user_access), ): await message.answer("Welcome admin!")
DependFilter — вызов зависимости на уровне фильтра
Чтобы не создавать неиспользуемый аргумент через _, можно воспользоваться DependFilter. Он выполняет зависимость на этапе фильтров: если она выбрасывает DependExit, фильтр возвращает False, и обработчик не вызывается.
from aiogram_tool.tools.depend import DependFilter @dp.message( CommandStart(), DependFilter(Depends(verify_user_access)), ) async def start_handler(message: Message): await message.answer("Welcome admin!")
Dependency override
Поддерживается подмена зависимостей, это удобно для тестов и локальной разработки. Зависимости передаются в обработчик через значение по умолчанию или через Annotated. Подмена задаётся в DependTool:
async def get_external_data(): return "REAL_API_DATA" async def get_mocked_data(): return "MOCKED_DATA" depend_tool = DependTool( dependency_override={ get_external_data: Depends(get_mocked_data), } )
Подробнее о всех возможностях можно узнать в документации.
RateLimit — ограничение частоты запросов
Задача простая: ограничить, сколько раз пользователь может вызвать обработчик за единицу времени.
Инструмент построен на двух классах:
RateLimitToolрегистрируется один раз черезaiogram_tool_setupи хранит настройки по умолчанию для всех обработчиков:storage(где хранятся лимиты) иanswer_callback(что ответить при превышении);RateLimitFilterдобавляется в конкретный обработчик. В него тоже можно передатьstorageиanswer_callback, и тогда они будут использоваться только этим обработчиком.
При вызове фильтр ищет RateLimitTool в dispatcher.workflow_data, строит уникальный ключ, и под блокировкой хранилища проверяет лимит. Блокировка делает проверку атомарной даже при конкурентных апдейтах. Если лимит не исчерпан, обработчик вызывается. Если исчерпан, вызывается answer_callback, а обработчик не вызывается.
Быстрый старт:
from datetime import timedelta from aiogram.filters import Command from aiogram.types import Message from aiogram_tool.tools.limit import RateLimitFilter, RateLimitTool from aiogram_tool.tools.limit.rate_limit import FixedWindowRateLimit from aiogram_tool.tools.setup import aiogram_tool_setup @dp.message( # Лимит: 3 запроса за 10 секунд на пользователя Command("ping"), RateLimitFilter( rate_limit=FixedWindowRateLimit(requests=3, time=timedelta(seconds=10)) ), ) async def ping_handler(message: Message): await message.answer("Pong!") aiogram_tool_setup(dp, [RateLimitTool()])
Алгоритмы
В инструменте есть три алгоритма ограничения.
FixedWindowRateLimit — фиксированное окно. Окно открывается с первого запроса и закрывается через time. Внутри окна разрешено не более requests запросов, после чего все запросы отклоняются до конца окна.
RateLimitFilter(rate_limit=FixedWindowRateLimit(requests=5, time=timedelta(seconds=60)))
У этого алгоритма есть особенность: возможны всплески на границе окна. Пользователь может отправить requests запросов в конце одного окна и столько же в начале следующего.
SlidingWindowRateLimit — скользящее окно. Хранятся временные метки последних запросов, и лимит — не более requests запросов за любые time. Всплесков на границе окна нет, но и данных в хранилище больше.
RateLimitFilter( rate_limit=SlidingWindowRateLimit(requests=5, time=timedelta(seconds=60)) )
TokenBucketRateLimit — токеновое ведро. В ведре накапливаются токены (не больше bucket_size), каждый запрос расходует один токен, а пополнение идёт со скоростью refill_tokens каждые refill_time. Алгоритм разрешает кратковременные всплески за счёт накопленных токенов и при этом ограничивает среднюю частоту.
RateLimitFilter( rate_limit=TokenBucketRateLimit( bucket_size=5, # максимальное число токенов current_tokens=5, # начальное число токенов refill_time=timedelta(seconds=5), # интервал пополнения refill_tokens=1, # +1 токен каждые 5 секунд ) )
Алгоритм |
Аргументы |
Особенности |
|---|---|---|
|
|
Простой, минимум данных; возможны всплески на границе окна |
|
|
Точный, без всплесков; хранит список временных меток |
|
|
Всплески разрешены, средняя частота ограничена скоростью пополнения |
Персональные и глобальные лимиты
По умолчанию лимит действует на каждого пользователя отдельно: в ключ входит user_id. Если указать all_users=True, лимит станет общим для всех пользователей:
@dp.message( Command("start"), RateLimitFilter( rate_limit=SlidingWindowRateLimit(requests=10, time=timedelta(minutes=1)), all_users=True, # лимит общий для всех key="global_start", # свой ключ вместо имени обработчика ), ) async def start_handler(message: Message): await message.answer("10 запросов в минуту на всех")
Свой ответ при превышении лимита
По умолчанию пользователь получает сообщение вида Next request after 7.0 seconds.. Чтобы изменить его, достаточно унаследоваться от RateLimitAnswer и переопределить __call__:
from datetime import timedelta from aiogram.types import TelegramObject from aiogram_tool.tools.limit import RateLimitAnswer, RateLimitTool class CustomLimitAnswer(RateLimitAnswer): async def __call__( self, event: TelegramObject, window_time: timedelta, retry_after: timedelta ) -> None: await event.answer( text=f"? Слишком часто! Повторите через {retry_after.total_seconds():.1f} сек." ) rate_limit_tool = RateLimitTool(answer_callback=CustomLimitAnswer())
Хранилища
Лимиты хранятся в хранилище с поддержкой блокировок:
Хранилище |
Описание |
|---|---|
|
По умолчанию. Данные в памяти процесса, сбрасываются при перезапуске |
|
Данные в файле, переживают перезапуск |
|
Данные в Redis, подходят для нескольких инстансов бота |
from redis.asyncio import Redis as AsyncRedis from aiogram_tool.storage import AsyncRedisLockStorage from aiogram_tool.tools.limit import RateLimitTool rate_limit_tool = RateLimitTool(storage=AsyncRedisLockStorage(redis=AsyncRedis()))
Хранилище можно переопределить и для отдельного обработчика, передав storage в RateLimitFilter.
Подводные камни
Есть три момента, на которые стоит обратить внимание.
События без атрибута
from_userне поддерживаются, будет выброшеноTypeError.По умолчанию ключ формируется из имени модуля и функции обработчика. Если имена двух обработчиков совпадут, они будут делить один лимит, поэтому для надёжности задавайте
keyявно.Фильтр получает
RateLimitToolчерез диспетчер, который ищется в данных апдейта. При обычном polling это работает автоматически. Если апдейты обрабатываются вручную (например, через webhook иfeed_update), передайте в фильтрdispatcher=..., иначе будет выброшеноValueError("Dispatcher not found").
Все примеры и подробности есть в документации.
LongCallbackData — длинный callback_data у InlineKeyboardButton
Telegram ограничивает размер атрибута callback_data инлайн-кнопки 64 байтами. Если сериализованные данные превышают этот лимит, aiogram выбрасывает ошибку ValueError: Resulted callback data is too long!.
LongCallbackData решает эту проблему: слишком «длинные» данные автоматически сохраняются в хранилище, а в кнопку упаковывается короткий уникальный идентификатор. Когда пользователь нажимает на кнопку, данные прозрачно восстанавливаются из хранилища. API остаётся таким же, как у стандартного CallbackData в aiogram.
from aiogram import F from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup from aiogram.filters import CommandStart from aiogram_tool.tools.callback_data import LongCallbackData class MyLongData(LongCallbackData, prefix="mydata"): mode: str payload: str @dp.message(CommandStart()) async def start_handler(message: Message): # Короткие данные упаковываются как обычно short_cb = await MyLongData(mode="short", payload="Hello!").pack_long() # Длинные данные (больше 64 байт) автоматически сохраняются в хранилище long_cb = await MyLongData(mode="long", payload="A" * 200).pack_long() await message.answer( "Выберите действие:", reply_markup=InlineKeyboardMarkup( inline_keyboard=[ [InlineKeyboardButton(text="Короткие данные", callback_data=short_cb)], [InlineKeyboardButton(text="Длинные данные", callback_data=long_cb)], ] ), ) # Фильтры работают так же, как в стандартном aiogram @dp.callback_query(MyLongData.filter(F.mode == "long")) async def process_long_data(query: CallbackQuery, callback_data: MyLongData): await query.answer(text=f"Длина payload: {len(callback_data.payload)}")
Также можно контролировать хранилище, в которое сохраняются длинные данные. Для этого переопределите атрибут класса _storage, например на MemoryStorage, FileStorage или AsyncRedisStorage:
from redis.asyncio import Redis as AsyncRedis from aiogram_tool.storage import AsyncRedisStorage redis_storage = AsyncRedisStorage( redis=AsyncRedis(host="localhost", port=6379, decode_responses=True), expire=3600, # данные живут 1 час ) class PersistentData(LongCallbackData, prefix="redis"): _storage = redis_storage user_id: int big_context: str
Данные при этом переживают перезапуск бота.
Если callback_data не найдена в хранилище (например, бот перезапустился), вызывается _answer_callback, то есть __call__ класса CallbackDataAnswer. По умолчанию он показывает alert «Button expired». Чтобы задать свой ответ, наследуйтесь от CallbackDataAnswer и переопределите __call__:
from aiogram.types import CallbackQuery from aiogram_tool.tools.callback_data import CallbackDataAnswer class MyExpiredAnswer(CallbackDataAnswer): async def __call__(self, query: CallbackQuery) -> None: await query.message.answer("Кнопка устарела, отправьте /start заново.") await query.answer() class MyLongData(LongCallbackData, prefix="mydata"): _answer_callback = MyExpiredAnswer() mode: str payload: str
Для LongCallbackData aiogram_tool_setup не нужен, класс работает сам по себе.
Итоги
Все три инструмента решают типичные задачи, с которыми сталкивается каждый, кто пишет ботов на aiogram: регистрация зависимостей, ограничение частоты запросов и длинные данные в кнопках. Исходный код, документация и примеры доступны в репозитории. Буду рад вашим замечаниям и идеям в issues.