Существует продуктовый паттерн, который я редко вижу разобранным в технических статьях на русском: бот в групповом чате, который реагирует не на команды, а на содержимое обычных сообщений участников. Юзер кидает в чат ссылку на Instagram Reels — бот молча присылает видео файлом под этой ссылкой. Никаких /download, никаких упоминаний @bot, никаких inline-режимов.

Звучит просто. На практике — десяток подводных камней: Telegram Bot API в группах работает иначе, чем в личках; privacy mode ломает половину очевидных решений; flood-control прибьёт наивную реализацию на третьем активном чате; и есть отдельная проблема — как не превратить бота в спам-машину, которая реагирует на каждый https-ссылку в чате и раздражает участников.

Эту статью пишу как разработчик такого бота. Цифры из моего прода маленькие — 31 групповой чат, 380 пользователей в личке за месяц жизни — но проблемы в коде ровно те же, что были бы и при 31000 чатов. Хочу разобрать архитектурные решения, к которым пришёл, и услышать, как делали бы вы.

Проблема 1: privacy mode

Первое, обо что спотыкается каждый, кто пишет такого бота — это privacy mode, включённый у телеграм-ботов по умолчанию.

В privacy mode бот в группе получает от Bot API только следующие апдейты:

  • сообщения, начинающиеся с команды (/start, /help)

  • сообщения, в которых упомянут сам бот (@my_bot ...)

  • ответы на сообщения бота

  • service-сообщения (вступления, выходы)

Все остальные сообщения участников — бот их не видит. Если бот должен реагировать на ссылку, которую пишет произвольный участник, нужно privacy mode выключить.

Делается через @BotFatherBot SettingsGroup PrivacyDisable. После этого бот в группах получает все сообщения, и API начинает доставлять полный поток.

И тут начинается интересное.

Проблема 2: после отключения privacy mode на бота сваливается ВСЁ

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

Наивная реализация выглядит так:

@dp.message()
async def handle_any_message(message: Message):
    if message.text and contains_supported_url(message.text):
        url = extract_url(message.text)
        video = await download_video(url)
        await message.reply_video(video)

Эта штука работает в одном тестовом чате. На пятом активном — ты упираешься в три проблемы одновременно:

1. Telegram flood-control. API ограничивает бота: примерно 1 сообщение в секунду на чат, 30 сообщений в секунду суммарно по всем чатам, и отдельный лимит на одинаковые операции. При параллельной активности в нескольких чатах ты словишь RetryAfter exceptions, и бот начнёт пропускать видео.

2. Скачивание блокирует обработку. Если download_video занимает 5–20 секунд (а для Instagram это нормальный диапазон), то синхронный обработчик превращает бота в очередь из одного человека.

3. Дубликаты. Один и тот же рилс может прилететь от трёх человек в трёх чатах подряд, и ты будешь его качать три раза вместо одного.

Решение: разделение на три уровня

Я пришёл к архитектуре, где обработка сообщения разбита на три стадии с разными требованиями к латентности:

Стадия 1 — Filter (синхронно, мгновенно). Регулярка по тексту сообщения, проверка домена. Тут нельзя делать ничего, что может занять больше миллисекунды. Если ссылки нет или домен не поддерживается — выходим, забываем.

Стадия 2 — Dedup + Queue (синхронно, быстро). Если ссылка есть и валидна — кладём задачу в очередь. Перед этим проверяем: не качали ли мы уже этот URL за последние N минут (Redis с TTL). Если качали — берём готовый file_id из кэша и сразу отправляем reply_video(file_id), не качая заново.

Стадия 3 — Download + Send (асинхронно, медленно). Воркер из очереди берёт задачу, скачивает видео, отправляет в чат, кладёт file_id в кэш для будущих дубликатов.

Псевдокод обработчика:

@dp.message()
async def handle_message(message: Message):
    # Стадия 1: фильтр
    url = extract_supported_url(message.text)
    if not url:
        return
    
    # Стадия 2: проверка кэша
    cached_file_id = await cache.get(url_hash(url))
    if cached_file_id:
        await message.reply_video(cached_file_id)
        return
    
    # Стадия 3: задача в очередь
    await queue.enqueue(DownloadTask(
        url=url,
        chat_id=message.chat.id,
        reply_to_id=message.message_id,
    ))

Воркер:

async def worker():
    while True:
        task = await queue.dequeue()
        try:
            video_path = await download_video(task.url)
            sent = await bot.send_video(
                chat_id=task.chat_id,
                video=FSInputFile(video_path),
                reply_to_message_id=task.reply_to_id,
            )
            # Кэшируем file_id, чтобы в следующий раз не качать
            await cache.set(
                url_hash(task.url), 
                sent.video.file_id, 
                ttl=timedelta(days=7),
            )
        except TelegramRetryAfter as e:
            await asyncio.sleep(e.retry_after)
            await queue.requeue(task)

Самый важный нюанс тут — file_id Telegram'а живёт долго и переиспользуется между чатами без перезагрузки бинарника. Это значит: один раз скачал рилс — отправил его 50 раз в 50 чатов за следующие сутки, потратив на это 50 API-запросов и ноль гигабайт трафика. У меня в проде доля «отправок из кэша» в часы пик доходит до значимой части всех ответов — точную цифру не считал, но субъективно бот в эти моменты работает «мгновенно», без видимой задержки на скачивание.

Проблема 3: как не быть навязчивым

Это уже не техническая, а продуктовая проблема, но она имеет техническое выражение в коде.

Если бот реагирует на каждую ссылку — он раздражает. Бывают случаи, когда люди в чате обсуждают рилс и кидают ссылку как референс, не ожидая что её кто-то будет качать. Бывают чаты, где половина — мемы, и бот превращается в спам.

Я пришёл к нескольким эвристикам:

Тихий режим как опция чата. Админ группы может включить silent — тогда бот качает только по реплаю или по упоминанию, на голые ссылки не реагирует.

Антифлуд per-chat. Если в чате уже было N скачиваний за последнюю минуту — следующее ставится на паузу или скипается. Защищает от ситуации «кто-то скинул 20 ссылок подряд».

Только видео, не посты. Текстовые посты и галереи Instagram бот в групповом режиме игнорирует — они редко нужны всему чату, а в личке скачать никто не мешает.

В итоге получается такой компромисс: бот по умолчанию активный, потому что 90% пользователей именно этого хотят, но даёт админам ручки, чтобы тон подкрутить.

Проблема 4: эфемерность ссылок Instagram

Отдельная боль, которая стоила мне недели отладки.

Когда Instagram отдаёт URL медиа-файла после resolve'а ссылки на рилс — этот URL подписан временной меткой и живёт несколько часов. Если положить его в очередь и попытаться скачать через 30 минут (например, бот лежал, очередь копилась) — получишь 403.

Решение прямолинейное: разделять resolve и download нельзя, они должны быть атомарной операцией внутри одного воркера. И при failure не requeue'ить старую задачу с протухшей ссылкой, а делать resolve заново с нуля.

Звучит очевидно, когда написано. На практике я пару раз словил ситуацию, когда воркер «починил» зависшую очередь и попытался добить старые задачи — все упали с 403, и пользователи получили сообщения «не получилось» от запросов, которые они отправили ещё час назад.

Что в итоге

Бот живёт месяц. В групповом режиме стоит в 31 чате (большинство — небольшие, до 30 человек, самый большой — на 70). Аудитория групп ~296 уникальных пользователей. За день в часы пик — десятки скачиваний из чатов плюс гораздо больше из личек.

Цифры скромные, нагрузки на разрыв нет — но архитектурные решения выше выкристаллизовались именно из попытки масштабировать без переписываний. Когда придёт нагрузка — буду знать, что переживёт без рефакторинга, а что нет (например, in-memory очередь точно нужно будет менять на Redis Streams или RabbitMQ).

Что я хочу обсудить в комментариях, если есть желание:

1. file_id кэширование между чатами — кто-нибудь сталкивался с протуханием file_id? У меня TTL стоит 7 дней, и за это время не словил ни одного WrongFileId, но боюсь, что на больших объёмах это может выстрелить.

2. Очередь. Сейчас у меня in-process asyncio.Queue с одним воркером — этого хватает. Но интересно, на каком масштабе это перестаёт хватать, и как у вас решено в более нагруженных ботах.

3. Privacy и UX. Дилемма «бот по умолчанию активный vs тихий» — где граница между полезным и навязчивым? У меня по дефолту активный, потому что данные показывают, что юзеры этого хотят. Но это субъективно, и в комментах буду рад услышать другие подходы.

Если кто-то хочет потыкать референс — мой бот на скачивание видео живёт по адресу t.me/dicksavepleasebot (название специфическое, родилось как шутка, т.к. не думал, что он выйдет за пределы нашего чата с друзьями — переименовывать поздно, мигрировать лень). Аналитику для написания статьи я снимал именно с него. В личке тоже работает, но интереснее посмотреть как раз в групповом режиме — добавьте в любой свой чат и киньте туда любой рилс, увидите всё описанное в действии.

Так работает в групповом чате (хотя что тут демонстрировать по сути):

Комментарии (0)