commerce.EvaluateDelivery, и незнакомый вид она отклоняет, а не
трактует по умолчанию как «отправить». Поэтому добавить уведомление, не решив,
транзакционное оно или маркетинговое, невозможно: новый вид просто никогда не
будет доставлен, а это видимый сбой, а не тихое разглашение. Доставка
выполняется в процессе bot тремя циклами. Проход жизненного цикла — уведомления
о неудачных списаниях и уведомления жизненного цикла каждого клиента — идёт раз
в 15 минут и обходит всех достижимых клиентов в порядке идентификаторов, по
200 за страницу, пока последняя страница не окажется короткой, — поэтому клиент,
чей идентификатор сортируется последним, получает предупреждение об истечении в
том же проходе, что и первый. У получателей рассылок и объявлений свой цикл: он
читает по 200 тех и других, отправляет их и, пока есть очередь, сразу читает
следующую пачку, а когда очереди нет — заглядывает снова каждые 30 секунд. У
ответов поддержки — свой 30-секундный цикл.
Каждое сообщение бота расходует один общий бюджет —
APP_TELEGRAM_DELIVERY_RATE сообщений в секунду (25 по умолчанию; потолок
Telegram для одного бота — около 30). Объём одной секунды может уйти сразу,
дальше темп держится. Если Telegram всё же отвечает 429, его retry_after
приостанавливает все отправки, потому что это ограничение бота, а не
получателя: паузу до 30 секунд бот выжидает, более длинная завершает текущий
проход, а попавшие под неё сообщения откладываются до её конца, а не помечаются
неудачными. 429 больше не записывается на клиента, которому случилось быть
получателем.
Два класса
Уведомление бывает либо транзакционным — факт о том, что клиент купил или о чём попросил, — либо маркетинговым, и тогда оно требует явного и отзываемого согласия. Классификация — это закрытая таблица в коде, а не выбор, который делает для каждого сообщения тот, кто его написал.
Четыре вида дополнительно считаются срочными и никогда не задерживаются
тихими часами:
payment, fulfillment, support и incident. Клиенту, который
находится в середине оплаты или прямо сейчас затронут аварией, не поможет
сообщение, пришедшее в восемь утра следующего дня.
news — маркетинговый вид. Пост, опубликованный в категории news, поэтому
требует маркетингового согласия и расходует ограничение частоты, а посты
announcement, incident и maintenance — нет. Это важно, когда оператор
пишет уведомление об аварии: публикуйте его как incident или announcement,
а не как news, иначе push получат только те клиенты, кто согласился на
предложения.Что клиент получает на самом деле
У подписки ровно один отсчёт до окончания. У проданной Omniflow есть право на
услугу, и напоминание
renewal считается от его окончания и может предложить
продление; у унаследованной подписки, импортированной до появления продаж, есть
только срок из Remnawave, и она получает вместо этого напоминание expiry. Оба
сразу не отправляются никогда: они считают одни и те же дни двумя способами и
раньше приходили двумя сообщениями об одном событии — до восьми за последнюю
неделю.
Оповещения привязаны к конкретной подписке, поэтому активная подписка не может
заглушить уведомление об окончании тихой. У каждого свой ключ дедупликации —
уведомление об окончании ключуется по дате окончания и числу дней,
предупреждение о трафике — по порогу и лимиту, — и именно это не даёт
напоминанию повторяться на каждом проходе целую неделю.
Два пути намеренно обходят механику политик:
- Ответы поддержки отправляются собственным 30-секундным циклом без проверки
согласия, тихих часов и лимита частоты — ответ клиент сам попросил. Строку
доставки вида
supportони оставляют, поэтому история ниже показывает каждый ответ как доставленный, ожидающий или придержанный с причиной (bot_blocked,no_telegram); само сообщение в любом случае можно прочитать на/account/support. - Уведомления о неудачном автосписании (dunning) отправляются независимо от настроек и тихих часов. Клиенту, который вот-вот потеряет доступ из-за отклонённой карты, не поможет придержать сообщение до утра, и в модели настроек для него нет отключения.
Как задать настройки
Обе поверхности опираются на одну и ту же строкуbot_preferences, поэтому
выбор, сделанный в одной, немедленно действует и в другой. Обе пишут в одни и те
же четыре переключателя, одно и то же поле языка и одно и то же тихое окно.
В браузере
Откройте/account/preferences — переход из Аккаунт → Уведомления в
центре на /account/profile.
1
Выберите, что мы отправляем
Четыре переключателя, каждый включён по умолчанию: Окончание подписки,
Предупреждения о трафике, Продления и платежи и Объявления.
Переключение одного отправляет
PATCH только с этим полем.2
Выберите язык сообщений
Как в аккаунте, Русский или Английский. Это язык, на котором вам
пишет бот, а не язык панели — собственный язык панели задаётся на
/account/profile.3
Задайте тихие часы
Главный переключатель включает окно по умолчанию 23:00–08:00; два списка
затем сдвигают час начала и час окончания. Выключение переключателя
отправляет
{"startHour": 0, "endHour": 0}, а не опускает поле, потому что
опустить поле означает «оставить окно как есть», то есть противоположное
тому, о чём попросили.4
Решите насчёт предложений
Переключатель Предложения и акции — это маркетинговое согласие. Под ним
указаны дата, поверхность и версия условий того решения, которое действует
сейчас.
/account/privacy.
Каждое поле обновления на сервере — указатель, и именно это делает запрос
настоящим PATCH: отсутствующее поле остаётся нетронутым, поэтому экран,
рисующий один переключатель, не может молча переписать те, которых он никогда не
показывал. Ответ — это полностью перечитанный документ, поэтому панель целиком
заменяет свой кэш вместо ревалидации.
В Telegram
Отправьте/settings. На экране есть строка языка, те же четыре переключателя,
маркетинговый переключатель и пункт тихие часы, открывающий подменю из трёх
пресетов: 22:00–08:00, 23:00–09:00 и «выключено». Пресеты — это явный список, а
не поле для произвольного ввода времени, поэтому здесь нет разбора времени, в
котором можно ошибиться, а данные обратного вызова остаются закрытым множеством.
Когда настроено ограничение частоты маркетинговых сообщений, экран настроек в
Telegram его сообщает. Веб-экран — нет.
Какой переключатель управляет какими сообщениями
Последняя строка — суть всей модели. Эти виды являются следствием собственных
действий клиента, и модель настроек, позволяющая их отключить, позволила бы
кому-то тихо потерять доступ.
У клиента, который ни разу не открывал экран настроек, строки настроек нет
вовсе; чтение падает на значения столбцов по умолчанию — все четыре
переключателя включены, маркетинг выключен, язык
auto.
Тихие часы
Тихое окно — это окно по местному времени, в течение которого несрочные сообщения ждут, а не отбрасываются. Окно, конец которого не позже начала, переходит через полночь, поэтому 23:00–08:00 — это одно окно, а не два. Равные часы означают отсутствие окна, и обе поверхности используют это соглашение, чтобы его снять. Когда сообщение попадает внутрь окна, строка доставки помечается какdeferred с
моментом окончания окна. Следующий проход заново забирает отложенную строку, чей
момент уже наступил, — именно это превращает отсрочку в задержку, а не в потерю.
Окно вычисляется в
users.timezone, значение по умолчанию — UTC. Его задают
обе поверхности: веб-экран настроек предлагает все зоны IANA, которые знает
браузер, а кнопка бота 🌍 Часовой пояс — на экране настроек и на экране
тихих часов — предлагает список распространённых зон с их текущим смещением.
Бот называет зону рядом с окном везде, где его показывает, а клиенту, который
всё ещё на значении по умолчанию, сообщает, что тихие часы считаются по UTC.
Если язык его клиента Telegram подсказывает зону — русский подсказывает
Москву, украинский Киев, казахский Алматы и так далее, — выбор сначала
спрашивает о ней, вопросом с кнопкой ✅ Да; сам бот зону никогда не
выставляет.Маркетинговое согласие и что делает отписка
Маркетинговое согласие — это не столбец, который кто-то поддерживает в актуальном состоянии. Каждое решение добавляет строку вconsent_records с
целью, флагом согласия, версией условий, поверхностью и идентификатором запроса,
который его принёс, а текущий ответ — это самая новая строка. История, которую
можно переписать, ничего не доказывает, и «вы согласились» — не самое полезное,
что можно сказать человеку, который этого не помнит, тогда как «вы согласились в
такую-то дату, из такого-то интерфейса, на таких-то условиях» — полезно.
Изменение настройки добавляет строку только тогда, когда значение действительно
изменилось. Отписка добавляет строку безусловно, потому что «он и так уже был
отписан» — не повод оставить его отписку без следа.
Отписка
/account/preferences → Отписаться от всего рекламного → подтвердить.
Маршрут вообще не принимает тела: элемент отписки, требующий правильно
заполнить форму, — это элемент, который может не сработать для человека, уже
принявшего решение.
В одной транзакции он выключает маркетинговое согласие, выключает переключатель
объявлений, снимает флаг рекламы со всех живых контактных каналов, добавляет
запись об отзыве согласия и пишет строку блокировки отправки с причиной
customer_request.
Он намеренно не трогает окончание, трафик и продление. Они несут факты, которые
клиенту нужны независимо от того, хочет ли он слышать бизнес, — что его подписка
заканчивается в четверг, что продление не прошло, — и отписка, которая заглушила
бы их, превратила бы маркетинговую настройку в способ потерять доступ без
предупреждения.
Блокировка отправки
Блокировка отправки — это не флаг согласия. Согласие — это настройка, которую клиент переключает; блокировка переживает случайное обратное включение, и её также может записать оператор из-за возвратов или жалобы. Экран настроек сообщает о действующей блокировке с её причиной и датой вступления в силу и не предлагает элемента, чтобы её снять, — удержание, поставленное потому, что сообщения раз за разом не доходили, этот экран не вправе отменять. Обратное включение маркетинга удаляет только блокировку с причинойcustomer_request, а
отписка никогда не перезаписывает операторскую: если строка с причиной
bounced, complaint или operator уже есть, отписка клиента оставляет её как
есть, так что два клика — отписаться, затем снова включить — не превратят вывод
о возвратах в снятое удержание.
Проход доставки читает этот список. Маркетинговое сообщение — рассылка или пост
категории news — заблокированному клиенту придерживается с причиной
suppressed, что бы ни говорил флаг согласия, и история показывает его под этой
причиной, а не как no_consent. Транзакционные сообщения не затрагиваются:
заблокированный клиент по-прежнему узнаёт, что его подписка заканчивается в
четверг.
Операторы видят список в /admin/marketing → Блокировки отправки и могут
снять запись там; добавление — это вызов API, а не экран. См.
рассылки.
Ограничение частоты
Обе читаются процессом bot. Счёт берётся по строкам доставки с классом
marketing и статусом sent внутри окна, поэтому заблокированное или
отложенное сообщение лимит не расходует. Поскольку посты категории news
являются маркетинговыми, объявление, опубликованное в этой категории,
конкурирует за те же три места, что и рассылка.
Решение, по порядку
EvaluateDelivery выполняет эти проверки в такой последовательности и
останавливается на первой, которая даёт ответ:
- Состояние доставки в Telegram —
blocked→ отказ с причинойbot_blocked;deactivated→ отказ с причинойuser_deactivated;not_started→ отказ с причинойnot_started. - Настройка по данному виду выключена → отказ с причиной
kind_disabled. - Сообщение маркетинговое, а согласия нет → отказ с причиной
no_marketing_consent. - Сообщение маркетинговое, а лимит израсходован → отказ с причиной
frequency_cap. - Ещё действует flood wait от Telegram → отложить до этого момента.
- Вид несрочный, и момент попадает внутрь тихих часов → отложить до конца окна.
- Иначе — отправить.
suppressed, если клиент есть в
communication_suppressions. Оно стоит вне функции, потому что блокировка — это
запись о клиенте, а не его настройка, и у поверхностей, разделяющих функцию, нет
списка, с которым сверяться.
Каждая попытка оставляет строку в notification_deliveries, уникальную по
(user_id, kind, subscription_id, dedupe_key) с NULLS NOT DISTINCT, чей статус
— один из pending, deferred, sent, failed или suppressed и которая
несёт класс и причину отказа. Строка failed повторяется, пока её счётчик
неудач меньше трёх; строка deferred повторяется, когда её момент наступил;
строка suppressed окончательна.
Собственные сбои Telegram классифицируются в устойчивое состояние, чтобы
заблокированный или удалённый аккаунт перестал расходовать повторы:
bot_blocked помечает получателя как blocked, а user_deactivated или
chat_not_found — как deactivated, и ни то ни другое не повторяется, тогда как
flood wait, таймаут или авария Telegram помечают его как failing, и это
повторяется. Ответ Telegram «bot can’t initiate conversation with a user» — так
бывает с тем, кто ни разу не нажимал Start, например с клиентом, вошедшим на
сайт через виджет Telegram, — помечает его как not_started: это так же
окончательно, но это не блокировка.
blocked, deactivated и not_started исключают клиента из каждого прохода
уведомлений, поэтому успешная отправка никогда не может их снять. Вместо этого
бот снимает их, как только слышит этот аккаунт: любое сообщение, нажатие кнопки
или оплата из личного чата клиента возвращает состояние в active. Собственное
сообщение Telegram о том, что бота в этом чате заблокировали или перезапустили
(my_chat_member), тоже учитывается: блокировка записывается как blocked ещё
до того, как об неё споткнулась хоть одна отправка, а перезапуск её снимает.
Лента объявлений
Push можно пропустить. Лента — это долговременное место, куда клиент может вернуться, и на обеих поверхностях это одна и та же запись: прочитанный в Telegram пост перестаёт быть непрочитанным в вебе. В браузере —/account/news, переход из /account/support → Новости. Посты
перечислены от новых к старым, по 20 за раз за элементом Показать более
старые объявления, с числом непрочитанных в заголовке. У каждого поста есть
значок категории — announcement в информационном тоне, incident в тоне
опасности, maintenance в предупреждающем тоне, news нейтрально, — а пост с
классом marketing получает второй значок Реклама, потому что клиент,
согласившийся на предложения, вправе видеть, что здесь служебное уведомление, а
что — продающее сообщение, не выводя это из формулировок. Тела постов
отображаются как обычный текст; ничто в посте не интерпретируется как разметка.
Прочтение — явное действие: нажмите Отметить прочитанным. Панель не выводит
его из положения прокрутки, потому что общее состояние прочтения — это
утверждение, которое стоит делать только тогда, когда клиент действительно так
сказал.
В Telegram — пункт меню Новости со значком непрочитанного, затем список
и просмотр поста.
Какие посты видны
Пост виден, когда его статус —published, момент публикации задан и уже
наступил, а сам пост не истёк. Проверка статуса несёт нагрузку: снятие с
публикации намеренно сохраняет отметку публикации, чтобы повторная публикация не
переписывала момент, когда клиенты впервые увидели пост, — и одно только окно по
отметкам времени поэтому продолжало бы показывать пост, который оператор снял.
Язык разрешается на сервере: явный запрос выигрывает, затем настройка бота (если
она не auto), затем язык аккаунта, затем английский. Пост, у которого нет
перевода на разрешённый язык, отсутствует, а не показывается на другом языке,
потому что ленту, которая переключает язык посреди списка, читать труднее, чем
более короткую. Разрешённый язык возвращается вместе с ответом, и панель
показывает уведомление, когда он отличается от того, на котором отрисована
страница.
Веб-панель намеренно не отправляет собственный язык, чтобы один и тот же клиент
не читал одно и то же объявление на двух языках в зависимости от того, какую
поверхность он открыл.
Операторского экрана для написания объявлений нет. Единственный переход
состояния, который выполняет хоть какой-то код, — это проход воркера
scheduled → published, который выполняется примерно раз в минуту и
продвигает до 50 постов, чей запланированный момент наступил. Создание поста,
написание его переводов, снятие с публикации и архивирование делаются прямой
записью в news_posts и news_post_localizations.Уведомления браузера
/account/preferences → Уведомления браузера → Включить уведомления
браузера. Запрос разрешения от браузера следует за этим нажатием, а не за
загрузкой страницы, потому что браузер даёт сайту один безропотный шанс спросить,
а рефлекторное «Заблокировать» действует навсегда и со страницы не отменяется.
Это API Notification, работающий на опросах, которые панель и так выполняет, —
и это явно не web push. Здесь нет service worker, нет ключа VAPID и никуда не
отправляется подписка, потому что у Omniflow нет серверной отправки push, чтобы
её доставлять, а подписка, которую сервер никогда не использует, — это запрос
разрешения, потраченный впустую. Когда вкладка закрыта, до клиента добирается
Telegram.
Что это означает на практике:
- Согласие живёт в
localStorageпод ключомomniflow.account.browser-notifications, поэтому это настройка одного браузера, а не аккаунта, и она исчезает вместе с данными сайта. Флаг на уровне аккаунта заявлял бы настройку, которую одно устройство соблюсти не может. - Обе половины должны совпадать. Разрешение, отозванное в собственных настройках браузера, очищает сохранённое согласие при следующем чтении.
- Пока клиент не дал согласие, вообще ничего не запрашивается; после этого список обращений и список новостей опрашиваются каждые 60 секунд.
- Ничего не показывается, пока вкладка видима и в фокусе. Функция существует для вкладки, оставленной в фоне.
- Первый опрос после согласия только устанавливает базовую точку, поэтому открытие панели с тремя непрочитанными ответами ничего не вызовет.
- Повторы схлопываются по обращению и по посту через тег уведомления.
- Отклонённое разрешение никогда не запрашивается повторно; карточка сообщает, что его нужно изменить в настройках браузера.
- Chrome на Android бросает исключение из конструктора
Notification, потому что уведомления там требуют service worker. Это перехватывается и сообщается как «не поддерживается», с пояснением на карточке, а не становится тихой пустышкой. - Обнаружение ответа сравнивает серверный счётчик непрочитанного, который вычисляется из общей записи сообщений, — поэтому ответ, уже прочитанный в Telegram, приходит сюда без повода что-либо сообщать.
Что было отправлено на самом деле
Всё, что выше, — это настройки. Настройка говорит, что должно приходить, и поэтому «я не получил предупреждение об истечении» остаётся утверждением, которое никто в разговоре не может проверить: у клиента есть только отсутствие сообщения, а отсутствие выглядит одинаково — сломан ли бот, удержало ли сообщение тихое окно или отправлять было нечего. Запись существовала с первого релиза.notification_deliveries всегда несла
тип, класс, ушло ли сообщение, когда, сколько было попыток и — если не ушло —
код с причиной. Её никто не читал. Теперь её читают обе стороны.
Собственная история клиента
Под элементами настроек, на том же экране, сначала новые. Текстов сообщений там нет: запись говорит, что уведомление такого-то типа состоялось, а не что в нём было, и подстановка шаблона под сегодняшние данные показала бы человеку то, что ему никогда не отправляли. Что означает каждый исход:
Причины удержания указывают на элемент управления на этой же странице:
quiet_hours — на тихое окно, no_consent — на переключатель, frequency_cap
— на настройку маркетинга. В этом и смысл соседства: «ничего не пришло»
превращается в то, что клиент может изменить сам, не создавая обращение.
Что видит оператор
/admin/customers/{id}, вкладка Уведомления. Для чтения нужно
customers.read: история доставок — часть записи клиента, и тот, кто отвечает
на «вы отправляли?», уже смотрит на этот аккаунт.
Те же строки, со сводкой по типам сверху и фильтром по исходу. Полезнее всего
фильтр Удержано: именно он разворачивает разговор с поддержкой. Статус
«удержано» показан нейтральным цветом, а не как предупреждение, — это установка
делает то, что ей велели, и окраска его как сбоя отправила бы операторов искать
проблему в работающей функции.
Отправка теста
Оператор может отправить одно сообщение одному клиенту, чтобы убедиться, что доставка работает прямо сейчас. Нужно правоsupport.write: в клиенте ничего не
меняется, уходит сообщение, и это нужно тем, кто ведёт разговор, из которого
вопрос возник.
Тест идёт через обычную очередь отправки, и в этом вся его ценность. У процесса
панели нет соединения с Telegram — оно есть у рассыльщика. Тест, который пошёл
бы своим путём, доказал бы, что работает путь теста, о чём никто не спрашивал.
Этот забирается тем же воркером, отправляется тем же вызовом, классифицируется
теми же кодами ошибок и записывается в ту же таблицу — поэтому, когда настоящие
уведомления падают, тест падает так же и называет причину.
Три следствия, которые стоит назвать прямо:
- Он поставлен в очередь, а не отправлен. API отвечает
202, и панель так и говорит. Сообщение уйдёт в следующем проходе рассыльщика, а результат появится в истории под кнопкой. Сообщать об отправке, которой ещё не было, как о состоявшейся — ровно тот сбой, ради которого эта функция и существует. - Тихие часы, лимит частоты и переключатели по типам не применяются. Тест, отложенный до девяти утра, ничего не отвечает в тот момент, когда спрашивают. Состояние доставки применяется по-прежнему: клиента, заблокировавшего бота, не достать желанием, и это как раз тот вывод, который оператору и нужен.
- У него собственный тип. Тест никогда не прочитается как настоящее уведомление и никогда не потратит бюджет частоты рекламных сообщений.
customer.notification.tested.
Справочник
Отказы — это problem-документы RFC 9457.
locale должен нормализоваться в
auto, ru или en — значение сначала приводится к нижнему регистру, поэтому
отказ на EN был бы правилом о регистре, а не о языке. Тихие часы должны быть
целыми часами от 0 до 23. Несуществующая запись клиента отвечает 404.
Операторские маршруты, все под /v1/panel:
Смежные страницы
Клиентская веб-панель
Остальное, что клиент может сделать из браузера.
Telegram-бот
/settings, меню новостей и все остальные команды.Персональные данные
Контактные каналы, след согласий, экспорт и удаление.
Тексты сообщений
Со стороны оператора: изменение этих уведомлений, с предпросмотром и тестовой отправкой.
Рассылки
Сторона оператора: сегменты, шаблоны, отправки и блокировки.