Содержание урока · 10 разделов
Учебного бота на двести пользователей заказчик просит «ещё уведомлять о новых статьях» — и вот тут простая на вид задача рассылки ломает наивный код. Цикл по списку chatID с bot.Send внутри выглядит рабочим: первые десятки сообщений уходят, потом Telegram начинает отвечать 429 Too Many Requests, а при особенно рьяной скорости — надолго резать лимиты всему боту. Второй сюрприз ждёт позже: часть адресов отвечает 403 Forbidden, потому что люди заблокировали бота за год, а список никто не чистил. Рассылка — первая функция бота, где ошибки API становятся нормой, а не исключением: она долбит в сотни чатов подряд, и любой нюанс лимитов сразу виден на цифрах. Хорошая новость в том, что все решения урока — пауза, чистка, фоновый запуск — умещаются в три десятка строк и переносятся между ботами без изменений. В этом уроке соберём подписку, напишем вежливую функцию рассылки с паузой, разберём обе ошибки и вынесем отправку в горутину.
Подписчики: кто получит рассылку#
Начало всему — список адресатов, а адресат в Bot API один: chat_id. Чат с ботом начинается командой /start — это и есть точка подписки: обработчик добавляет From.ID или Chat.ID в хранилище подписчиков. Отписка — команда /stop или слово «стоп», и её наличие обязательно: рассылка без пути назад выжигает доверие и собирает блокировки, о которых ниже. Хранится список тем же способом, что и данные анкеты из урока про хранение: в памяти — map[int64]bool под мьютексом, на подольше — Redis, где подписчики лежат множеством SAdd и удалить участника можно без чтения всего списка. Нюанс, о котором забывают: bot.GetChat и поиск по username в рассылке не участвуют — нельзя «найти всех пользователей Telegram», только тех, кто сам написал боту. Поэтому список подписчиков — актив бота, который стоит беречь: вежливость, частота и полезность рассылок определяют, сколько адресов переживёт месяц.
Подписка и отписка
GOtype Subscribers struct {
mu sync.RWMutex
chats map[int64]bool
}
func (s *Subscribers) Add(chatID int64) {
s.mu.Lock()
s.chats[chatID] = true
s.mu.Unlock()
}
func (s *Subscribers) Remove(chatID int64) {
s.mu.Lock()
delete(s.chats, chatID)
s.mu.Unlock()
}
func (s *Subscribers) List() []int64 {
s.mu.RLock()
defer s.mu.RUnlock()
ids := make([]int64, 0, len(s.chats))
for id := range s.chats {
ids = append(ids, id)
}
return ids
}Тот же протокол мьютекса, что и у хранилища анкеты: замок на каждую операцию, наружу — только методы. List копирует ключи в срез: рассылка работает по копии, и одновременная отписка не рвёт цикл посреди прохода. map[int64]bool вместо среза берётся ради дешёвых Add и Remove без поиска по списку.
Пауза между отправками#
Лимиты Telegram на отправку не опубликованы как константы, но в официальном FAQ ботов назван ориентир: примерно тридцать сообщений в секунду на одного бота в разные чаты, а в один чат — не чаще одного сообщения в секунду. Практическая производная: пауза 50-100 миллисекунд между Send держит бота под порогом с запасом. В коде это time.Sleep(80 * time.Millisecond) в конце каждой итерации цикла рассылки — самой обычной, без хитростей. Двести подписчиков с паузой 80 мс — шестнадцать секунд на всю рассылку, и это нормально: уведомления не горят. Соблазн ускорить есть всегда, и расплата предсказуема: Telegram сначала шлёт 429 с полем RetryAfter — сколько секунд ждать, — а при систематическом игнорировании начинает резать скорость надолго. Про обработку этих ответов подробно написано в уроке про ошибки и лимиты, здесь же правило: пауза дешевле бана скорости.
Функция рассылки
GOfunc broadcast(bot *tgbotapi.BotAPI, subs *Subscribers, text string) {
for _, id := range subs.List() {
msg := tgbotapi.NewMessage(id, text)
_, err := bot.Send(msg)
if err != nil {
log.Println("mail:", id, err)
}
time.Sleep(80 * time.Millisecond)
}
}Цикл идёт по копии списка из List, на каждого подписчика — NewMessage с его chat_id и общей строкой текста. Ошибка отправки не прерывает рассылку: один недоступный чат не отменяет сообщения остальным. Пауза стоит после каждой отправки, включая неудачные, — счётчик скорости у Telegram не различает ваши намерения.
Ответ API с кодом 429 Too Many Requests приходит с полем retry_after — числом секунд, на которые стоит заткнуться. Игнорировать нельзя: после пары проигнорированных 429 лимиты режутся жёстче и дольше. В библиотеке ошибка приходит как *tgbotapi.Error с полем RetryAfter — пауза в цикле рассылки выставляется по нему, а не по константе.
403 Forbidden: список с мёртвыми душами#
Вторая ошибка рассылки накопительная: пользователь заблокировал бота, и каждый новый выпуск уведомлений упирается в 403 Forbidden по его адресу. Сама ошибка безобидна — сообщение не уйдёт, остальные дойдут, — но без чистки список растёт, а эффективная скорость падает: пауза-то тратится на каждого адресата, включая мёртвых. Лечение — удалять подписчика при Forbidden: ошибка отправки разбирается строкой, и встреча с Forbidden вызывает Remove. Отдельно различаются два ответа: Forbidden — пользователь вас заблокировал, chat not found — бота удалили или чат не существует; для чистки списка они равнозначны, оба означают мёртвый адрес. Восстановление возможно только одним путём: пользователь вернётся и напишет /start — точка подписки та же, что и в начале. Держать мёртвые адреса в надежде на возврат можно, но тогда честно считать их отдельно, чтобы статистика доставки не врала. Чистка при отправке стоит одну ветку if и работает без вашего участия — редкое вложение с такой отдачей, аналогичное экранированию ввода из урока про форматирование.
Чистка при отправке
GO_, err := bot.Send(msg)
if err != nil {
if strings.Contains(err.Error(), "Forbidden") ||
strings.Contains(err.Error(), "chat not found") {
log.Println("убираю:", id)
subs.Remove(id)
} else {
log.Println("ошибка:", id, err)
}
}Ошибка API приходит текстом, и Contains здесь честный способ распознать причину: Forbidden и chat not found означают мёртвый адрес, остальное — временные беды вроде сети, которые не повод удалять живого подписчика. Remove защищён мьютексом внутри Subscribers, параллельная горутина напоминаний не сломается.
Вариация: рассылка в фоне#
Шестнадцать секунд рассылки внутри цикла обновлений — шестнадцать секунд бот не отвечает никому: GetUpdatesChan ждёт, пока обработчик освободится, а пользователи видят тишину. Решение из стандартного набора Go — отправить рассылку в горутину: go broadcast(bot, subs, text), и цикл обновлений продолжает работать. Здесь приходит время дисциплины из урока про горутины: во-первых, рассылка должна быть единовременной — флаг или семафор, иначе две команды /send подряд запустят два цикла с половинной скоростью каждый; во-вторых, goroutine не должна делить мутируемое состояние без замка — Subscribers уже защищён, и это не случайность, а условие жизни фонового кода. Для редких уведомлений хватает и goroutine на вызов, для регулярных — тикер из урока про напоминания, который раз в день сам запускает вежливую отправку. Механика goroutine с WaitGroup разобрана в продвинутом курсе.
Полную скорость рассылки не проверяют на живых подписчиках: заведите десяток тестовых чатов — свой chat_id, второй телефон, друзья, — и гоняйте паузы на них. Локальный Go, реальный токен, десять адресатов и лог с временем отправки покажут и паузу, и 403, и 429 — быстрее любого чтения документации.
Что отвечает Telegram при рассылке#
| Ответ | Что значит | Что делать боту |
|---|---|---|
| 200 OK | сообщение доставлено | спать 80 мс |
| 403 Forbidden | пользователь заблокировал бота | убрать из списка |
| 400 chat not found | чат не существует | убрать из списка |
| 429 + retry_after | превышена скорость | спать по retry_after |
| таймаут сети | временный сбой | лог и продолжить |
Кейс: дайджест по пятницам#
Бот студенческого клуба копит новости в течение недели: команда /news от модератора кладёт строку в Redis-список, и в пятницу в 18:00 тикер из урока про напоминания просыпается, собирает все накопленные строки и запускает broadcast. Дайджест решает главную проблему частых рассылок — шум: пять уведомлений в день выжигают подписчиков, одно в неделю с содержанием — держит. Собранные строки склеиваются с нумерацией, сверху — заголовок с датой, и весь выпуск уходит одной рассылкой с той же паузой 80 мс. Если за неделю ничего не накопилось, честнее пропустить выпуск, чем слать «новостей нет»: пустое уведомление по статистике каналов собирает блокировки ничуть не хуже рекламы. Схема копилка-плюс-выпуск — заготовка и для напоминаний, и для отчётов, и она же объясняет, почему пауза в broadcast важнее скорости: выпусков мало, а репутация адресата одна.
Спорное мнение: рассылка — не маркетинговый канал#
Заказчики любят формулировать рассылку как «канал продвижения», и я с этим борюсь на этапе ТЗ. Бот, которому доверили chat_id, получил не рекламную базу, а канал уведомлений: это ответ на явное действие — подписку — и содержание должно соответствовать обещанию при /start. Граница, где я сдаюсь: подборка по явному интересу — пользователь попросил «присылай дайджест по Go» — это уведомление, даже если в нём есть ссылка на сайт. А вот «привет, у нас акция» списку молчаливых адресатов — путь к кнопке «заблокировать», и за ней 403, который не лечится. Практический критерий из продуктовых практик: если сообщение отвечает на вопрос «что нового по тому, о чём я просил», оно в формате уведомления; если на вопрос «что ещё продать», это спам, и Telegram с числом блокировок согласится с вами раньше, чем юрист.
Проверка: время рассылки
В списке 250 подписчиков, пауза 80 мс. Сколько займёт рассылка?
Проверка: мёртвый адрес
Send вернул 403 Forbidden для одного подписчика из списка. Что делает правильный обработчик?
Сколько сообщений уйдёт
Прочитайте код и выберите, что он выведет:
blocked := map[int64]bool{200: true, 300: true}
ids := []int64{100, 200, 300, 400}
sent := 0
for _, id := range ids {
if blocked[id] {
continue
}
sent++
}
fmt.Println(sent)Соберите цикл рассылки
Впишите источник адресов, признак блокировки и паузу.
Шесть строк тела рассылки
Расставьте тело цикла рассылки: от конструктора сообщения до паузы.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: рассылки и уведомления
Уведомления заработали в масштабе — теперь стоит заняться их качеством и расписанием: напоминания через тикер отправят персональные уведомления в заданное время, а разметка из урока про форматирование соберёт дайджест с жирными заголовками и ссылками. Хранилище подписчиков строится на карте с мьютексом и Redis из одиннадцатой части, а состояние диалога подскажет, как принять текст рассылки у администратора в два шага. Карта всех двадцати пяти уроков — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Рассылка — цикл по срезу chat_id с bot.Send и паузой 50-100 мс между отправками.
- Подписка собирается на /start, отписка на /stop; список живёт в памяти под мьютексом или в Redis множеством.
- Ориентир лимитов Telegram — около 30 сообщений в секунду; 429 с retry_after требует паузы по числу секунд.
- 403 Forbidden и chat not found означают мёртвый адрес: подписчик удаляется, рассылка продолжается.
- List отдаёт копию списка — отписка во время рассылки не рвёт цикл.
- Долгую рассылку отправляют в горутине, чтобы цикл обновлений продолжал отвечать; параллельные запуски запрещают флагом.
- Уведомление — ответ на явную подписку; реклама молчаливому списку кончается кнопкой блокировки и 403.
Домашнее задание
Добавьте локальному боту подписку: /start записывает chat_id в Subscribers, /stop удаляет, команда /announce у админа принимает текст и запускает рассылку в горутине с паузой 80 мс. Проверьте сценарии: рассылка на трёх адресах приходит всем, заблокировавший бот адрес выпадает из списка после Forbidden, и во время рассылки бот отвечает на /help без задержки.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли слать сообщения пользователям, которые не писали боту?
Нет: бот общается только с чатами, где уже есть диалог. Это фундаментальное правило Bot API — отсюда и ценность подписчиков: каждый адрес в списке значит, что человек сам нажал Start.
Как узнать точные лимиты отправки для своего бота?
Точных опубликованных чисел нет, в FAQ назван ориентир около 30 сообщений в секунду на бота. Лимиты динамические и зависят от истории бота, поэтому честный способ — двигаться с паузой и слушать 429 с retry_after.
Куда деть текст рассылки, если он приходит сообщением от админа?
Собирать в состояние диалога из урока про FSM: команда /announce переводит админа на шаг текста, следующее сообщение сохраняется и уходит в broadcast. Так текст не ломается пробелами в аргументах команды.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Мониторинг Go с Prometheus: метрики, Grafana, алерты
Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.
- Понимать четыре типа метрик и выбирать правильный
- Экспортировать метрики из Go через promhttp
- Инструментировать HTTP-запросы и бизнес-события
- Писать PromQL-запросы: rate, sum, p99