Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
104
docs/adr/0001-chat-platform-service-boundaries.md
Normal file
104
docs/adr/0001-chat-platform-service-boundaries.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# ADR-0001: Разделение Chat Platform на 3 сервиса
|
||||
|
||||
Дата: 2026-02-21
|
||||
Статус: accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
Сейчас delivery уже вынесен отдельно, но часть omni-интеграции остается в приложении `frontend`.
|
||||
Нужна архитектура, где входящие вебхуки, доменная логика чатов и исходящая доставка развиваются независимо и не ломают друг друга.
|
||||
|
||||
Критичные требования:
|
||||
|
||||
- входящие webhook-события не теряются при рестартах;
|
||||
- delivery управляет retry/rate-limit централизованно;
|
||||
- omni_chat остается единственным местом доменной логики и хранения состояния диалогов;
|
||||
- сервисы можно обновлять независимо.
|
||||
|
||||
## Решение
|
||||
|
||||
Принимаем разделение на 3 сервиса:
|
||||
|
||||
1. `omni_inbound`
|
||||
- Принимает вебхуки провайдеров.
|
||||
- Валидирует подпись/секрет.
|
||||
- Нормализует событие в универсальный envelope.
|
||||
- Пишет событие в durable queue (`receiver.flow`) с идемпотентным `jobId`.
|
||||
- Возвращает `200` только после успешной durable enqueue.
|
||||
- Не содержит бизнес-логики CRM.
|
||||
|
||||
2. `omni_chat`
|
||||
- Потребляет входящие события из `receiver.flow`.
|
||||
- Разрешает идентичности и треды.
|
||||
- Создает/обновляет `OmniMessage`, `OmniThread`, статусы и доменные эффекты.
|
||||
- Формирует исходящие команды и кладет их в `sender.flow`.
|
||||
|
||||
3. `omni_outbound`
|
||||
- Потребляет `sender.flow`.
|
||||
- Выполняет отправку в провайдеров (Telegram Business и др.).
|
||||
- Управляет retry/backoff/failover, DLQ и статусами доставки.
|
||||
- Не содержит UI и доменной логики чатов.
|
||||
|
||||
## Почему webhook и delivery разделены
|
||||
|
||||
- Входящий контур должен отвечать быстро и предсказуемо.
|
||||
- Исходящий контур живет с долгими retry и ограничениями провайдера.
|
||||
- Сбой внешнего API не должен блокировать прием входящих сообщений.
|
||||
|
||||
## Границы ответственности
|
||||
|
||||
`omni_inbound`:
|
||||
|
||||
- можно: auth, валидация, нормализация, дедуп, enqueue;
|
||||
- нельзя: запись доменных сущностей CRM, принятие продуктовых решений.
|
||||
|
||||
`omni_chat`:
|
||||
|
||||
- можно: вся доменная модель чатов, orchestration, бизнес-правила;
|
||||
- нельзя: прямые вызовы провайдеров из sync API-контекста.
|
||||
|
||||
`omni_outbound`:
|
||||
|
||||
- можно: провайдерные адаптеры, retry, rate limits;
|
||||
- нельзя: резолвинг бизнес-правил и маршрутизации диалога.
|
||||
|
||||
## Универсальный протокол событий
|
||||
|
||||
Внутренний контракт входящих событий: `docs/contracts/omni-inbound-envelope.v1.json`.
|
||||
|
||||
Обязательные поля:
|
||||
|
||||
- `version`
|
||||
- `idempotencyKey`
|
||||
- `provider`, `channel`, `direction`
|
||||
- `providerEventId`, `providerMessageId`
|
||||
- `eventType`, `occurredAt`, `receivedAt`
|
||||
- `payloadRaw`, `payloadNormalized`
|
||||
|
||||
## Идемпотентность и надежность
|
||||
|
||||
- `jobId` в очереди строится из `idempotencyKey`.
|
||||
- Дубликаты входящих webhook событий безопасны и возвращают `200`.
|
||||
- `200` от `omni_inbound` отдается только после успешного добавления в Redis/BullMQ.
|
||||
- При ошибке durable enqueue `omni_inbound` возвращает `5xx`, провайдер выполняет повторную доставку.
|
||||
- Базовые рабочие очереди: `receiver.flow` и `sender.flow`; технические очереди для эскалации: `receiver.retry`, `sender.retry`, `receiver.dlq`, `sender.dlq`.
|
||||
|
||||
## Последствия
|
||||
|
||||
Плюсы:
|
||||
|
||||
- независимые релизы и масштабирование по ролям;
|
||||
- меньше blast radius при инцидентах;
|
||||
- проще подключать новые каналы поверх общего контракта.
|
||||
|
||||
Минусы:
|
||||
|
||||
- больше инфраструктурных компонентов (очереди, мониторинг, трассировка);
|
||||
- требуется дисциплина по контрактам между сервисами.
|
||||
|
||||
## План внедрения
|
||||
|
||||
1. Вводим `omni_inbound` как отдельный сервис для Telegram Business.
|
||||
2. Потребление `receiver.flow` реализуем в `omni_chat`.
|
||||
3. Текущее исходящее API оставляем за `omni_outbound`.
|
||||
4. После стабилизации выносим оставшиеся omni endpoint'ы из `frontend` в `omni_chat`/`omni_inbound`.
|
||||
91
docs/contracts/omni-inbound-envelope.v1.json
Normal file
91
docs/contracts/omni-inbound-envelope.v1.json
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://crm.local/contracts/omni-inbound-envelope.v1.json",
|
||||
"title": "OmniInboundEnvelopeV1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"version",
|
||||
"idempotencyKey",
|
||||
"provider",
|
||||
"channel",
|
||||
"direction",
|
||||
"providerEventId",
|
||||
"providerMessageId",
|
||||
"eventType",
|
||||
"occurredAt",
|
||||
"receivedAt",
|
||||
"payloadRaw",
|
||||
"payloadNormalized"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"const": 1
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"enum": ["TELEGRAM", "WHATSAPP", "INSTAGRAM", "PHONE", "EMAIL", "INTERNAL"]
|
||||
},
|
||||
"direction": {
|
||||
"const": "IN"
|
||||
},
|
||||
"providerEventId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"providerMessageId": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 256
|
||||
},
|
||||
"eventType": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"occurredAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"receivedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"payloadRaw": {
|
||||
"type": ["object", "array", "string", "number", "boolean", "null"]
|
||||
},
|
||||
"payloadNormalized": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": ["threadExternalId", "contactExternalId", "text", "businessConnectionId"],
|
||||
"properties": {
|
||||
"threadExternalId": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 256
|
||||
},
|
||||
"contactExternalId": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 256
|
||||
},
|
||||
"text": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 4096
|
||||
},
|
||||
"businessConnectionId": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user