Рассылки и уведомления

Учим Цыплёнка-помощника говорить со всеми сразу: достаём список пользователей из базы, шлём им новость по очереди, делаем паузы под лимиты Telegram и аккуратно убираем тех, кто бота заблокировал.
Рассылка — это отправка одного и того же сообщения сразу многим пользователям бота: ты пишешь текст один раз, а бот в цикле доставляет его каждому, кто есть в базе.

Зачем боту уметь писать всем сразу

Представь: ты ведёшь бота для своего игрового клана, и в субботу в 19:00 — клановый рейд. Можно, конечно, лично написать каждому из тридцати человек «не забудь про рейд». А можно набрать одну команду — и Цыплёнок сам разошлёт напоминание всем участникам за пять секунд. Вот это и есть рассылка: один текст, много получателей.

Рассылки нужны постоянно. Бот-напоминалка про домашку шлёт «завтра сдавать проект» всем одноклассникам. Бот музыкального паблика кидает «вышел новый альбом» всем подписчикам. Бот опросов отправляет «голосование закрывается через час». Везде один и тот же приём: взять список людей и пройтись по нему циклом.

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

Админ: /анонс В субботу в 19:00 клановый рейд! 🐥
Бот:   Начинаю рассылку 47 пользователям…
Бот:   Готово! Доставлено: 44, не дошло: 3.

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

Как это устроено: список плюс цикл плюс паузы

Идея рассылки до смешного простая. Помнишь, как учитель раздаёт тетради? Он берёт стопку, вызывает по списку и каждому кладёт тетрадь на парту — по очереди, одного за другим. Бот делает ровно то же самое: берёт список пользователей из базы и в цикле каждому отправляет сообщение.

Список людей (из базы) + цикл (пройтись по каждому) + пауза (чтобы не частить) = рассылка.

В этой формуле новая для нас только пауза. Зачем она? У Telegram есть правила вежливости — лимиты. Грубо говоря, бот не должен слать больше примерно 30 сообщений в секунду. Если ломануться рассылать всем разом без передышки, Telegram временно скажет «притормози» и часть сообщений не доставит. Поэтому между отправками мы делаем крошечную паузу — будто учитель идёт от парты к парте, а не швыряет все тетради в воздух одновременно.

В уроке про SQLite для бота мы научились хранить пользователей в базе и доставать из неё строки. Теперь это пригодится по-настоящему: список получателей мы возьмём именно оттуда. Если ты сохранял каждого, кто нажал /start — отлично, этот список и есть твоя аудитория.

Маленькая разминка: пройтись по списку с паузой

Прежде чем трогать настоящего бота, разберём саму механику «цикл + пауза + счётчик» на чистом Python — без aiogram, чтобы было видно скелет идеи. Здесь мы не шлём ничего в Telegram, а просто печатаем, кому «отправили», и считаем успехи.

users = [101, 102, 103, 104]

sent = 0
failed = 0
for tg_id in users:
    # представим, что у пользователя 103 бот заблокирован
    if tg_id == 103:
        failed += 1
        print("не дошло:", tg_id)
        continue
    sent += 1
    print("отправлено:", tg_id)

print("итог -> доставлено:", sent, "| не дошло:", failed)

Вывод:

отправлено: 101
отправлено: 102
не дошло: 103
отправлено: 104
итог -> доставлено: 3 | не дошло: 1

Вот и весь костяк рассылки. Мы идём по списку id, ведём два счётчика — сколько дошло и сколько нет, — а continue позволяет «пропустить» неудачного получателя и спокойно двинуться к следующему. В настоящем боте вместо print будет реальная отправка через Telegram, а проверку if tg_id == 103 заменит обработка ошибки. Логика же остаётся ровно такой.

Шаг 1. Достаём список пользователей из базы

Начнём с самого первого пункта формулы — списка получателей. Добавим в наш bot.py маленькую функцию, которая возвращает все tg_id из таблицы users. Вспомни SQL из прошлого модуля: нам нужен один столбец у всех строк.

SELECT tg_id FROM users;

А вот функция-обёртка на Python. Она подключается к той же базе chicken.db, что и раньше, выбирает столбец tg_id и собирает его в обычный список чисел.

import sqlite3

DB = "chicken.db"

def get_all_user_ids() -> list[int]:
    with sqlite3.connect(DB) as db:
        rows = db.execute("SELECT tg_id FROM users").fetchall()
    # fetchall вернёт список кортежей: [(101,), (102,), ...]
    # вытащим из каждого кортежа само число
    return [row[0] for row in rows]

Результат: в чате эта функция ничего не печатает — она молча возвращает список id вроде [101, 102, 103], то есть нашу аудиторию для рассылки. Дальше мы пройдёмся по этому списку циклом.

Обрати внимание на [row[0] for row in rows]. Метод fetchall() отдаёт список кортежей, даже когда столбец всего один: [(101,), (102,)]. Нам неудобно таскать эти скобки, поэтому мы достаём из каждого кортежа первый (и единственный) элемент row[0] и складываем чистые числа в новый список. Получается аккуратный [101, 102].

Шаг 2. Рассылаем сообщение по очереди

Теперь — сердце урока. Напишем асинхронную функцию broadcast, которая принимает текст и проходит по всем пользователям, отправляя каждому сообщение через знакомый объект bot. Отправка сообщения в aiogram — это await bot.send_message(chat_id, text), где chat_id для личного чата совпадает с tg_id пользователя.

import asyncio

async def broadcast(text: str) -> tuple[int, int]:
    user_ids = get_all_user_ids()
    sent = 0
    failed = 0

    for tg_id in user_ids:
        await bot.send_message(tg_id, text)
        sent += 1
        await asyncio.sleep(0.05)  # крошечная пауза между отправками

    return sent, failed

Результат: в чате каждый пользователь из базы получит сообщение с текстом text, одно за другим. Функция вернёт пару чисел — сколько отправлено и сколько не дошло (пока второе всегда 0, ошибки мы обработаем на следующем шаге).

Разберём по строчкам. Сначала достаём список id уже знакомой функцией. Заводим два счётчика. В цикле для каждого tg_id зовём await bot.send_message(...) — это и есть отправка. После каждой отправки делаем await asyncio.sleep(0.05) — пауза в 5 сотых секунды. Помнишь учителя с тетрадями? Это его шаг от парты к парте. На тридцати пользователях такая пауза вообще незаметна, зато бот не врезается в лимит Telegram.

Почему asyncio.sleep, а не обычный time.sleep? Потому что наш бот асинхронный: пока он «спит» через asyncio.sleep, он не замораживается целиком и может в это время отвечать другим людям. А time.sleep заморозил бы вообще всё — будто официант застыл посреди зала на полсекунды и перестал реагировать на любые столики. Запомни: внутри async-функции для пауз всегда await asyncio.sleep(...).

Шаг 3. Обрабатываем тех, кто заблокировал бота

А теперь самое важное и самое частое в реальной жизни. Любой пользователь в любой момент может удалить чат с ботом или нажать «Заблокировать». Telegram этого не запрещает — это право человека. Но когда ты попробуешь отправить такому сообщение, bot.send_message поднимет ошибку. И если её не поймать, твоя рассылка рухнет на первом же заблокировавшем — и остальные сорок человек ничего не получат.

Решение — обернуть отправку в try / except. Это как «попробуй отдать тетрадь; если парта пустая — не роняй всю стопку, просто отметь и иди дальше». В aiogram 3.x ошибка, когда бот заблокирован или чат недоступен, — это TelegramForbiddenError (бот заблокирован) и в целом TelegramBadRequest (чат не найден и подобное). Поймаем их.

import asyncio
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest

async def broadcast(text: str) -> tuple[int, int]:
    user_ids = get_all_user_ids()
    sent = 0
    failed = 0

    for tg_id in user_ids:
        try:
            await bot.send_message(tg_id, text)
            sent += 1
        except (TelegramForbiddenError, TelegramBadRequest):
            failed += 1
            # человек заблокировал бота или чат недоступен — пропускаем
        except Exception:
            failed += 1
            # любая другая неожиданная ошибка тоже не должна валить рассылку
        await asyncio.sleep(0.05)

    return sent, failed

Результат: в чате теперь рассылка дойдёт до всех, кто бота не блокировал, а на заблокировавших не упадёт — она их просто посчитает в failed и спокойно продолжит дальше. Функция вернёт честную пару, например (44, 3).

Смотри, как устроена защита. Строка await bot.send_message(...) стоит внутри try. Если всё хорошо — увеличиваем sent. Если Telegram ответил «этот человек тебя заблокировал» (TelegramForbiddenError) или «такого чата нет» (TelegramBadRequest) — попадаем в первый except, увеличиваем failed и едем дальше. Запасной except Exception ловит вообще любую другую неожиданность (например, мигнул интернет), чтобы одна случайная заминка не обрубила рассылку на полпути. Пауза asyncio.sleep стоит после try/except, поэтому срабатывает в любом случае.

Можно ли потом удалить заблокировавших из базы?

Да, и это хорошая привычка: зачем хранить мёртвые контакты. Если поймал TelegramForbiddenError — значит, человек тебя точно заблокировал, и можно удалить его строку, чтобы база не распухала. Достаточно в первом except добавить вызов вроде такого:

DELETE FROM users WHERE tg_id = ?;

Только не удаляй на TelegramBadRequest подряд — там бывают временные причины. Удаляй именно тех, кто явно заблокировал. В мини-практике ты сможешь это докрутить.

Шаг 4. Команда /анонс только для админа

Собираем всё в хэндлер. Рассылка — мощная штука, поэтому запускать её должен только ты, а не любой случайный пользователь (иначе кто-нибудь начнёт спамить твоей же аудитории). Самый простой способ — заранее записать свой tg_id в переменную и пускать команду только себе.

from aiogram.filters import Command
from aiogram.types import Message

ADMIN_ID = 777123  # сюда впиши свой telegram id

@dp.message(Command("анонс"))
async def cmd_broadcast(message: Message):
    if message.from_user.id != ADMIN_ID:
        await message.answer("Эта команда только для админа 🐥")
        return

    parts = message.text.split(maxsplit=1)
    if len(parts) < 2:
        await message.answer("Напиши так: /анонс текст сообщения")
        return

    text = parts[1]
    user_ids = get_all_user_ids()
    await message.answer(f"Начинаю рассылку {len(user_ids)} пользователям…")

    sent, failed = await broadcast(text)
    await message.answer(f"Готово! Доставлено: {sent}, не дошло: {failed}.")

Результат: в чате на команду /анонс В субботу в 19:00 клановый рейд! бот сначала ответит «Начинаю рассылку 47 пользователям…», разошлёт текст всем из базы, а в конце покажет «Готово! Доставлено: 44, не дошло: 3.» Если команду вызовет не админ — бот вежливо откажет. Если забыть текст — подскажет формат.

Тут всё уже знакомо по прошлым урокам. Сначала проверяем, что команду вызвал админ, сравнивая message.from_user.id с заранее заданным ADMIN_ID. Потом отрезаем сам текст анонса через split(maxsplit=1) — так весь текст после слова /анонс попадёт в одну строку, даже если в нём много слов и пробелов. Сообщаем админу, что начали, зовём нашу broadcast и в конце отчитываемся цифрами. Свой tg_id, кстати, можно подсмотреть через бота @userinfobot в Telegram.

Частые ошибки и подводные камни

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

  • Не ловить ошибку заблокировавших. Самая болезненная. Без try/except первый же человек, заблокировавший бота, обрушит send_message с TelegramForbiddenError, цикл прервётся, и остальная аудитория ничего не получит. Отправку в рассылке всегда оборачивай в try/except.
  • Поставить time.sleep вместо asyncio.sleep. time.sleep внутри async-функции замораживает всего бота: пока идёт рассылка, он перестаёт отвечать остальным. Для пауз в асинхронном коде только await asyncio.sleep(...).
  • Рассылать вообще без пауз. Если убрать asyncio.sleep и слать сотням людей залпом, Telegram упрётся в лимит (примерно 30 сообщений в секунду) и начнёт отвечать ошибкой TelegramRetryAfter — часть сообщений не дойдёт. Маленькая пауза дешевле, чем потерянная рассылка.
  • Забыть проверку на админа. Если /анонс может вызвать кто угодно, то любой пользователь способен разослать спам всей твоей аудитории от имени бота. Всегда сверяй message.from_user.id с ADMIN_ID в начале хэндлера.
  • Брать текст как message.text целиком. Если отправить в рассылку весь message.text, в сообщение попадёт и сама команда — люди получат «/анонс В субботу…». Отрезай команду через split(maxsplit=1) и бери только вторую часть.

Мини-практика: рассылка только тем, кто хочет уведомления

В прошлом модуле у пользователя в таблице появилось поле notify (1 — уведомления включены, 0 — выключены). Несправедливо слать анонсы тем, кто их отключил. Доработай рассылку так, чтобы она уважала этот флажок.

  1. Напиши новую функцию get_subscribed_ids(), которая возвращает id только тех, у кого notify = 1. Подсказка: SQL почти как раньше, но с условием — SELECT tg_id FROM users WHERE notify = 1.
  2. В функции broadcast используй get_subscribed_ids() вместо get_all_user_ids() — теперь анонс уйдёт только подписанным.
  3. Доработай первый except: если поймали именно TelegramForbiddenError, удаляй пользователя из базы запросом DELETE FROM users WHERE tg_id = ? (с плейсхолдером и кортежем, как мы привыкли).
  4. Проверь: отключи себе уведомления командой /уведомления из прошлого урока, запусти /анонс со второго аккаунта-друга и убедись, что тебе анонс не пришёл, а другу — пришёл.

Бонус для смелых: добавь в рассылку показ прогресса. Заведи счётчик и каждые, скажем, 20 отправленных сообщений редактируй своё первое сообщение админу через bot.edit_message_text, показывая «Разослано 20 из 47…». Так на большой аудитории ты будешь видеть, что бот не завис, а работает.

Итоги и что дальше

Сегодня Цыплёнок-помощник научился говорить со всей своей аудиторией разом. Мы разобрали простую формулу рассылки — список из базы плюс цикл плюс паузы — и собрали её по шагам: достали все tg_id функцией get_all_user_ids, прошлись по ним в асинхронной broadcast, поставили крошечную asyncio.sleep между отправками, чтобы не упереться в лимиты Telegram, и обернули отправку в try/except, чтобы заблокировавшие бота не валили всю рассылку. Сверху повесили команду /анонс с проверкой на админа и честным отчётом «доставлено / не дошло». А ещё научились по ошибке TelegramForbiddenError чистить базу от мёртвых контактов.

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

Проверьте себя
1. Зачем между отправками сообщений в рассылке делают паузу через asyncio.sleep?
AЧтобы пользователи успевали прочитать предыдущее сообщение
BЧтобы не упереться в лимиты Telegram (примерно 30 сообщений в секунду) и не потерять часть доставок
CЧтобы база данных успевала сохранять изменения
DЭто нужно только для красоты, на работу не влияет
2. Что произойдёт, если отправку в цикле рассылки НЕ обернуть в try/except, и один из пользователей заблокировал бота?
ATelegram сам пропустит этого пользователя, ничего не сломается
Bsend_message поднимет ошибку, цикл прервётся и остальные пользователи не получат сообщение
CБот автоматически удалит заблокировавшего из базы
DСообщение всё равно дойдёт, просто с задержкой
3. Почему в асинхронной функции рассылки используют asyncio.sleep, а не обычный time.sleep?
Atime.sleep не умеет принимать дробные числа
Basyncio.sleep точнее отмеряет время
Ctime.sleep заморозил бы всего бота, и он не смог бы отвечать другим, а asyncio.sleep — нет
DРазницы нет, можно использовать любой
4. Откуда функция broadcast берёт список получателей рассылки?
AИз базы данных — запросом SELECT tg_id FROM users
BИз жёстко прописанного в коде списка номеров
CTelegram сам присылает список всех, кто когда-либо писал боту
DИз текста команды /анонс
5. Зачем в хэндлере команды /анонс сравнивают message.from_user.id с ADMIN_ID?
AЧтобы записать админа в базу при первом запуске
BЧтобы рассылку мог запустить только админ, а не любой пользователь, иначе кто угодно сможет спамить всей аудитории
CЧтобы узнать город пользователя
DЭто требование aiogram для всех команд
6. Почему текст для рассылки берут через message.text.split(maxsplit=1)[1], а не как message.text целиком?
AЧтобы текст занимал меньше места в базе
BЧтобы в сообщение не попала сама команда /анонс — иначе люди получат «/анонс …»
Csplit обязателен для любой работы с текстом в aiogram
DЧтобы ограничить длину сообщения