MAATRIX / Блог / Бот для рассылки по подписчикам в Telegram

Бот для рассылки по подписчикам в Telegram

Бот для рассылки по подписчикам в Telegram

MAATRIX

Рано или поздно у любого Telegram-бота с аудиторией появляется задача разослать сообщение всем подписчикам сразу — акцию, новость, важное уведомление. Кажется, что это тривиально: цикл по списку chat_id и send_message. На практике вы почти сразу упрётесь в лимиты Telegram, часть пользователей за это время успеет заблокировать бота, а рассылка на пару тысяч человек без правильной архитектуры либо упадёт с исключением на середине, либо растянется на часы. Разберём, как сделать рассылку надёжно: где хранить подписчиков, как не словить rate limit, как переживать блокировки и как запускать рассылку по расписанию.

Где хранить список подписчиков: SQLite или PostgreSQL

Список подписчиков — это не текстовый файл и не переменная в памяти бота: процесс перезапустится, и данные потеряются. Нужна база.

Для небольшого бота (до нескольких десятков тысяч подписчиков, один процесс, без выборок вида «сегмент пользователей по трём условиям») достаточно SQLite. Плюсы: нулевая настройка, файл bot.db рядом с кодом, никакого отдельного сервера БД. Минус — SQLite плохо переживает параллельную запись из нескольких процессов, поэтому если у вас несколько воркеров рассылки одновременно, лучше сразу смотреть в сторону PostgreSQL.

Минимальная схема на SQLite:

CREATE TABLE subscribers (
    chat_id INTEGER PRIMARY KEY,
    username TEXT,
    joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    is_active INTEGER DEFAULT 1,
    blocked_at TIMESTAMP
);

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

Если бот растёт (сотни тысяч подписчиков, сегментация по интересам, несколько источников трафика, аналитика по рассылкам), переезжайте на PostgreSQL. Он спокойно переживает конкурентный доступ, даёт нормальные индексы для выборок по is_active, joined_at, тегам сегментов, и с ним проще делать инкрементальные рассылки (например, «разослать только тем, кто не получал сообщение за последний час», если рассылка прервалась и её нужно продолжить). Установка PostgreSQL на сервере подробно разобрана в статье про установку и настройку PostgreSQL на VPS.

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

Лимиты Telegram на отправку и как их не превышать

Telegram Bot API ограничивает скорость исходящих сообщений. Официально документированные и общепринятые на практике ориентиры:

  • не более 30 сообщений в секунду суммарно от бота, всем получателям;
  • не более 1 сообщения в секунду одному и тому же чату;
  • для рассылок в группы — дополнительно не более 20 сообщений в минуту на одну группу.

Это именно ориентиры — Telegram не публикует точные цифры как гарантию, и на практике лимит может ощущаться чуть иначе в зависимости от нагрузки на их сторону. Превысите лимит — получите ошибку 429 Too Many Requests, и в теле ответа будет поле retry_after с числом секунд, которое нужно подождать перед следующей попыткой.

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

import asyncio
from aiogram.exceptions import TelegramRetryAfter, TelegramForbiddenError

async def send_broadcast(bot, chat_ids: list[int], text: str):
    for chat_id in chat_ids:
        try:
            await bot.send_message(chat_id, text)
        except TelegramRetryAfter as e:
            await asyncio.sleep(e.retry_after)
            await bot.send_message(chat_id, text)  # повтор после паузы
        except TelegramForbiddenError:
            mark_blocked(chat_id)  # см. следующий раздел
            continue
        await asyncio.sleep(0.05)  # ~20 сообщений/сек, с запасом от лимита в 30

Пауза 0.05 секунды между отправками даёт около 20 сообщений в секунду — заметно ниже потолка в 30, и у вас остаётся запас на случайные всплески (например, если параллельно бот отвечает на входящие сообщения от других пользователей — это тоже расходует общий лимит). Гнаться за максимальными 30/сек ради скорости обычно не стоит: разница между 20 и 30 сообщениями в секунду на рассылке в 10 000 человек — это 8 минут против 5,5, а риск словить капы и получить более длинные retry_after увеличивается.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Архитектура рассылки: очередь и воркер

Простой for-цикл внутри обработчика команды /broadcast работает для теста на 50 подписчиках, но для реальной рассылки — плохая идея: если бот перезапустится посреди рассылки (деплой, падение процесса, OOM killer), вы не узнаете, кому сообщение ушло, а кому нет, и есть риск разослать дубликаты.

Рабочая схема:

  1. Команда /broadcast от админа создаёт запись broadcast_jobs в базе со статусом pending и текстом сообщения.
  2. Отдельный воркер (фоновая задача или отдельный процесс) читает список активных подписчиков, создаёт для каждого запись в broadcast_queue со статусом queued.
  3. Воркер обрабатывает очередь пачками, с паузой между отправками, и после каждой отправки обновляет статус записи (sent, failed, blocked).
  4. Если процесс упал и перезапустился — воркер при старте просто продолжает с записей в статусе queued, ничего не отправляя повторно тем, кто уже в статусе sent.
CREATE TABLE broadcast_queue (
    id SERIAL PRIMARY KEY,
    job_id INTEGER,
    chat_id BIGINT,
    status TEXT DEFAULT 'queued', -- queued, sent, failed, blocked
    attempted_at TIMESTAMP
);

Такая очередь превращает рассылку из «одного большого риска» в набор маленьких идемпотентных шагов — уронить процесс не страшно, рассылка просто продолжится с того места, где остановилась. Это тот же принцип, что используют почтовые рассылки с сервера — устройство похоже, и разница в основном в протоколе и лимитах; общие подходы к очереди и повторным попыткам разобраны в статье про массовую рассылку с сервера.

Обработка заблокировавших бота пользователей

Часть подписчиков со временем блокирует бота — это нормально, доля обычно растёт со временем с момента подписки. Если не обрабатывать это отдельно, рассылка либо упадёт целиком на первом же исключении, либо будет молча тратить время на попытки достучаться до недостижимых чатов.

Telegram Bot API при отправке заблокировавшему пользователю возвращает ошибку 403 Forbidden: bot was blocked by the user. В aiogram это исключение TelegramForbiddenError, в python-telegram-bot — Forbidden. Логика обработки простая: ловите именно этот тип ошибки, помечайте пользователя неактивным в базе и переходите к следующему — без ретраев, без падения всего процесса.

from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest

async def safe_send(bot, chat_id: int, text: str, db):
    try:
        await bot.send_message(chat_id, text)
        db.mark_sent(chat_id)
    except TelegramForbiddenError:
        db.mark_inactive(chat_id, reason="blocked")
    except TelegramBadRequest as e:
        # chat not found, user deactivated и подобное
        db.mark_inactive(chat_id, reason=str(e))
    except Exception as e:
        db.mark_failed(chat_id, error=str(e))
        # сюда попадают сетевые сбои — их стоит повторить позже,
        # в отличие от блокировки, которая необратима

Важный нюанс: не путайте «заблокировал бота» с временными сетевыми ошибками или TelegramRetryAfter. Первое — навсегда (пока пользователь сам не разблокирует бота и не напишет /start заново), второе — стоит повторить. Смешивание этих случаев в одной ветке except Exception — частая причина, почему рассылки либо теряют живых подписчиков, либо бесконечно долбятся в мёртвые чаты. Разбор похожих граблей с ботами на сервере в целом — в статье частые ошибки Telegram-бота на сервере.

Раз в 1-2 месяца полезно делать «ревалидацию» неактивных: часть пользователей блокирует бота, а потом разблокирует и продолжает получать сообщения без единого /start — Telegram в этом случае просто возобновит доставку, и следующая рассылка сама покажет, кто на самом деле отписался, а кто нет.

Отложенная и по расписанию рассылка

Рассылка «прямо сейчас» — частный случай более общей задачи: сообщение нужно отправить в конкретный момент, а не строго синхронно с командой админа. Два рабочих варианта.

APScheduler внутри процесса бота — подходит, если у вас один бот-процесс и рассылки не критичны к тому, что переживёт перезапуск ровно в момент отправки:

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from datetime import datetime

scheduler = AsyncIOScheduler()

def schedule_broadcast(run_at: datetime, job_id: int):
    scheduler.add_job(
        run_broadcast_job,
        trigger="date",
        run_date=run_at,
        args=[job_id],
        id=f"broadcast_{job_id}",
        misfire_grace_time=3600,  # если сервер был недоступен — выполнить с опозданием до часа
    )

scheduler.start()

Минус этого подхода — расписание живёт в памяти процесса. Если бот перезапустится, а вы не сохранили задачи в постоянное хранилище (SQLAlchemyJobStore для APScheduler это умеет), запланированная рассылка потеряется.

Cron-задача, дёргающая отдельный скрипт — надёжнее для регулярных рассылок (еженедельный дайджест, ежедневное напоминание), потому что не зависит от того, жив ли основной процесс бота:

# crontab -e
0 10 * * 1 /usr/bin/python3 /opt/bot/broadcast_weekly.py >> /var/log/broadcast.log 2>&1

Скрипт broadcast_weekly.py в этом случае — самостоятельный процесс: подключается к той же базе подписчиков, читает текст рассылки (из базы, файла или заранее заготовленного шаблона) и отправляет, используя тот же безопасный send_broadcast с обработкой лимитов и блокировок. Настройка cron на сервере с нуля — в статье про cron-задачи на VPS.

Практическое правило: разовые «отправить сегодня в 19:00» — через планировщик в процессе бота (проще и быстрее реализовать); регулярные, критичные к точности рассылки — через cron и отдельный процесс, независимый от возможных падений и перезапусков бота.

Рабочий пример: скрипт массовой рассылки целиком

Соберём разобранные части в законченный скрипт на aiogram — с очередью, паузами и обработкой блокировок:

import asyncio
import logging
from aiogram import Bot
from aiogram.exceptions import TelegramRetryAfter, TelegramForbiddenError, TelegramBadRequest

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("broadcast")

async def run_broadcast(bot: Bot, db, text: str, delay: float = 0.05):
    chat_ids = db.get_active_subscribers()
    sent, blocked, failed = 0, 0, 0

    for chat_id in chat_ids:
        try:
            await bot.send_message(chat_id, text)
            db.mark_sent(chat_id)
            sent += 1
        except TelegramRetryAfter as e:
            logger.warning(f"rate limit, sleeping {e.retry_after}s")
            await asyncio.sleep(e.retry_after)
            try:
                await bot.send_message(chat_id, text)
                db.mark_sent(chat_id)
                sent += 1
            except Exception as e2:
                db.mark_failed(chat_id, str(e2))
                failed += 1
        except TelegramForbiddenError:
            db.mark_inactive(chat_id, "blocked")
            blocked += 1
        except TelegramBadRequest as e:
            db.mark_inactive(chat_id, str(e))
            blocked += 1
        except Exception as e:
            db.mark_failed(chat_id, str(e))
            failed += 1

        await asyncio.sleep(delay)

    logger.info(f"broadcast done: sent={sent} blocked={blocked} failed={failed}")
    return sent, blocked, failed

На боте на пару тысяч подписчиков и паузе 0,05 секунды такая рассылка займёт около 2 минут — конкретное время у вас будет отличаться в зависимости от того, сколько запросов 429 вернёт Telegram и насколько загружен сервер. Если рассылка регулярно упирается в лимиты — это чаще сигнал, что где-то рядом с ботом крутится ещё один процесс, тоже расходующий общий лимит API (например, вебхук уведомлений или второй инстанс бота, запущенный по ошибке), а не повод уменьшать паузу.

Мониторинг и логирование рассылки

Без логов рассылка на несколько тысяч человек превращается в чёрный ящик: непонятно, сколько сообщений реально ушло, где застряло и сколько подписчиков отвалилось за этот раз. Минимальный набор, который стоит писать в лог или отдельную таблицу:

МетрикаЗачем
Время старта и завершения jobпонять, сколько реально заняла рассылка
sent / blocked / failedбазовая статистика по каждому запуску
Количество 429 и суммарное время ожидания по нимсигнал, что пора снижать темп или разносить рассылки по времени
chat_id из failedручной разбор ошибок, которые не попали ни в blocked, ни в успешные

Если бот уже настроен слать вам алерты о своих сбоях — подключите к этой же схеме и отчёт о рассылке (например, «рассылка завершена: 4180 отправлено, 62 заблокировали, 3 ошибки»), это удобнее, чем потом искать причину сбоя в логах постфактум. Отдельно стоит следить за памятью процесса на крупных рассылках — держать список из десятков тысяч chat_id в памяти обычно не проблема, а вот открытые сетевые соединения и накопленные логи со временем могут потребовать больше ресурсов, чем закладывали изначально.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Частые вопросы

Сколько подписчиков можно разослать за час?

При темпе около 20 сообщений в секунду и без учёта пауз на retry_after — это примерно 70 000 сообщений в час. Реальная цифра у вас будет ниже за счёт ошибок и капов, ориентируйтесь на неё как на верхнюю границу, а не как на гарантию.

Нужен ли отдельный сервер под рассылку или хватит того же VPS, где крутится бот?

Для рассылок на десятки тысяч подписчиков хватает того же сервера, где уже работает бот — рассылка не требовательна к CPU, основное ограничение — сетевые лимиты Telegram, а не мощность сервера.

Можно ли рассылать медиа (фото, видео), а не только текст?

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

Что делать, если рассылка прервалась на середине из-за падения сервера?

Именно для этого нужна очередь со статусами в базе (см. раздел про архитектуру) — при перезапуске воркер продолжает с записей queued, не отправляя повторно тем, кто уже в статусе sent.

Как узнать, сколько подписчиков реально активны, если часть заблокировала бота давно?

Периодическая ревалидация: раз в 1-2 месяца пробуйте отправить лёгкое тестовое сообщение всем в статусе is_active = 1 и по результатам обновляйте базу — это надёжнее, чем полагаться на дату последнего взаимодействия.