> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

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

> Публикуйте объявления, разберитесь, как классифицируется каждое уведомление клиенту, и точно знайте, почему одно из них было подавлено или отложено

Всё, что Omniflow отправляет клиенту, проходит через два механизма, описанных
здесь: **входящие с новостями**, где долговременно живут объявления, и **политика
доставки** — единственное место, где решается, можно ли вообще писать клиенту.

Оба существуют по одной причине. Клиенту нужно сообщить, что его подписка
заканчивается в четверг, независимо от того, хочет ли он маркетинг, а оператору
нужна запись о каждом неотправленном сообщении и о причине. Сведение этих двух
вещей в один переключатель «уведомления» сделало бы одну из них невозможной.

Эта страница — взгляд оператора и системы. О том, что видит и чем управляет
клиент, см. [Уведомления и согласие](https://omniflow-vpn.mintlify.site/ru/customer/notifications.md).

## Новости и объявления

### Категории и во что каждая обходится

Пост несёт `category` и `class`. Категорий четыре, и именно категория считается
для машинерии доставки видом уведомления:

| Категория | Класс, под которым доставляется | Нужно ли согласие | Откладывается ли тихими часами |
| - | - | - | - |
| `news` | marketing | Да — маркетинговое согласие, и расходуется квота частоты | Да |
| `announcement` | transactional | Нет | Да |
| `incident` | transactional | Нет | **Нет** — аварии срочны |
| `maintenance` | transactional | Нет | Да |

Все четыре дополнительно управляются единственным переключателем настройки
**новостей** у клиента, поэтому клиент, выключивший новости, перестаёт получать
и объявления, и уведомления об авариях.

> [!NOTE]
>
> `news_posts.class` — отдельная колонка, доводимая до полезной нагрузки
> клиентских входящих, где панель использует её для окраски бейджа. На доставку
> она не влияет — это решает категория.

### Жизненный цикл и что на самом деле способно провести пост по нему

`news_posts.status` — одно из `draft`, `scheduled`, `published`, `unpublished`
или `archived`, а рядом стоят `scheduled_for`, `published_at`, `unpublished_at` и
`archived_at`.

Одна отметка времени публикации не может выразить «написано, но не готово»,
«запланировано на вторник» или «снято, потому что было неверным» — а последнее
нужно оператору срочнее всего, поэтому снятие с публикации существует как
состояние, а не как удаление.

`published_at` выставляется через `COALESCE(published_at, now())`, поэтому
повторная публикация не переписывает момент, когда клиенты впервые увидели пост.
`CHECK` требует `scheduled_for` всегда, когда статус — `scheduled`.

> [!WARNING]
>
> **Операторской поверхности для новостей нет.** Единственный переход состояния,
> который делает хоть какой-то код, — это `scheduled → published` в воркере:
> проход `campaign_expansion` выполняется раз в минуту, берёт до 50 постов, у которых
> `scheduled_for` уже прошёл, и публикует их. Создать пост, написать его русскую
> и английскую локализации, запланировать его, снять с публикации или
> заархивировать можно только записью напрямую в `news_posts` и
> `news_post_localizations`.
>
> Слаги соответствуют `^[a-z0-9][a-z0-9_-]{2,63}$`, заголовки ограничены 200
> символами, тела — 3500, локали — только `ru` или `en`.

### Что делает пост видимым

Пост виден клиенту, когда выполняется всё перечисленное:

```
status = 'published'
AND published_at IS NOT NULL
AND published_at <= now()
AND (expires_at IS NULL OR expires_at > now())
```

Проверка статуса важна сама по себе. Снятие с публикации намеренно оставляет
`published_at` заполненным — это момент, когда клиенты впервые увидели пост, —
поэтому одно только окно по отметкам времени продолжало бы показывать пост,
который оператор снял. И веб-панель, и бот применяют одинаковый предикат, поэтому
отзыв поста убирает его сразу с обеих поверхностей.

### Разрешение локали

Пост существует по локалям, и пост, у которого нет перевода на разрешённую
локаль клиента, **отсутствует**, а не показывается на другом языке.

Разрешение идёт по порядку: явный параметр запроса `locale`, если он называет
`ru` или `en`, затем настройка клиента в боте, если она не `auto`, затем локаль
аккаунта, затем `en`. Разрешённая локаль возвращается в ответе, поэтому панель
может сказать, что получила не тот язык, который просила, а не делать вид.

### Входящие

| Маршрут | Примечания |
| - | - |
| `GET /v1/account/news?locale&cursor&limit` | По умолчанию 20, максимум 50; возвращает `{items, unreadCount, locale, nextCursor}` |
| `POST /v1/account/news/{postID}/read` | 204 |

Элементы упорядочены сначала новые по `(published_at, id)` на обеих
поверхностях, поэтому пачка, опубликованная в один и тот же момент, читается
везде в одном и том же порядке. `unreadCount` считает каждый видимый пост,
имеющий локализацию в разрешённой локали и не имеющий строки о прочтении, и не
ограничен страницей.

Отметка поста прочитанным сначала проверяет видимость и отвечает 404 для
невидимого; вставка идёт с `ON CONFLICT DO NOTHING`, поэтому прочитать дважды —
не ошибка.

Клиенты попадают сюда по адресу `/account/news` в веб-панели или через пункт меню
**News** в боте, и на каждом висит бейдж непрочитанного.

### Push

Входящие работают по запросу. Push — это то, благодаря чему уведомление об аварии
доходит.

Нотификатор бота ищет посты, опубликованные за последние семь дней, для активных
клиентов с получателем в Telegram, у которых есть локализация в разрешённой
локали клиента и нет строки доставки для этого поста, которая ещё ждёт или уже
окончательна. Он отправляет по одному сообщению на клиента на пост, по 200 за
раз, и читает следующую пачку сразу после отправки предыдущей — в темпе общего
для бота `APP_TELEGRAM_DELIVERY_RATE`.

Клиент, чей чат заведомо недоступен — `blocked`, `deactivated` или
`not_started`, — вообще не читается, а клиент, ставший недоступным между чтением
и отправкой, получает строку доставки `suppressed` с причиной `unreachable`. В
обоих случаях очередь идёт дальше; раньше такие клиенты пропускались без записи,
стояли в голове каждой пачки все семь дней, и двести таких останавливали
объявления для всех. Доставка, отложенная до конца тихих часов или
провалившаяся с оставшимися повторами, читается снова, как только может
сдвинуться, поэтому отсрочка — это задержка, а не потеря.

Пост, которому к моменту, когда воркер впервые его увидел, уже больше семи дней,
не анонсируется никогда. Публикация старого поста задним числом наполнит входящие
и не отправит ничего.

## Как классифицируется каждое уведомление

Каждое уведомление клиенту проходит через одну функцию принятия решения.
Неклассифицированный вид **отклоняется, а не доставляется**, поэтому добавить
уведомление, не решив, какого оно класса, невозможно.

| Класс | Виды |
| - | - |
| Transactional | `expiry`, `traffic`, `renewal`, `grace`, `recovery`, `payment`, `fulfillment`, `support`, `incident`, `maintenance`, `announcement`, `referral`, `trial` |
| Marketing | `news`, `marketing` |

Срочные виды никогда не откладываются тихими часами: `payment`, `fulfillment`,
`support` и `incident`. Всё остальное ждёт окончания окна.

### Какая настройка управляет каким видом

| Настройка | Виды, которыми она управляет |
| - | - |
| `expiry_notifications` | `expiry` |
| `traffic_notifications` | `traffic` |
| `renewal_notifications` | `renewal`, `grace`, `recovery`, `trial` |
| `news_notifications` | `news`, `announcement`, `incident`, `maintenance` |
| `marketing_enabled` | `marketing` |
| Отказаться нельзя | `payment`, `fulfillment`, `support`, `referral` |

Четыре вида, от которых нельзя отказаться, несут факты, нужные клиенту
независимо от того, хочет ли он слышать о бизнесе. Модель настроек, способная
заглушить сообщение о сбое платежа, была бы способом потерять доступ без
предупреждения.

### Порядок принятия решения

1. Клиент заблокировал бота — отказ, `bot_blocked`. Его аккаунт деактивирован —
   отказ, `user_deactivated`. Он ни разу не запускал бота — отказ,
   `not_started`. Ничто из этого не повторяется, и все три состояния снимаются,
   когда клиент в следующий раз пишет боту или нажимает в нём кнопку.
2. Настройка для этого вида выключена — отказ, `kind_disabled`.
3. Класс marketing без согласия — отказ, `no_marketing_consent`.
4. Класс marketing и квота частоты уже превышена — отказ, `frequency_cap`.
5. Retry-after в будущем — отложить до этого момента.
6. Не срочное и внутри тихих часов — отложить до конца окна.
7. Иначе разрешить.

### Тихие часы

Тихие часы — это окно в целых часах по местному времени, вычисляемое по
`users.timezone`. Окно переходит через полночь, когда час окончания не больше
часа начала, а `start == end` означает, что окна нет вовсе, — именно так на обеих
поверхностях выражается выключение тихих часов.

> [!NOTE]
>
> Часовой пояс, в котором вычисляется окно, — это пояс аккаунта, а экран настроек
> клиента его не показывает и не меняет. Клиент, задающий 23:00–08:00 в браузере,
> задаёт их в том часовом поясе, который уже записан у его аккаунта.

### Ограничение частоты

| Переменная | По умолчанию | Назначение |
| - | - | - |
| `APP_MARKETING_FREQUENCY_CAP` | `3` | Сколько маркетинговых сообщений разрешено за окно; `0` отключает ограничение |
| `APP_MARKETING_WINDOW_HOURS` | `168` | Длина окна в часах — семь дней |

Обе читаются процессом бота. Счёт — это число строк доставки с классом
`marketing` и статусом `sent` внутри окна, поэтому подавленное или отложенное
маркетинговое сообщение не расходует квоту клиента. Экран `/settings` в боте
показывает клиенту ограничение, когда оно настроено.

## Долговременная запись

`notification_deliveries` — это запись каждого решения, уникальная по
`(user_id, kind, subscription_id, dedupe_key)` с `NULLS NOT DISTINCT`.

| Статус | Значение |
| - | - |
| `pending` | Взято в работу и оценивается |
| `deferred` | Придержано до конца тихих часов или до истечения retry-after |
| `sent` | Принято Telegram |
| `failed` | Telegram отклонил его, с классифицированным кодом ошибки и ограниченным числом повторов |
| `suppressed` | Отклонено политикой, причина — в `error_code` |

Строка берётся в работу повторно только тогда, когда она `failed` с числом сбоев
меньше трёх или `deferred` с истёкшей отсрочкой. Именно это превращает отсрочку в
задержку, а не в потерю сообщения.

Ключ включает подписку, поэтому уведомления жизненного цикла идут по каждой
подписке отдельно и активная подписка не может подавить уведомление о сроке
окончания у тихой.

Сбои Telegram классифицируются в долговременное состояние доставки на аккаунте:
`bot_blocked` превращается в `blocked` без повторов, `user_deactivated` и
`chat_not_found` — в `deactivated` без повторов, «bot can't initiate
conversation» — в `not_started` без повторов, а флуд-ожидания, таймауты и
недоступность Telegram — в `failing`, который повторяем. Любое обновление из
личного чата клиента и сообщение Telegram `my_chat_member` о перезапуске бота
снимают три окончательных состояния; блокировка из `my_chat_member`
записывается как `blocked`.

### Три намеренных исключения

* **Уведомления о просроченных платежах полностью обходят политику.** Клиенту,
  который вот-вот потеряет доступ из-за отклонённой карты, не поможет
  придерживание сообщения до утра, и в модели настроек отказаться от него тоже
  нельзя. Каждое уведомление всё же оставляет строку доставки `payment` с ключом
  `dunning:<attempt>`, и она его ограничивает: неудачная отправка повторяется в
  двух следующих проходах и прекращается, отказ, который Telegram считает
  окончательным, прекращает её сразу, клиенту с заведомо недоступным чатом
  отправка не делается, а уведомление старше семи дней не отправляется.
* **Ответы поддержки тоже её обходят.** Проход доставки ответов отправляет
  напрямую и не пишет строку доставки; дедупликация берётся из собственного
  `delivered_at` сообщения. Как следствие, у ответа поддержки нет записи о
  подавлении и нет учёта повторов. Он в любом случае был бы классифицирован как
  срочный.
* **Клиент без Telegram-идентичности не получает push вообще.** Получатели
  берутся из активных Telegram-идентичностей и Telegram-идентификаторов
  Remnawave. Клиент, вошедший только по магической ссылке или через OIDC, видит
  ответы и объявления в веб-панели, и никуда ничего не отправляется.

## Согласие дописывается, а не редактируется

Каждое маркетинговое решение дописывает строку `consent_records`, несущую цель,
факт выдачи согласия, версию политики, поверхность-источник и идентификатор
запроса. Текущим ответом считается самая новая строка. История, которую можно
переписать, ничего не доказывает.

| Кто пишет | Источник | Версия политики |
| - | - | - |
| Настройки и отписка в веб-панели клиента | `customer_web` | `web-v0.10` |
| Изменение контактных каналов в веб-панели клиента | `account_web` | `account-web-v0.10` |
| Telegram-бот | `telegram_bot` | Собственная константа бота |

Обновление настройки дописывает строку только тогда, когда значение
действительно меняется относительно последней записи. **Отписка дописывает
безусловно**, потому что «он и так уже отписался» — не причина, чтобы его
отписка не оставила следа.

Колонка `marketing_enabled` в строке настроек — это запасной вариант для
установок, появившихся раньше истории согласий. Там, где они расходятся,
побеждает запись.

Именно это делает возможной строку с доказательством, показываемую клиенту:
«вы дали согласие» — не слишком полезное сообщение тому, кто этого не помнит, а
«вы дали согласие в такую-то дату, с такой-то поверхности, под такой-то версией
политики» — полезное.

### Что делает отписка

Одна кнопка на `/account/preferences` выключает маркетинг и настройку новостей,
снимает маркетинговый флаг со всех живых контактных каналов, дописывает отзыв
согласия и пишет строку `communication_suppressions` с причиной
`customer_request`.

Она намеренно не трогает уведомления об окончании срока, о трафике и о
продлении. Они несут факты, нужные клиенту независимо от того, хочет ли он
слышать о бизнесе, и отписка, заглушающая их, превратила бы маркетинговую
настройку в способ потерять доступ без предупреждения.

Обратное включение маркетинга позже удаляет только подавление
`customer_request` — о том, почему записанное оператором подавление это
переживает, см. [список подавления](https://omniflow-vpn.mintlify.site/ru/commerce/campaigns.md).

## Вложения в поддержке и сроки хранения

Вложение в обращении хранится одним из двух способов, и разница важна с
операционной точки зрения.

Вложение из **Telegram** хранит только идентификатор файла в Telegram. Хранителем
выступает Telegram, а Omniflow не держит байтов. У загрузки через **веб** такого
хранителя нет, поэтому байты пишутся на диск под ключом, адресуемым по
содержимому.

Какой из двух видов перед вами — это колонка: `origin` равен `telegram` или
`local`, а ссылка лежит соответственно в `telegram_file_id` или в `storage_key`.
Таблица сама следит за этой парой, поэтому строка не может объявить одно
происхождение и нести ссылку другого. Всё, что отправляет вложение обратно в
Telegram, пропускает строку с `local` — дайджест содержимого ничего не значит для
Telegram, — а маршрут скачивания отклоняет строку с `telegram`, потому что этих
байтов у него нет.

| Переменная | По умолчанию | Назначение |
| - | - | - |
| `APP_SUPPORT_ATTACHMENT_DIR` | `/var/lib/omniflow/support-attachments` | Где хранятся байты, загруженные через веб |
| `APP_RETENTION_INTERVAL_HOURS` | `1` | Как часто выполняется обход по срокам хранения |

> [!WARNING]
>
> **API и воркер должны указывать на один и тот же каталог.** Байты пишет API, а
> удаляет их обход воркера. Если они расходятся, обход удаляет строки, а файлы
> остаются, и диск растёт без ограничения.

Сам срок хранения вложений — это значение колонки по умолчанию, 90 дней, и он не
настраивается. Ежечасный обход удаляет каждую истёкшую строку, а затем удаляет
только те веб-файлы, на которые не ссылается ни одна уцелевшая строка: два
клиента, загрузившие одинаковые байты, делят один файл, адресуемый по
содержимому, поэтому удаление по строке удалило бы чужое вложение. Файлы
удаляются только после фиксации транзакции, а файл, который не удалось удалить,
записывается в лог и пропускается, а не роняет весь обход.

> [!NOTE]
>
> Истёкшее вложение удаляет только обход воркера. Раньше бот тоже вычищал строки
> в конце каждого прохода уведомлений, и этот путь не освобождал файлы — поэтому
> всякий раз, когда он срабатывал первым, строки уже не было, обходу нечего было
> освобождать, и байты оставались на диске навсегда. Он убран: удаляющий один, и
> это тот же, кто освобождает диск.

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

> **[Сегменты аудитории и кампании](https://omniflow-vpn.mintlify.site/ru/commerce/campaigns.md)**
>
> Отправка сообщения выбранной аудитории и список подавления.

> **[Уведомления и согласие](https://omniflow-vpn.mintlify.site/ru/customer/notifications.md)**
>
> Та же машинерия со стороны клиента.

> **[Служба поддержки](https://omniflow-vpn.mintlify.site/ru/operations/support-desk.md)**
>
> Куда приходят вложения и как доставляются ответы.

> **[Конфигурация](https://omniflow-vpn.mintlify.site/ru/getting-started/configuration.md)**
>
> Каждая переменная окружения, названная на этой странице.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.