The Jerusalem PostUS military has lost 81 aircraft in Iran war according to Congressional reportRTP DesportoI Liga. Académico de Viseu-EstorilPunchTrump says Ukraine needs new president, blames Zelenskyy for diesel pricesESPN DeportesNo extrañan a Raphinha: Barcelona golea al Getafe y sigue con paso perfectoESPNFollow live: USWNT, Spain go head-to-head in friendlyBBC SportWatch Sportscene highlights of Saturday's Premiership actionZDF heuteAktuelle Pressemitteilungen des ZDFBBC MundoTrump dice que "es hora de que Ucrania tenga un nuevo presidente", tras las críticas de Zelensky al acuerdo sobre el diésel entre EE.UU. y RusiaNOSTot zeker zondagavond geen treinen tussen Rotterdam en Delft na brandstichtingColliderNetflix's 'East of Eden' Adaptation Is Even Better When It Goes Beyond the Book7sur7Evenepoel: “J’ai du mal à retrouver ma meilleure forme depuis le Tour de France”Rai NewsTrump: “Kiev trovi un nuovo leader che faccia un accordo”. Strage a Zaporizhzhia: 20 morti
The Daily Newsstand · Free, Always
Saturday, October 10, 2026

Инструменты для aiogram

Translate

Хочу рассказать про библиотеку 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.TRANSIENT

По умолчанию. Зависимость вызывается каждый раз

Scope.REQUEST

Результат кэшируется на время обработки одного апдейта

Scope.SINGLETON

Результат кэшируется на всё время работы приложения

Задавать 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 секунд
    )
)

Алгоритм

Аргументы

Особенности

FixedWindowRateLimit

requests, time

Простой, минимум данных; возможны всплески на границе окна

SlidingWindowRateLimit

requests, time

Точный, без всплесков; хранит список временных меток

TokenBucketRateLimit

bucket_size, current_tokens, refill_time, refill_tokens

Всплески разрешены, средняя частота ограничена скоростью пополнения

Персональные и глобальные лимиты

По умолчанию лимит действует на каждого пользователя отдельно: в ключ входит 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())

Хранилища

Лимиты хранятся в хранилище с поддержкой блокировок:

Хранилище

Описание

MemoryLockStorage

По умолчанию. Данные в памяти процесса, сбрасываются при перезапуске

FileLockStorage

Данные в файле, переживают перезапуск

AsyncRedisLockStorage

Данные в 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.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.