Рассылки
Описание
Лидер (состояние C) может разослать сообщение своей структуре в боте:
пошаговый мастер на инлайн-кнопках (BroadcastUpdate) собирает сегмент и
контент, а фактическую доставку (мгновенно или по расписанию) выполняет
BroadcastSenderService. Рассылка всегда скоупится по структуре лидера —
это единственный источник аудитории (глобальная рассылка недоступна из
консоли).
Код: apps/onboarding-bot/src/broadcast/broadcast.update.ts (мастер),
apps/onboarding-bot/src/broadcast/broadcast-sender.service.ts
(отправитель+cron), apps/onboarding-bot/src/admin/admin.service.ts
(segmentForLeader).
Мастер (4 шага, состояние в ctx.session.bc)
Состояние мастера живёт только в telegraf-сессии (session() middleware,
глобально подключена в app.module.ts), в БД (bot_broadcasts) пишется
только на финальном шаге (отправка/планирование) — незавершённые мастера не
плодят черновики.
-
Сегмент (
bc:new/bc:restart→bc:seg:<kind>):Пресет Callback BroadcastSegmentВся структура bc:seg:all{}(только скоуп структуры)Смотрели презентацию bc:seg:preso{ funnelStageMin: 3, funnelStageMax: 5 }Зарегистрированы без NFT bc:seg:nonft{ funnelStageMin: 8, funnelStageMax: 8 }Неактивные (порог 3/7/14/30 дней) bc:seg:inactive:<N>{ inactiveDays: N }Достижимо и не только из
bc:new— карточка партнёра (кнопка «📣 Написать сегменту») ведёт наbc:seg:preso, аналитика воронки («📣 Разбудить») — наbc:seg:inactive; сессия мастера инициализируется на лету, даже если мастер формально не был открыт черезbc:new.После выбора бот считает размер аудитории через
AdminService.segmentForLeader(leaderUid, segment).lengthи показывает «📊 В сегменте: N чел.» на шаге 2. -
Контент (
step: 'await_content', обработчик@On('message')): одно сообщение — текст, либо фото/видео с подписью (caption). Хендлер активен только при этом шаге сессии; во всех прочих случаях сообщение пробрасываетсяnext()дальше по цепочке апдейтов — иначе мастер перехватывал бы вообще все текстовые сообщения в боте, включая persistent reply-кнопки. Тап по «☰ Меню»/«❓ Помощь» внутри контент-шага отменяет мастер (session.bc = undefined) и тоже пробрасываетсяnext(), чтобыMenuUpdate(@Hears) отрендерил соответствующий экран — своего рендера меню вBroadcastUpdateнет намеренно (он в другом@Update). -
Предпросмотр (
bc:preview): бот шлёт себе рассылку ровно в том виде, в котором она уйдёт (тем же методом —replyWithPhoto/replyWithVideoсcaptionили обычный текст), отдельным сообщением, ниже — панель действий (Отправить сейчас / Запланировать / Изменить текст / Другой сегмент / Отмена). -
Отправка/расписание:
bc:send→ подтверждение →bc:confirmсоздаётBotBroadcast(status: Scheduled,scheduledAt: now,ownerUid: leaderUid) и сразу вызываетsender.sendBroadcast(id)— итог показывается синхронно («✅ Готово · 📤 Доставлено N · ⚠️ Не доставлено M»).bc:schedule→ пресет времени (bc:sched:1h/bc:sched:tmrw10) → создаётBotBroadcastсоscheduledAtв будущем, не отправляет сразу — подбирает@Cron-sweep()отправителя.- Оба пути защищены от двойного тапа:
ctx.session.bcзахватывается в локальную переменную и очищается синхронно первой строкой, до любогоawait— повторный колбэк того же действия (пока первый ещё летит —sendBroadcastне мгновенен из-за throttle) увидитsession.bc === undefinedи выйдет no-op'ом, не создавая вторую записьBotBroadcast.
Отправитель (BroadcastSenderService)
sendBroadcast(broadcastId) — обслуживает и мгновенную отправку из мастера,
и подхват @Cron-подборщиком:
- Терминальный статус (
Done/Cancelled) — no-op, возвращает текущиеstats. - Помечает
status = Sending,lockedUntil = now + 5 мин(лиз для resume, если процесс упадёт посреди рассылки). - Аудитория =
AdminService.segmentForLeader(broadcast.ownerUid, segment)∩optinMarketing === true(маркетинговая категория — единственная, которую фильтрует мастер; срочные/дайджест/отчётные категории — только фоновые процессы, не мастер). - Resume/дедуп: уже доставленным (
bot_broadcast_recipientsсоstatus: Sent) сообщение повторно не шлётся —pending = audience \ alreadySent. - Цикл строго последовательный (не параллельный) с паузой
THROTTLE_MS = 35мс между получателями (Telegram лимит ~30 msg/s глобально на бота). Один получатель с неожиданной ошибкой не роняет всю рассылку — фиксируетсяfailedв леджере, цикл продолжается. total = sent + failed(неaudience.length) — держит инвариантsent ≤ totalдаже если получатель, ранее записанный какsent, выпал из текущей аудитории между крашем и resume (отписался/ушёл из структуры).- По завершении:
stats = { sent, failed, total },status = Done,lockedUntil = null.
@Cron-подборщик (sweep, EVERY_MINUTE)
Single-flight через RedlockService (ключ onboarding:broadcast, TTL 5
мин). Подбирает:
status = ScheduledиscheduledAt <= now(запланированные, время пришло);status = Sendingи (lockedUntil IS NULLилиlockedUntil < now) (зависшие после краша процесса — лиз истёк).
Каждую вызывает sendBroadcast(); одна упавшая (неожиданное исключение)
помечается status = Failed, lockedUntil = null и не блокирует остальные.
Гарантия доставки — at-least-once, не exactly-once
Между ACK от Telegram (deliver() резолвится) и записью в
bot_broadcast_recipients (recordRecipient) есть окно: краш процесса ровно
в этот момент оставит получателя без записанного sent, и resume отправит
ему то же сообщение повторно — максимум один дубль на один краш. Это
осознанный компромисс: редкий дубль сообщения предпочтительнее потери
получателя вовсе. Полностью убрать окно нельзя без идемпотентной доставки на
стороне Telegram; pre-insert pending-строки перед отправкой переносит то же
окно на сам INSERT и добавляет риск залипания записи в pending навсегда
— решение осознанно не делать так задокументировано в коде.
Rate-limit и медиа
NotifierService.sendMessage/sendMedia — тонкая обёртка отправки: при
Telegram 429 ретраит с уважением retry_after (до MAX_ATTEMPTS = 3
попыток, буфер +250мс), при исчерпании лимита или 403 (нет активного
диалога с ботом) возвращает false, не бросая исключение — вызывающий код
трактует это как failed, не как краш. Медиа-рассылки: тип медиа
персистится на BotBroadcast.mediaType ('photo'|'video', колонка
media_type, миграция 1720100000000-broadcast-media-type) — раньше
отправитель хардкодил sendPhoto, теперь video идёт через sendVideo.
Список и отмена (bc:list, bc:cancel:<id>)
bc:list — последние 20 рассылок текущего createdBy (не всей структуры —
только созданные этим лидером), статус-бейджи:
| Статус | Отображение |
|---|---|
done | ✅ Готово · «текст…» · 📤 sent/⚠️ failed |
scheduled | 🕐 Запланирована «дата» + кнопка «✖️ Отменить» |
sending | 📤 Отправляется |
cancelled | ✖️ Отменена |
failed | ⚠️ Ошибка |
draft | 📝 Черновик |
bc:cancel:<id> переводит Scheduled → Cancelled (только если рассылка
принадлежит текущему createdBy и ещё не подхвачена sweep'ом).
Скоуп: segmentForLeader vs глобальный segment
AdminService содержит два метода сегментации:
segmentForLeader(leaderUid, filter)— И-скоуп по структуре (where: [{ leaderUid, ...filter }, { magnetParentUid: leaderUid, ...filter }]). Единственный метод, которым пользуется мастер/отправитель лидерской консоли — критично для изоляции: без этого скоупа рассылка ушла бы всей базеbot_users.segment(filter)— глобальный, без скоупа. Используется только скрытыми/admin_*командами (AdminUpdate, гейт поADMIN_TELEGRAM_IDS) для операционной диагностики (/admin_segment,/admin_funnel) — не выставлен ни в одну пользовательскую (лидерскую) кнопку.
AdminService.createBroadcast/runBroadcast (глобальные, без per-recipient
леджера/rate-limit/resume) — более ранняя реализация, вытесненная
BroadcastUpdate + BroadcastSenderService; код оставлен, но ни один
пользовательский путь его не вызывает (см.
«Из вестные ограничения»).