Существует продуктовый паттерн, который я редко вижу разобранным в технических статьях на русском: бот в групповом чате, который реагирует не на команды, а на содержимое обычных сообщений участников. Юзер кидает в чат ссылку на 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 выключить.
Делается через @BotFather → Bot Settings → Group Privacy → Disable. После этого бот в группах получает все сообщения, и 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 (название специфическое, родилось как шутка, т.к. не думал, что он выйдет за пределы нашего чата с друзьями — переименовывать поздно, мигрировать лень). Аналитику для написания статьи я снимал именно с него. В личке тоже работает, но интереснее посмотреть как раз в групповом режиме — добавьте в любой свой чат и киньте туда любой рилс, увидите всё описанное в действии.
Так работает в групповом чате (хотя что тут демонстрировать по сути):
