Рассылки и уведомления
Учим Цыплёнка-помощника говорить со всеми сразу: достаём список пользователей из базы, шлём им новость по очереди, делаем паузы под лимиты 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 — выключены). Несправедливо слать анонсы тем, кто их отключил. Доработай рассылку так, чтобы она уважала этот флажок.
- Напиши новую функцию
get_subscribed_ids(), которая возвращает id только тех, у когоnotify = 1. Подсказка: SQL почти как раньше, но с условием —SELECT tg_id FROM users WHERE notify = 1. - В функции
broadcastиспользуйget_subscribed_ids()вместоget_all_user_ids()— теперь анонс уйдёт только подписанным. - Доработай первый
except: если поймали именноTelegramForbiddenError, удаляй пользователя из базы запросомDELETE FROM users WHERE tg_id = ?(с плейсхолдером и кортежем, как мы привыкли). - Проверь: отключи себе уведомления командой
/уведомленияиз прошлого урока, запусти/анонссо второго аккаунта-друга и убедись, что тебе анонс не пришёл, а другу — пришёл.
Бонус для смелых: добавь в рассылку показ прогресса. Заведи счётчик и каждые, скажем, 20 отправленных сообщений редактируй своё первое сообщение админу через bot.edit_message_text, показывая «Разослано 20 из 47…». Так на большой аудитории ты будешь видеть, что бот не завис, а работает.
Итоги и что дальше
Сегодня Цыплёнок-помощник научился говорить со всей своей аудиторией разом. Мы разобрали простую формулу рассылки — список из базы плюс цикл плюс паузы — и собрали её по шагам: достали все tg_id функцией get_all_user_ids, прошлись по ним в асинхронной broadcast, поставили крошечную asyncio.sleep между отправками, чтобы не упереться в лимиты Telegram, и обернули отправку в try/except, чтобы заблокировавшие бота не валили всю рассылку. Сверху повесили команду /анонс с проверкой на админа и честным отчётом «доставлено / не дошло». А ещё научились по ошибке TelegramForbiddenError чистить базу от мёртвых контактов.
Теперь у тебя есть один из самых полезных паттернов реальных ботов — без него не обходится ни одна напоминалка, ни один паблик. В следующих уроках раздела мы продолжим собирать арсенал боевых приёмов: разберём, как защититься от спама самих пользователей и как красиво обрабатывать ошибки, чтобы бот не падал на ровном месте, а аккуратно сообщал, что пошло не так.