Инструменты для aiogram
Хочу рассказать про библиотеку 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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.