Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View 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`.

View 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
}
}
}
}
}