Skip to main content
Кампания — это один шаблон сообщения, отправляемый одному сегменту аудитории по расписанию, со строкой на каждого получателя, которая фиксирует, что с ним произошло. Она живёт по адресу /admin/marketing, для открытия которого нужно право marketing.read. Экран построен вокруг момента перед отправкой. Кампания создаётся черновиком, и оценка её аудитории видна сразу, планирование — отдельное второе действие, а определение сегмента выводится словами рядом с числом, потому что ошибка, ради предотвращения которой существует эта страница, — отправить правильное сообщение не тем людям, и никто не заметит этого, читая код сегмента.
Несколько частей этого раздела сегодня доступны только через API. Сегменты, шаблоны и записи списка подавления можно прочитать в /admin/marketing, но создаются они запросами к /v1/panel, а не через форму. Каждый раздел ниже говорит, как обстоит дело у него.

Сегмент — это фильтры, а не сохранённый SQL

Сегмент аудитории — это именованный набор фильтров, хранимый как JSON и компилируемый в параметризованный SQL в момент чтения. Он намеренно не является сохранённым фрагментом запроса, и на то есть две причины, каждая из которых перевешивает гибкость, которую дал бы такой фрагмент. Оператор должен иметь возможность прочитать, кого отбирает сегмент, до того как что-то ему отправить, а аудитор — проверить постфактум, кого он отобрал. Ни то, ни другое невозможно, если определение — это запрос, который кто-то однажды написал и который теперь никто не может истолковать. К тому же сохранённый фрагмент — это поверхность для инъекций с приложенным журналом аудита. Здесь каждое значение попадает в SQL параметром, а словарь закрыт, поэтому сегмент не может выразить ничего, чего словарь не допускает, — в том числе прочитать таблицу, читать которую ему незачем.

Словарь

Ключ вне этого списка отклоняется при записи с unknown audience filter, а значение, которое фильтр использовать не может, — с invalid audience filter value; и то и другое приходит как 422. Отклонение при записи, а не в момент отправки, сделано намеренно: молчаливо отброшенный фильтр отправил бы сообщение более широкой аудитории, чем просил оператор, а узнать об этом в момент начала отправки — значит узнать слишком поздно. Числа, приходящие в JSON как дробные, должны быть целыми. expiringWithinDays: 7.5 — это отказ, а не округление.

Он объясняет себя сам

Каждый сегмент несёт сгенерированное объяснение того, кого он отбирает. Оно генерируется из фильтров, а не пишется оператором, поэтому не может описать то, чего запрос не делает; панель выводит его маркированным списком рядом с текущим размером. Фильтры компилируются в отсортированном порядке ключей независимо от того, как был записан JSON-объект, поэтому одно и то же определение всегда даёт один и тот же SQL и одно и то же объяснение. Сегмент, чьё отображение меняется между проверками, — это сегмент, который никто не может утвердить.
Пустой набор фильтров — это все клиенты. Он компилируется в true и объясняет себя как «все клиенты». Это законный случай — уведомление об аварии адресовано всем, — но он назван словами, а не выглядит как пустое определение, чтобы никто не разослал письмо всей базе, просто забыв добавить фильтр.
И подсчёт, и отправка ограничены users.status = 'active' ещё до применения любого фильтра, поэтому фильтр status способен лишь сузить эту выборку. Сегмент, построенный из status: deleted, не отбирает никого.

Как сохранить сегмент

Формы нет. Отправьте:
code соответствует ^[a-z][a-z0-9_-]{1,63}$, названия — от 1 до 80 символов. Для сохранения нужно marketing.write, и оно пишет запись аудита, panel.segment.saved, несущую весь объект filters, — потому что «кто изменил то, что кампания понимала под ушедшими клиентами» — это ровно тот вопрос, ради которого журнал аудита и существует.

Шаблоны объявляют свои переменные сами

Шаблон сообщения — это пара двуязычных темы и тела с объявленным списком переменных и классом. Тело или тема, ссылающиеся на {{ variable }}, которой нет в объявленном списке, отклоняются при записи. Альтернатива — клиент, получивший Hello {{name}}, потому что никто не проверил, и к этому моменту письмо ушло всем. Оба языка обязательны по той же причине, по которой шаблоны ответов поддержки требуют обоих: язык клиента — не язык оператора. Тела отправляются в режиме разметки HTML Telegram, поэтому к ним применяется то же правило разметки, что и к переформулированным уведомлениям: только теги, которые принимает Telegram (b, strong, i, em, u, s, strike, del, code, pre и a со ссылкой https), каждый тег закрыт, а буквальный < записывается как &lt;. Всё остальное отклоняется при записи с кодом 422. Раньше Скидка <50% принималось, а затем Telegram отклонял его для каждого получателя кампании. Темы экранируются перед отправкой и могут содержать что угодно. Если Telegram всё же отклоняет сообщение, отказ записывается один раз как failed и не повторяется — тот же текст получит тот же ответ — и не засчитывается в состояние доставки клиента, потому что с его чатом всё в порядке. Создание доступно только через API: PUT /v1/panel/marketing/templates с {code, class, subjectEn, subjectRu, bodyEn, bodyRu, variables}, требует marketing.write. Тела — от 1 до 3500 символов, темы могут быть пустыми и ограничены 200 символами, class по умолчанию marketing.
Объявленная переменная превращается в ничто. Доставка кампании подставляет пустую карту значений, а затем вырезает любой уцелевший плейсхолдер, и ни одна часть модели кампании не хранит значения по получателям. Тело, написанное как Hello {{name}}, уходит как Hello и ничего вместо плейсхолдера. Пишите шаблоны без переменных, пока не появятся значения, которыми их заполнять.Шаблон класса transactional доставить нельзя. При доставке сообщение классифицируется передачей строки класса шаблона в классификатор уведомлений, который знает виды уведомлений; marketing оказывается одним из них, а transactional — нет. Транзакционный шаблон приводит к тому, что нотификатор пишет в лог «campaign classification failed» и оставляет каждого получателя в queued, что заодно не даёт кампании когда-либо завершиться. Используйте marketing — значение по умолчанию.

Сборка и планирование рассылки

1

Откройте карточку кампаний

/admin/marketing. С правом marketing.write над таблицей кампаний появляется пунктирный конструктор.
2

Назовите её, выберите сегмент, выберите шаблон

В выпадающем списке сегментов показано английское название каждого сегмента и его текущий размер. При выборе появляется блок с предпросмотром того, кого это охватит, — собственными сгенерированными словами сегмента, ещё до того как что-либо создано.
3

Создайте черновик

POST /v1/panel/marketing/campaigns с {name, segmentId, templateId}. Аудитория считается сейчас и сохраняется как estimatedAudience. Ничего не ставится в очередь — черновик, уже поставивший в очередь своих получателей, превратил бы проверку в формальность. Оценка считается тем же скомпилированным фрагментом, который обойдёт отправка, поэтому то, что утверждает оператор, и то, что получит сообщение, не могут происходить из разных определений.
4

Отправьте себе предпросмотр

Preview EN и Preview RU ставят в очередь по одной копии сообщения для операторской группы, отрисованной ровно так, как её получит клиент. Больше её никто не получает, и в кампании ничего не меняется. См. Предпросмотр кампании.
5

Запланируйте её

Нажмите Schedule — откроется выбор даты и времени: календарь для дня, затем часы и минуты двумя списками. Прошедшие дни не предлагаются, а кнопка остаётся заблокированной, пока момент не выбран. Это отправляет {"state": "scheduled", "scheduledFor": "<ISO 8601>"} на /v1/panel/marketing/campaigns/{campaignID}/state. Планирование без даты — это 422: «отправить немедленно» и «кто-то забыл дату» не должны быть одним и тем же запросом.
6

Наблюдайте и остановите, если что-то не так

В назначенное время воркер переводит кампанию в running и разворачивает аудиторию. Pause или Cancel — из того же элемента управления.

Предпросмотр кампании

POST /v1/panel/marketing/campaigns/{campaignID}/test с {"locale": "en"} или {"locale": "ru"} ставит в очередь одну копию сообщения кампании для операторской группы. Требуется marketing.write: чтение кампании не должно уметь что-либо отправлять в чат. Приходит собственное сообщение кампании, отрисованное тем же кодом, что и настоящая отправка: переменные шаблона превращаются в ничто, потому что именно это получает клиент, — предпросмотр с подставленными примерами был бы предпросмотром текста, которого никто не увидит. Он попадает в отдельную тему форума, 📣 Campaign previews, под заголовком, называющим кампанию, язык и то, что больше его никто не получил. Две вещи он намеренно не трогает:
  • Получателей. Запись живёт в отдельной таблице campaign_test_sends. Все счётчики рядом с кампанией — в очереди, отправлено, ошибок, подавлено — выводятся из campaign_recipients, а разворачивание аудитории пропускает всех, у кого там уже есть строка. Предпросмотр, записанный как получатель, сдвинул бы числа, по которым оператор вот-вот будет судить о кампании, и убрал бы настоящего клиента из отправки. Ни то, ни другое не откатить после того, как кампания прошла.
  • Саму кампанию. Состояние не меняется, счётчики не двигаются, а запись аудита — panel.campaign.tested, а не что-то похожее на отправку.
Для кампании в состоянии completed или cancelled запрос отклоняется. Предпросмотр нужен, пока решение ещё открыто, а текст в операторской группе для кампании, которая больше не запустится, читается так, будто она может запуститься. Доставку выполняет бот, поэтому панель сообщает, что предпросмотр поставлен в очередь. Он приходит на следующем проходе уведомителя, примерно в течение десяти секунд. Предпросмотр, который не удалось доставить — не настроена операторская группа, у бота нет права создавать темы, — записывается как failed с категорией и не повторяется: попросить ещё один — это один клик, а цикл повторов против чата, который отказывает, наполняет очередь, а не чат.
Нужна операторская группа. Если она не настроена, уведомитель бота не запускается и предпросмотры остаются в состоянии queued. О привязке группы — в Telegram.

Разрешённые переходы

Конечный автомат реализован в самом запросе, а не в вызывающем коде: обновление несёт AND status = ANY(allowed_from), поэтому запрещённый переход не находит ни одной строки и API отвечает 404, а не ошибкой валидации. Завершённая кампания не может вернуться в работу, а отменённую нельзя воскресить с другой аудиторией. Работающую кампанию нельзя отменить напрямую — сначала поставьте её на паузу.
Панель предлагает Pause для запланированной кампании, но paused достижим только из running, поэтому такая кнопка отвечает 404. Вместо этого отмените кампанию или перепланируйте её на более позднее время. Учтите также, что любой переход, кроме планирования, очищает scheduled_for, поэтому кампании, которую поставили на паузу и возобновили, дату нужно задать заново.

Что делает воркер

Обработчик кампаний в процессе воркера срабатывает раз в минуту и берёт до 20 подошедших кампаний — тех, что в scheduled со временем в прошлом, плюс всё, что уже running.
  • Кампания в scheduled переводится в running, что один раз проставляет started_at.
  • Сегмент перекомпилируется на каждом проходе. Сегмент, который больше не компилируется, ставит кампанию на паузу, вместо того чтобы запускать её по аудитории, которую никто не может прочитать.
  • За проход вставляется до 5000 строк получателей. Дедупликацией служит составной первичный ключ строки получателя, поэтому кампания, поставленная на паузу и возобновлённая, продолжается, а не начинается заново, и клиент не может попасть в очередь дважды.
  • Клиент из списка подавления или без Telegram-идентичности, на которую бот мог бы написать, сразу при постановке в очередь получает исход suppressed с причиной suppressed или no_telegram. Раньше клиент без Telegram-идентичности ставился в очередь для бота, который его никогда не читал, поэтому строка оставалась в queued, и кампания не завершалась.
  • Счётчики после каждого прохода пересчитываются из строк получателей, а не инкрементируются, поэтому они не могут разойтись с реальностью.
  • Когда проход не ставит в очередь никого нового и ничего не осталось в queued, кампания помечается как completed.
Разворачивание аудитории раньше ломалось для любого сегмента, несущего связанное значение, — то есть почти для всех. Запрос передавал идентификатор кампании как $1 и добавлял собственные аргументы сегмента после него, тогда как скомпилированный фильтр тоже нумерует плейсхолдеры с $1; они сталкивались, PostgreSQL отклонял привязку, проход писал в лог «campaign expansion failed» и не ставил в очередь никого — из панели это неотличимо от сегмента, которому никто не соответствует. Теперь аргументы сегмента идут первыми, а идентификатор кампании — последним. Интеграционный тест разворачивает сегмент по locale и проверяет, что в очередь попал именно тот клиент, которого сегмент называет.

Доставка и кого пропускают

Воркер владеет аудиторией. Доставкой владеет нотификатор бота, потому что согласие, тихие часы, ограничение частоты и состояние доставки в Telegram уже живут там — дублирование означало бы два места, которые могут разойтись во мнении, можно ли писать клиенту. Он вычерпывает получателей в queued у работающих кампаний по 200 за раз, читая следующую пачку сразу после отправки предыдущей и заглядывая снова каждые 30 секунд, когда очередь пуста, и записывает исход по каждому получателю. Темп задаёт общий для бота APP_TELEGRAM_DELIVERY_RATE, по умолчанию 25 сообщений в секунду; раньше это было 200 за пятнадцатиминутный проход, около 800 в час. Флуд-ожидание Telegram оставляет остаток пачки в очереди до следующей, а не помечает его неудачным. Эти 200 делятся между работающими кампаниями: каждая отдаёт своих самых старых получателей, и проход берёт первого из каждой кампании, затем второго и так далее. Кампания с большим хвостом не может задержать маленькую, как это делала одна очередь, упорядоченная по времени постановки. Читается каждый получатель в queued, доступен он или нет, поэтому ни один не может застрять в queued навсегда: клиент без Telegram-идентичности, клиент, чей аккаунт приостановили или удалили после разворачивания аудитории, и клиент, чей чат заведомо не принимает сообщения, получают исход с причиной, а шаблон с классом, которого не знает политика доставки, проваливает получателя с classification_failed вместо того, чтобы оставить его в очереди. Получатель, которому политика отказала, записывается как подавленный с причиной, а не отбрасывается молча: Кампания записывает отказ по тихим часам, а не придерживает сообщение до утра. Маркетинговая рассылка недостаточно срочна, чтобы приходить с опозданием в пачке, которая уже не соответствует тому, ради чего оператор её писал. Тема, если она у шаблона есть, добавляется к телу сверху жирным шрифтом. Отрендеренные тела никогда не хранятся: историей служат шаблон плюс запись о доставке, а копирование тела в таблицу кампаний продублировало бы обращённое к клиенту содержимое во второй режим хранения без всякой операционной пользы. Каждый получатель проверяется по маркетинговому переключателю клиента независимо от класса шаблона, а запрос, фиксирующий исход, находит только строку, всё ещё находящуюся в queued, поэтому повторная доставка не может перезаписать уже записанный исход. Правила классификации, арифметику тихих часов и сами настройки ограничения частоты см. в разделе Новости и общение с клиентами.

Как читать результат

В таблице кампаний по каждой кампании показаны: название, под ним segmentCode · templateCode, бейдж статуса со временем запланированной отправки, оценка аудитории и тройка результата — отправлено / не удалось / подавлено. Подавленные отчитываются рядом с отправленными, а не сворачиваются в них, потому что «мы намеренно не написали этим людям» — факт иного рода, нежели сбой. Оператору, оценивающему охват, нужно видеть, что четыреста человек были в списке и не получили сообщения, и почему. Кампания, которая просто показала бы меньшую аудиторию, скрыла бы это решение, а меньшая аудитория без объяснения неотличима от сломанной отправки. Поэтому разрыв между estimatedAudience и queued + sent + failed + suppressed — это честная мера того, насколько далеко продвинулось разворачивание, а не расхождение.

Список подавления

communication_suppressions хранит по одной строке на клиента с причиной из закрытого набора — customer_request, bounced, complaint, operator, — необязательной заметкой и тем, кто её добавил. Любая другая причина — 422. Это намеренно не флаг маркетингового согласия. Согласие — это настройка, которую клиент переключает; подавление — это постоянное указание, которое переживает случайное обратное переключение настройки. /admin/marketing → Suppressions показывает идентификатор клиента, причину, заметку и кнопку Remove для всех, у кого есть marketing.write. Добавление возможно только через API: PUT /v1/panel/marketing/suppressions с {customerId, reason, note}, с upsert по клиенту, поэтому повторное подавление перезаписывает причину и заметку. Обе операции пишут записи аудита, panel.suppression.added и panel.suppression.removed. Клиент, отписавшийся на /account/preferences, пишет собственную строку с причиной customer_request, и повторное согласие позже удаляет только эту причину. Подавление, записанное из-за возврата письма или жалобы, — это чужой вывод о доставляемости, и галочка, поставленная клиентом, его не отменяет. Экран настроек клиента сообщает о действующем подавлении с его причиной и датой, но не даёт никакого элемента управления, чтобы его снять. Колонки срока действия нет, поэтому подавление стоит до тех пор, пока его не снимет оператор.

Права доступа

Каждая запись здесь аудируется: panel.segment.saved, panel.template.saved, panel.campaign.created, panel.campaign.<state>, panel.suppression.added и panel.suppression.removed.

Смежные страницы

Новости и общение с клиентами

Входящие с объявлениями, классы уведомлений, согласие, тихие часы и ограничение частоты.

Тексты сообщений

Транзакционные уведомления: они переписываются в другом месте и по другим причинам.

Уведомления для клиента

Что клиент видит и чем управляет на своей стороне.

Панель администратора

Сессии, роли и всё остальное в /admin.

Задания и очереди

Циклы воркера, которые разворачивают кампании и публикуют запланированные посты.