- PVSM.RU - https://www.pvsm.ru -
Существует продуктовый паттерн, который я редко вижу разобранным в технических статьях на русском: бот в групповом чате, который реагирует не на команды, а на содержимое обычных сообщений участников. Юзер кидает в чат ссылку на Instagram Reels — бот молча присылает видео файлом под этой ссылкой. Никаких /download, никаких упоминаний @bot, никаких inline-режимов.
Звучит просто. На практике — десяток подводных камней: Telegram Bot API в группах работает иначе, чем в личках; privacy mode ломает половину очевидных решений; flood-control прибьёт наивную реализацию на третьем активном чате; и есть отдельная проблема — как не превратить бота в спам-машину, которая реагирует на каждый https-ссылку в чате и раздражает участников.
Эту статью пишу как разработчик такого бота. Цифры из моего прода маленькие — 31 групповой чат, 380 пользователей в личке за месяц жизни — но проблемы в коде ровно те же, что были бы и при 31000 чатов. Хочу разобрать архитектурные решения, к которым пришёл, и услышать, как делали бы вы.
Первое, обо что спотыкается каждый, кто пишет такого бота — это privacy mode, включённый у телеграм-ботов по умолчанию.
В privacy mode бот в группе получает от Bot API только следующие апдейты:
сообщения, начинающиеся с команды (/start, /help)
сообщения, в которых упомянут сам бот (@my_bot ...)
ответы на сообщения бота
service-сообщения (вступления, выходы)
Все остальные сообщения участников — бот их не видит. Если бот должен реагировать на ссылку, которую пишет произвольный участник, нужно privacy mode выключить.
Делается через @BotFather → Bot Settings → Group Privacy → Disable. После этого бот в группах получает все сообщения, и API начинает доставлять полный поток.
И тут начинается интересное.
Это не очевидно, пока не посмотришь на трафик. В активном чате на 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-запросов и ноль гигабайт трафика. У меня в проде доля «отправок из кэша» в часы пик доходит до значимой части всех ответов — точную цифру не считал, но субъективно бот в эти моменты работает «мгновенно», без видимой задержки на скачивание.
Это уже не техническая, а продуктовая проблема, но она имеет техническое выражение в коде.
Если бот реагирует на каждую ссылку — он раздражает. Бывают случаи, когда люди в чате обсуждают рилс и кидают ссылку как референс, не ожидая что её кто-то будет качать. Бывают чаты, где половина — мемы, и бот превращается в спам.
Я пришёл к нескольким эвристикам:
— Тихий режим как опция чата. Админ группы может включить silent — тогда бот качает только по реплаю или по упоминанию, на голые ссылки не реагирует.
— Антифлуд per-chat. Если в чате уже было N скачиваний за последнюю минуту — следующее ставится на паузу или скипается. Защищает от ситуации «кто-то скинул 20 ссылок подряд».
— Только видео, не посты. Текстовые посты и галереи Instagram бот в групповом режиме игнорирует — они редко нужны всему чату, а в личке скачать никто не мешает.
В итоге получается такой компромисс: бот по умолчанию активный, потому что 90% пользователей именно этого хотят, но даёт админам ручки, чтобы тон подкрутить.
Отдельная боль, которая стоила мне недели отладки.
Когда 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 [1] (название специфическое, родилось как шутка, т.к. не думал, что он выйдет за пределы нашего чата с друзьями — переименовывать поздно, мигрировать лень). Аналитику для написания статьи я снимал именно с него. В личке тоже работает, но интереснее посмотреть как раз в групповом режиме — добавьте в любой свой чат и киньте туда любой рилс, увидите всё описанное в действии.
Так работает в групповом чате (хотя что тут демонстрировать по сути):

Автор: KaJIuHKa
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/video/452379
Ссылки в тексте:
[1] t.me/dicksavepleasebot: https://t.me/dicksavepleasebot
[2] Источник: https://habr.com/ru/articles/1040324/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1040324
Нажмите здесь для печати.