// Python Dev

Два дня на задачу, которая казалась тривиальной: асинхронность загрузки в Telegram-ботах

Опубликовано 30.05.2026

Есть класс задач, которые выглядят как пятнадцать минут работы. Потом садишься за них и обнаруживаешь, что дело не в коде — дело в том, как устроена система под капотом. Это история про одну такую задачу.

Контекст

Я разрабатывал Telegram-бота с несложной на первый взгляд логикой: пользователь отправляет фотографии, бот их собирает, ждёт последнюю и спрашивает — «Есть ли ещё?». Есть ограничения: минимум 5 фото, максимум 30. Если меньше пяти — молчим, ждём ещё. Если пришло больше тридцати — останавливаем и говорим, что хватит. Дальше FSM переходит в следующее состояние и начинается основная обработка.

Казалось бы: ловим событие, считаем фото, после последнего шлём кнопку. Пара часов работы с чтением спецификаций и тестами, максимум.

Но что-то пошло не так и работа затянулась на два дня.

Как Telegram работает с фотографиями

Первое, что я не учёл: Telegram не передаёт фотографии по одной, если их несколько. Он группирует их в группы, присваивая каждой media_group_id — и отправляет чанками по 10 штук. Это поведение встроено в платформу и не настраивается.

Казалось бы, ладно: ловим события по media_group_id, дожидаемся тишины, отправляем кнопку. Но здесь второй сюрприз.

Telegram заранее не сообщает, сколько фотографий будет в группе.

Ты получаешь события одно за другим. Каждое следующее может быть последним — а может, нет. Если фотографии большие, между событиями возникают паузы: серверы Telegram обрабатывают загрузку. И ты стоишь перед классической проблемой неопределённости: ждать дальше или уже отправлять сообщение?

Первые попытки

Первая идея — ждать фиксированный таймаут после каждого события. Подождали 1-2 секунды тишины — значит, всё пришло, шлём кнопку. Логика понятная, реализуется просто: отслеживаем время последнего полученного фото, запускаем фоновую проверку без блокировки основного потока. Но вы же помните, что фото могут быть большими, сети не стабильными и ожидание превращается в лотерею.

Проблема с таймстэмпами

Вот что происходит на практике. Пользователь отправляет 25 фотографий. Клиент начинает загрузку. Сервера Telegram принимают фото и обрабатывают их — и именно в момент обработки на сервере фиксируется таймстэмп события, а не момент отправки с устройства.

Это значит: события sendMediaGroup могут приходить в бот с задержкой и в непредсказуемом порядке относительно того, как они видны в интерфейсе клиента.

Мы ловим последнее событие, ждём секунду, шлём сообщение с кнопкой. Всё логично. Но в интерфейсе пользователя это выглядит так: сначала появляется наша кнопка, потом — бах — поверх неё приходят ещё несколько фотографий, которые Telegram показывает в таймлайне по времени своей обработки.

Кнопка улетает вверх. Пользователь её не видит и не понимает, что делать дальше.

Я пробовал разные таймауты, следил за номерами входящих сообщений — ничего не давало стабильного результата. Потому что проблема не в таймауте. Проблема в том, что ты не контролируешь порядок появления сообщений в интерфейсе клиента: это решает Telegram на своей стороне.

Решение, которое сработало

В итоге я пришёл к простой идее, которая решает проблему не предотвращением, а реакцией на факт.

Алгоритм такой:

После последнего полученного события ждём 1-2 секунды. Отправляем сообщение с кнопкой и сохраняем его message_id. Если после этого прилетает ещё одно фото — удаляем старое сообщение с кнопкой и отправляем новое. Повторяем, пока не наступит реальная тишина.

# Примерно так это выглядит на aiogram
last_photo_time = {}
button_message_id = {}

async def handle_photo(message: Message, state: FSMContext):
    user_id = message.from_user.id
    last_photo_time[user_id] = time.time()
    
    data = await state.get_data()
    photos = data.get("photos", [])
    photos.append(message.photo[-1].file_id)
    await state.update_data(photos=photos)
    
    # Удаляем предыдущую кнопку, если она была
    if user_id in button_message_id:
        try:
            await message.bot.delete_message(
                chat_id=message.chat.id,
                message_id=button_message_id[user_id]
            )
        except Exception:
            pass
    
    # Ждём тишины без блокировки
    asyncio.create_task(check_and_send_button(message, state))

async def check_and_send_button(message: Message, state: FSMContext):
    user_id = message.from_user.id
    await asyncio.sleep(1.5)
    
    # Если за это время пришло новое фото — выходим, новая задача уже запущена
    if time.time() - last_photo_time[user_id] < 1.4:
        return
    
    data = await state.get_data()
    photos = data.get("photos", [])
    
    if len(photos) < 5:
        return  # Молчим, ждём ещё
    
    if len(photos) >= 30:
        # Говорим, что хватит, переходим дальше
        await state.set_state(NextState.processing)
        return
    
    # Отправляем кнопку и запоминаем id
    sent = await message.answer(
        "Это все фотографии?",
        reply_markup=confirmation_keyboard()
    )
    button_message_id[user_id] = sent.message_id

Ключевая идея: мы не пытаемся угадать, когда придёт последнее фото. Мы просто готовы переотправить кнопку, если ошиблись. Пользователь видит актуальную кнопку всегда внизу ленты, рядом с последними фото. Визуально пропадающая кнопка не заметна.

Что здесь важно с точки зрения архитектуры

Это хороший пример того, что асинхронные системы часто нельзя проектировать исходя из предположений о порядке событий. Telegram — асинхронная система со своей логикой обработки на серверах. Наш бот — тоже асинхронная система. Когда они взаимодействуют, порядок событий в обоих интерфейсах не гарантирован.

Первый инстинкт — предсказать поведение и написать под него код. Второй, более устойчивый инстинкт — сделать систему, которая корректно реагирует на неожиданное состояние.

Удаление и переотправка сообщения выглядят как грубый инструмент. Но именно это решение оказалось надёжным, предсказуемым и понятным для пользователя. Никакой магии с таймстэмпами, никакого подбора таймаутов вслепую.

Иногда простое решение — не компромисс. Это и есть правильное решение.

Стек

Python, aiogram, asyncio. FSM для управления состояниями диалога.

// Python Dev

Другие статьи Python Dev

Все статьи

// Python Projects

Проекты Python Dev

Все проекты

// Contact

Нужна помощь?

Свяжись со мной и я помогу решить проблему

Написать в Telegram

Отвечаю в течение рабочего дня (03:00–13:00 GMT)

Или оставьте заявку здесь:

Отправить заявку
Написать и получить быстрый ответ