Перейти к основному содержимому

Рассылки

Описание

Лидер (состояние 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) пишется только на финальном шаге (отправка/планирование) — незавершённые мастера не плодят черновики.

  1. Сегмент (bc:new/bc:restartbc:seg:<kind>):

    ПресетCallbackBroadcastSegment
    Вся структураbc:seg:all{} (только скоуп структуры)
    Смотрели презентациюbc:seg:preso{ funnelStageMin: 3, funnelStageMax: 5 }
    Зарегистрированы без NFTbc: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.

  2. Контент (step: 'await_content', обработчик @On('message')): одно сообщение — текст, либо фото/видео с подписью (caption). Хендлер активен только при этом шаге сессии; во всех прочих случаях сообщение пробрасывается next() дальше по цепочке апдейтов — иначе мастер перехватывал бы вообще все текстовые сообщения в боте, включая persistent reply-кнопки. Тап по «☰ Меню»/«❓ Помощь» внутри контент-шага отменяет мастер (session.bc = undefined) и тоже пробрасывается next(), чтобы MenuUpdate (@Hears) отрендерил соответствующий экран — своего рендера меню в BroadcastUpdate нет намеренно (он в другом @Update).

  3. Предпросмотр (bc:preview): бот шлёт себе рассылку ровно в том виде, в котором она уйдёт (тем же методом — replyWithPhoto/replyWithVideo с caption или обычный текст), отдельным сообщением, ниже — панель действий (Отправить сейчас / Запланировать / Изменить текст / Другой сегмент / Отмена).

  4. Отправка/расписание:

    • 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-подборщиком:

  1. Терминальный статус (Done/Cancelled) — no-op, возвращает текущие stats.
  2. Помечает status = Sending, lockedUntil = now + 5 мин (лиз для resume, если процесс упадёт посреди рассылки).
  3. Аудитория = AdminService.segmentForLeader(broadcast.ownerUid, segment)optinMarketing === true (маркетинговая категория — единственная, которую фильтрует мастер; срочные/дайджест/отчётные категории — только фоновые процессы, не мастер).
  4. Resume/дедуп: уже доставленным (bot_broadcast_recipients со status: Sent) сообщение повторно не шлётся — pending = audience \ alreadySent.
  5. Цикл строго последовательный (не параллельный) с паузой THROTTLE_MS = 35 мс между получателями (Telegram лимит ~30 msg/s глобально на бота). Один получатель с неожиданной ошибкой не роняет всю рассылку — фиксируется failed в леджере, цикл продолжается.
  6. total = sent + failed (не audience.length) — держит инвариант sent ≤ total даже если получатель, ранее записанный как sent, выпал из текущей аудитории между крашем и resume (отписался/ушёл из структуры).
  7. По завершении: 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; код оставлен, но ни один пользовательский путь его не вызывает (см. «Известные ограничения»).