Provider Adapter: MAX Messaging (заглушка)
Назначение
Доставка канала Max (мессенджер MAX) (../domain/channel.md) через Bot/Business API
MAX. Заглушка: реализация отложена — контракт зафиксирован, отправка активируется per-канал флагом.
Привязку UserId → MAX chat-id ведёт адаптер (deep-link /start); уведомление хранит только
RecipientUserId. Provider input (updates) — недоверенный внешний ввод.
Границы Adapter-а
| Поле | Значение |
|---|
| Provider | MAX Bot/Business API |
| Сервис | notifications |
| Capability | messaging (max) |
| Adapter class | MaxChannelProvider |
| Typed client | IMaxBotClient + IMessengerHandleStore (chat-id по UserId) |
| Внутренняя операция | dispatcher при Channel = Max, Status = Pending |
| Реализация | заглушка (отложено) — до включения канала отправка не выполняется и возвращается код NTF_PROVIDER_NOT_IMPLEMENTED |
Контракты Provider-а
| Операция | Direction | Internal model | Provider-specific model |
|---|
sendMessage | outbound | NotificationDispatchRequest | MaxSendMessage (chat_id/text/buttons) |
bot updates (/start, block) | callback | привязка / отвязка messenger-handle | MaxUpdate (подписанный webhook) |
Модель Internal NotificationDispatchRequest
Дом — ../domain/notification.md. Ключевые: NotificationId (idempotency),
RecipientUserId → chat-id из IMessengerHandleStore, RenderedTitle+RenderedBody → text,
DeepLink → кнопка.
Модель Provider-Specific MaxSendMessage
| Поле | Тип | Обязательность | Provider format | Validation | Internal mapping |
|---|
chat_id | string | Да | MAX chat id | привязка есть (иначе skip) | IMessengerHandleStore.Get(RecipientUserId) |
text | string | Да | text/markup | не пустое | RenderedTitle+RenderedBody |
buttons | объект? | Нет | inline buttons | — | DeepLink |
clientReference | string | Да | idempotency | = NotificationId | NotificationId |
Безопасность
| Контроль | Правило |
|---|
| Authentication | bot/business-token MAX |
| Signature validation | webhook — подпись/secret-token + timestamp |
| Secrets | токены/webhook-secret — secret-store, ротация |
| PII | тело — Sensitive (маска); chat-id — Sensitive, не логируется |
| Tenant binding | привязка chat-id к RecipientUserId; отправка только в привязанный chat |
Resilience И Устойчивость
| Аспект | Правило |
|---|
| Timeout | 5с к API |
| Retry | 5xx/429/timeout → перевод в Failed → повтор retry-worker-ом; blocked → unlink, не retry |
| Circuit breaker | размыкание на клиенте провайдера |
| Idempotency | clientReference = NotificationId |
| Rate limit | по лимиту MAX + ChannelConfig.RateLimitPerMinute |
Обработка Callback
| Шаг | Правило |
|---|
| Verify | подпись/secret webhook + timestamp |
| Normalize | MaxUpdate → привязка handle (/start с UserId) / отвязка |
| Validate | deep-link payload = валидный UserId |
| Dispatch | привязка/отвязка chat-id в IMessengerHandleStore |
| Store | handle в store; статус доставки — на NotificationEntity |
Маппинг Ошибок
| Provider code | Internal error | Повторяемость | Видно пользователю |
|---|
| 5xx / timeout | NTF_PROVIDER_TRANSIENT | Да | Нет |
| 429 | NTF_PROVIDER_RATE_LIMITED | Да | Нет |
| bot blocked | NTF_MAX_BLOCKED | Нет (unlink) | Нет |
| нет привязки chat-id | NTF_MESSENGER_NOT_LINKED | Нет (skip) | Нет |
| не активирован (заглушка) | NTF_PROVIDER_NOT_IMPLEMENTED | Нет | Нет |
Поток доставки
Happy path и все ветки отказа отправки уведомления Channel = Max. Статусы и переходы — по
notification.md (INV-NTF-5/7); коды — таблица «Маппинг Ошибок».
- Выбор к отправке. Dispatcher берёт свежее
Pending, retry-worker — Failed с наступившим
NextAttemptAt и Attempts < MaxAttempts; уведомление с Channel = Max. Если канал выключен
(ChannelConfig.IsEnabled = false — заглушка не активирована, INV-CHN-2) → попытка не выполняется,
статус остаётся Pending, исход NTF_PROVIDER_NOT_IMPLEMENTED (skip, метрика channel-disabled).
- Резолв chat-id. Адрес канала — chat-id, привязанный к
RecipientUserId (реестр привязок ведёт
адаптер). Привязки нет → NTF_MESSENGER_NOT_LINKED: канал пропускается (non-retriable, без бесконечных
повторов); получатель
покрыт другими разрешёнными каналами (in-app доставляется всегда).
- Сборка сообщения.
RenderedTitle+RenderedBody → text (усечение по ChannelConfig.MaxBodyLength,
если задан, INV-CHN-3), DeepLink → inline-кнопка, clientReference = NotificationId (идемпотентность
у провайдера).
- Отправка (синхронно, timeout 5с). Повторная отправка того же
NotificationId не создаёт второе
сообщение (идемпотентность по clientReference).
- Классификация ответа:
- успех → переход
Pending/Failed → Sent, сохраняется ProviderMessageId; вклад в
NotificationDispatchedEvent(Sent) (топик pin.notifications.dispatched.v1);
5xx/timeout → NTF_PROVIDER_TRANSIENT (retriable): → Failed, Attempts++,
NextAttemptAt = now + backoff (по ChannelConfig.RetryBackoff);
429 → NTF_PROVIDER_RATE_LIMITED (retriable): тот же дефер по backoff;
- bot blocked →
NTF_MAX_BLOCKED (non-retriable): терминальный Failed, привязка chat-id снимается
(unlink) — до повторного /start канал недоставляем.
- Порог повторов.
Attempts >= MaxAttempts (INV-NTF-7) → терминальный Failed +
NotificationDispatchedEvent(Failed) + алерт; запись выбывает из retry-цикла (ручной разбор по runbook
terminal-failures).
Обратный вызов и согласованность
Bot-updates приходят асинхронно и не привязаны к конкретной отправке — это привязка/отвязка handle,
не delivery-receipt:
/start c deep-link payload UserId → после проверки подписи/secret-token и валидности UserId
chat-id привязывается; с этого момента канал Max доставляем.
- block/stop → отвязка chat-id; последующие отправки до повторного
/start завершаются
NTF_MESSENGER_NOT_LINKED.
- Повтор того же update идемпотентен (привязка к тому же chat-id — no-op); update с неизвестным/невалидным
UserId игнорируется (лог webhook-received, без ошибки).
- Окно согласованности: между отправкой и block возможна гонка — сообщение, ушедшее до обработки block,
считается
Sent; отвязка влияет только на будущие отправки.
Пред- и постусловия
- Предусловие: канал
Max включён (IsEnabled = true); есть уведомление Channel = Max в
Pending/Failed(due); для доставки — привязан chat-id (/start).
- Постусловие: уведомление в терминальном
Sent (доставлено, есть ProviderMessageId) либо Failed
(терминально после MaxAttempts или non-retriable unlink); на финальном исходе эмитирован
NotificationDispatchedEvent. При отсутствии привязки канал пропущен, прочие каналы получателя не затронуты.
Кросс-сервисные ожидания и гарантии
| Направление | Контракт |
|---|
Ожидает от ChannelConfig (channel.md) | IsEnabled, ProviderKey, MaxAttempts, RetryBackoff, RateLimitPerMinute, MaxBodyLength |
| Ожидает от рендера шаблона | непустой RenderedBody (INV-NTF-2); опц. RenderedTitle/DeepLink |
| Ожидает от привязки | chat-id по RecipientUserId (deep-link /start); отсутствует → skip |
| Предоставляет агрегату | переход статуса Sent/Failed, ProviderMessageId, LastError (безопасный код) |
| Предоставляет наружу | вклад в NotificationDispatchedEvent (ключ партиционирования NotificationId, ContractVersion = 1) |
| Реплики | своих реплик нет; недоступность провайдера — transient-retry, не потеря уведомления |
Acceptance Criteria
| # | Given / When / Then |
|---|
| AC1 | Given канал Max выключен, When dispatcher берёт Pending, Then попытки нет, статус Pending, метрика channel-disabled (NTF_PROVIDER_NOT_IMPLEMENTED). |
| AC2 | Given привязан chat-id и канал включён, When отправка успешна, Then Sent + ProviderMessageId + вклад в NotificationDispatchedEvent(Sent). |
| AC3 | Given провайдер вернул 5xx/429, When обработка ответа, Then Failed, Attempts++, NextAttemptAt по backoff; повтор до успеха или MaxAttempts. |
| AC4 | Given MaxAttempts исчерпан, When очередная ошибка, Then терминальный Failed + NotificationDispatchedEvent(Failed) + алерт. |
| AC5 | Given bot заблокирован, When ответ blocked, Then терминальный Failed + unlink chat-id (NTF_MAX_BLOCKED), без повторов. |
| AC6 | Given нет привязки chat-id, When отправка, Then канал пропущен (NTF_MESSENGER_NOT_LINKED), in-app доставлен независимо. |
| AC7 | Given повтор той же отправки (NotificationId), When идемпотентный clientReference, Then у провайдера одно сообщение (без дубля). |
Наблюдаемость
| Тип | Правило |
|---|
| Logs | send-started/sent/blocked/not-linked/provider-error/webhook-received (тело/chat-id — маска) |
| Metrics | notifications_max_sent_total, notifications_max_error_rate, notifications_max_linked_total, circuit-state |
| Traces | correlationId, NotificationId, provider request id |
| Redaction | токены/secret и raw тело/chat-id — исключены |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы