Compare commits
134 Commits
1b3b215bff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39cf198e11 | ||
|
|
2722aa860d | ||
|
|
bb2fab8b40 | ||
|
|
8eec280b9d | ||
|
|
08a31383f0 | ||
|
|
9283ab436f | ||
|
|
25623b8f65 | ||
|
|
29309419bf | ||
|
|
035f24387b | ||
|
|
2ca1e75651 | ||
|
|
e96b57a55f | ||
|
|
c2cf4e6dd8 | ||
|
|
ad0f61fa8d | ||
|
|
22e04e0a34 | ||
|
|
f1cf90adc7 | ||
|
|
e4870ce669 | ||
|
|
0df426d5d6 | ||
|
|
7d1bed0d67 | ||
|
|
fe4bd59248 | ||
|
|
12af9979ab | ||
|
|
881a8c6d39 | ||
|
|
b2a948889e | ||
|
|
aae2a03340 | ||
|
|
0a470d3922 | ||
|
|
5063dfdecf | ||
|
|
a0ff1d00f6 | ||
|
|
97fb67f68c | ||
|
|
6bae0300c8 | ||
|
|
ba3e5f7cac | ||
|
|
0f87586e81 | ||
|
|
6291797bb6 | ||
|
|
f4891e6932 | ||
|
|
5c29cde13d | ||
|
|
693a96cffd | ||
|
|
bf7f4ae933 | ||
|
|
b830f3728c | ||
|
|
5ff7dc8d65 | ||
|
|
7d647bef25 | ||
|
|
9b6e8291fe | ||
|
|
6e3763a5fd | ||
|
|
292d587fe1 | ||
|
|
1a6840cdc6 | ||
|
|
898f0dc0c5 | ||
|
|
cb685446a5 | ||
|
|
3ff9120070 | ||
|
|
c07ef2026d | ||
|
|
5492e0d05c | ||
|
|
643d8d02ba | ||
|
|
ac9c50b47d | ||
|
|
601de37ab0 | ||
|
|
c229bdee23 | ||
|
|
3775d881f9 | ||
|
|
195df8e16a | ||
|
|
19d001815c | ||
|
|
d892d0c604 | ||
|
|
a4d8d81de9 | ||
|
|
e5ad3809e0 | ||
|
|
00e036946c | ||
|
|
9505cecab2 | ||
|
|
77141978c5 | ||
|
|
227030b9ae | ||
|
|
638652b4d8 | ||
|
|
f553c26931 | ||
|
|
5657da13c1 | ||
|
|
947ef4d56d | ||
|
|
3e711a5533 | ||
|
|
b316b024be | ||
|
|
1db8e58da1 | ||
|
|
6cce211c0b | ||
|
|
c5d3a90413 | ||
|
|
c1e8f912d1 | ||
|
|
ed78532260 | ||
|
|
94d8d46693 | ||
|
|
79f1012f41 | ||
|
|
faea65dfcb | ||
|
|
bb628a7c0d | ||
|
|
2e1014d726 | ||
|
|
5fb8113ed7 | ||
|
|
2a3d18f326 | ||
|
|
0ed2a6b353 | ||
|
|
179cc39d53 | ||
|
|
6ab3b374a2 | ||
|
|
49c4757490 | ||
|
|
67a186e916 | ||
|
|
6d5402dcc1 | ||
|
|
295b3a3dda | ||
|
|
94c01516ba | ||
|
|
2eb2f3109c | ||
|
|
6bc0bfa156 | ||
|
|
cb2d12819c | ||
|
|
0bbeef5594 | ||
|
|
df8c06d313 | ||
|
|
f716a0ea26 | ||
|
|
7c019a6300 | ||
|
|
ec94dd6e2a | ||
|
|
40a225783d | ||
|
|
60b9bb9fd1 | ||
|
|
f6b738352b | ||
|
|
db49c4a830 | ||
|
|
6ad53e64c5 | ||
|
|
68cbe7bc64 | ||
|
|
a19ba07baa | ||
|
|
894210cd42 | ||
|
|
d3b751db65 | ||
|
|
aa465f65bd | ||
|
|
f076726362 | ||
|
|
acd974766a | ||
|
|
c94c229a1a | ||
|
|
43960d0374 | ||
|
|
5918a0593d | ||
|
|
8be6e7d581 | ||
|
|
82bc5dd04e | ||
|
|
d5f7280297 | ||
|
|
2b72d42956 | ||
|
|
47ed805ac7 | ||
|
|
e5030a321f | ||
|
|
64b25bb189 | ||
|
|
6e40c96abd | ||
|
|
70369255a2 | ||
|
|
2b5aab1210 | ||
|
|
f67cef22be | ||
|
|
4b9682e447 | ||
|
|
c9e4c3172e | ||
|
|
95fd9a64ce | ||
|
|
6bc154a1e6 | ||
|
|
23d8035571 | ||
|
|
21d6e440e3 | ||
|
|
d4af315e2e | ||
|
|
c41849745c | ||
|
|
9c712e0129 | ||
|
|
8ef266e09d | ||
|
|
43b487ccec | ||
|
|
ab5370c831 | ||
|
|
eb298e786e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ coverage
|
||||
npm-debug.log*
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
frontend/server/generated
|
||||
|
||||
12
.gitmodules
vendored
12
.gitmodules
vendored
@@ -1,3 +1,15 @@
|
||||
[submodule "instructions"]
|
||||
path = instructions
|
||||
url = git@gitea.dsrptlab.com:dsrptlab/instructions.git
|
||||
[submodule "frontend"]
|
||||
path = frontend
|
||||
url = git@gitea.dsrptlab.com:clientflow/frontend.git
|
||||
branch = main
|
||||
[submodule "backend"]
|
||||
path = backend
|
||||
url = git@gitea.dsrptlab.com:clientflow/backend.git
|
||||
branch = main
|
||||
[submodule "backend_worker"]
|
||||
path = backend_worker
|
||||
url = git@gitea.dsrptlab.com:clientflow/backend_worker.git
|
||||
branch = main
|
||||
|
||||
1
backend
Submodule
1
backend
Submodule
Submodule backend added at 42e9dc7bcb
1
backend_worker
Submodule
1
backend_worker
Submodule
Submodule backend_worker added at 653617983a
@@ -2,7 +2,9 @@ version = 1
|
||||
|
||||
[services]
|
||||
frontend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||
omni_outbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||
omni_inbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||
omni_chat = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||
langfuse = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "langfuse/docker-compose.yml" }
|
||||
backend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||
backend_worker = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||
telegram_backend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||
telegram_worker = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||
hatchet = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "hatchet/docker-compose.yml" }
|
||||
vault = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
services:
|
||||
langfuse-worker:
|
||||
image: docker.io/langfuse/langfuse-worker:3
|
||||
restart: always
|
||||
depends_on:
|
||||
langfuse-postgres:
|
||||
condition: service_healthy
|
||||
langfuse-minio:
|
||||
condition: service_healthy
|
||||
langfuse-redis:
|
||||
condition: service_healthy
|
||||
langfuse-clickhouse:
|
||||
condition: service_healthy
|
||||
environment: &langfuse_env
|
||||
NEXTAUTH_URL: "http://localhost:3001"
|
||||
DATABASE_URL: "postgresql://langfuse:langfuse@langfuse-postgres:5432/langfuse"
|
||||
SALT: "clientsflow-local-salt"
|
||||
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
TELEMETRY_ENABLED: "false"
|
||||
CLICKHOUSE_MIGRATION_URL: "clickhouse://langfuse-clickhouse:9000"
|
||||
CLICKHOUSE_URL: "http://langfuse-clickhouse:8123"
|
||||
CLICKHOUSE_USER: "clickhouse"
|
||||
CLICKHOUSE_PASSWORD: "clickhouse"
|
||||
CLICKHOUSE_CLUSTER_ENABLED: "false"
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: "langfuse"
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION: "auto"
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: "minio"
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: "miniosecret"
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: "http://langfuse-minio:9000"
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: "events/"
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: "langfuse"
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION: "auto"
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: "minio"
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: "miniosecret"
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: "http://langfuse-minio:9000"
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: "media/"
|
||||
REDIS_HOST: "langfuse-redis"
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_AUTH: "langfuse-redis"
|
||||
REDIS_TLS_ENABLED: "false"
|
||||
|
||||
langfuse-web:
|
||||
image: docker.io/langfuse/langfuse:3
|
||||
restart: always
|
||||
depends_on:
|
||||
langfuse-postgres:
|
||||
condition: service_healthy
|
||||
langfuse-minio:
|
||||
condition: service_healthy
|
||||
langfuse-redis:
|
||||
condition: service_healthy
|
||||
langfuse-clickhouse:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
<<: *langfuse_env
|
||||
NEXTAUTH_SECRET: "clientsflow-local-nextauth-secret"
|
||||
LANGFUSE_INIT_ORG_ID: "org-clientsflow"
|
||||
LANGFUSE_INIT_ORG_NAME: "Clientsflow Local"
|
||||
LANGFUSE_INIT_PROJECT_ID: "proj-clientsflow"
|
||||
LANGFUSE_INIT_PROJECT_NAME: "clientsflow"
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-local"
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-local"
|
||||
LANGFUSE_INIT_USER_EMAIL: "admin@clientsflow.local"
|
||||
LANGFUSE_INIT_USER_NAME: "Local Admin"
|
||||
LANGFUSE_INIT_USER_PASSWORD: "clientsflow-local-admin"
|
||||
|
||||
langfuse-clickhouse:
|
||||
image: docker.io/clickhouse/clickhouse-server:latest
|
||||
restart: always
|
||||
user: "101:101"
|
||||
environment:
|
||||
CLICKHOUSE_DB: "default"
|
||||
CLICKHOUSE_USER: "clickhouse"
|
||||
CLICKHOUSE_PASSWORD: "clickhouse"
|
||||
volumes:
|
||||
- langfuse_clickhouse_data:/var/lib/clickhouse
|
||||
- langfuse_clickhouse_logs:/var/log/clickhouse-server
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
langfuse-minio:
|
||||
image: cgr.dev/chainguard/minio:latest
|
||||
restart: always
|
||||
entrypoint: sh
|
||||
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
|
||||
environment:
|
||||
MINIO_ROOT_USER: "minio"
|
||||
MINIO_ROOT_PASSWORD: "miniosecret"
|
||||
volumes:
|
||||
- langfuse_minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 15
|
||||
start_period: 5s
|
||||
|
||||
langfuse-redis:
|
||||
image: docker.io/redis:7-alpine
|
||||
restart: always
|
||||
command: ["redis-server", "--requirepass", "langfuse-redis", "--maxmemory-policy", "noeviction"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli -a langfuse-redis ping | grep PONG"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
langfuse-postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: "langfuse"
|
||||
POSTGRES_USER: "langfuse"
|
||||
POSTGRES_PASSWORD: "langfuse"
|
||||
volumes:
|
||||
- langfuse_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U langfuse -d langfuse"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
langfuse_postgres_data:
|
||||
langfuse_clickhouse_data:
|
||||
langfuse_clickhouse_logs:
|
||||
langfuse_minio_data:
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
@@ -1,104 +1,109 @@
|
||||
# ADR-0001: Разделение Chat Platform на 3 сервиса
|
||||
# ADR-0001: Chat Platform Boundaries (GraphQL + Hatchet)
|
||||
|
||||
Дата: 2026-02-21
|
||||
Дата: 2026-03-08
|
||||
Статус: accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
Сейчас delivery уже вынесен отдельно, но часть omni-интеграции остается в приложении `frontend`.
|
||||
Нужна архитектура, где входящие вебхуки, доменная логика чатов и исходящая доставка развиваются независимо и не ломают друг друга.
|
||||
Нужна минимальная и предсказуемая схема из 6 сервисов:
|
||||
|
||||
Критичные требования:
|
||||
- `frontend`
|
||||
- `backend`
|
||||
- `backend_worker`
|
||||
- `telegram_backend`
|
||||
- `telegram_worker`
|
||||
- `hatchet`
|
||||
|
||||
- входящие webhook-события не теряются при рестартах;
|
||||
- delivery управляет retry/rate-limit централизованно;
|
||||
- omni_chat остается единственным местом доменной логики и хранения состояния диалогов;
|
||||
- сервисы можно обновлять независимо.
|
||||
Ключевые ограничения:
|
||||
|
||||
- основная Prisma/доменная БД только в `backend`;
|
||||
- `telegram_backend` и `telegram_worker` не содержат CRM-домен и не пишут в основную БД;
|
||||
- взаимодействие между сервисами только через GraphQL;
|
||||
- асинхронность и ретраи централизованы в Hatchet.
|
||||
|
||||
## Решение
|
||||
|
||||
Принимаем разделение на 3 сервиса:
|
||||
Принимаем архитектуру:
|
||||
|
||||
1. `omni_inbound`
|
||||
- Принимает вебхуки провайдеров.
|
||||
- Валидирует подпись/секрет.
|
||||
- Нормализует событие в универсальный envelope.
|
||||
- Пишет событие в durable queue (`receiver.flow`) с идемпотентным `jobId`.
|
||||
- Возвращает `200` только после успешной durable enqueue.
|
||||
- Не содержит бизнес-логики CRM.
|
||||
1. `backend`
|
||||
- владеет доменной моделью чатов и единственной основной Prisma-базой;
|
||||
- принимает inbound события от `telegram_worker` через GraphQL (`ingestTelegramInbound`);
|
||||
- создает outbound задачи в `telegram_backend` через GraphQL (`requestTelegramOutbound`);
|
||||
- принимает delivery-отчеты от `telegram_worker` через GraphQL (`reportTelegramOutbound`).
|
||||
|
||||
2. `omni_chat`
|
||||
- Потребляет входящие события из `receiver.flow`.
|
||||
- Разрешает идентичности и треды.
|
||||
- Создает/обновляет `OmniMessage`, `OmniThread`, статусы и доменные эффекты.
|
||||
- Формирует исходящие команды и кладет их в `sender.flow`.
|
||||
2. `telegram_backend`
|
||||
- принимает webhook Telegram;
|
||||
- нормализует payload в `OmniInboundEnvelopeV1`;
|
||||
- ставит задачи в Hatchet (`process-telegram-inbound`, `process-telegram-outbound`);
|
||||
- предоставляет GraphQL API для enqueue и отправки в Telegram API.
|
||||
|
||||
3. `omni_outbound`
|
||||
- Потребляет `sender.flow`.
|
||||
- Выполняет отправку в провайдеров (Telegram Business и др.).
|
||||
- Управляет retry/backoff/failover, DLQ и статусами доставки.
|
||||
- Не содержит UI и доменной логики чатов.
|
||||
3. `telegram_worker`
|
||||
- исполняет задачи Hatchet;
|
||||
- для inbound вызывает `backend /graphql`;
|
||||
- для outbound вызывает `telegram_backend /graphql` (`sendTelegramMessage`), затем `backend /graphql` (`reportTelegramOutbound`);
|
||||
- не имеет собственной Prisma-базы.
|
||||
|
||||
## Почему webhook и delivery разделены
|
||||
4. `backend_worker`
|
||||
- исполняет периодические backend workflow в Hatchet;
|
||||
- для cron-задач вызывает `backend /graphql` (без прямого доступа к Prisma).
|
||||
|
||||
- Входящий контур должен отвечать быстро и предсказуемо.
|
||||
- Исходящий контур живет с долгими retry и ограничениями провайдера.
|
||||
- Сбой внешнего API не должен блокировать прием входящих сообщений.
|
||||
5. `hatchet`
|
||||
- единый оркестратор задач, ретраев и backoff-политик.
|
||||
|
||||
## Потоки
|
||||
|
||||
### Inbound (Telegram -> CRM)
|
||||
|
||||
1. Telegram webhook приходит в `telegram_backend`.
|
||||
2. `telegram_backend` нормализует событие и enqueue в Hatchet `process-telegram-inbound`.
|
||||
3. `telegram_worker` исполняет задачу и вызывает `backend.ingestTelegramInbound`.
|
||||
4. `backend` сохраняет доменные изменения в своей БД.
|
||||
|
||||
### Outbound (CRM -> Telegram)
|
||||
|
||||
1. `backend` инициирует отправку (`requestTelegramOutbound`) в `telegram_backend`.
|
||||
2. `telegram_backend` enqueue в Hatchet `process-telegram-outbound`.
|
||||
3. `telegram_worker` вызывает `telegram_backend.sendTelegramMessage`.
|
||||
4. `telegram_worker` репортит итог в `backend.reportTelegramOutbound`.
|
||||
|
||||
### Calendar Predue (Backend cron)
|
||||
|
||||
1. Hatchet по cron запускает workflow в `backend_worker`.
|
||||
2. `backend_worker` вызывает `backend.syncCalendarPredueTimeline`.
|
||||
3. `backend` делает upsert `ClientTimelineEntry` для `CalendarEvent` в окне `startsAt - preDue`.
|
||||
|
||||
## Границы ответственности
|
||||
|
||||
`omni_inbound`:
|
||||
`backend`:
|
||||
- можно: вся бизнес-логика и состояние;
|
||||
- нельзя: прямой вызов Telegram API.
|
||||
|
||||
- можно: auth, валидация, нормализация, дедуп, enqueue;
|
||||
- нельзя: запись доменных сущностей CRM, принятие продуктовых решений.
|
||||
`telegram_backend`:
|
||||
- можно: webhook ingress, нормализация, enqueue, адаптер Telegram API;
|
||||
- нельзя: доменные записи CRM.
|
||||
|
||||
`omni_chat`:
|
||||
`telegram_worker`:
|
||||
- можно: исполнение задач, ретраи, orchestration шагов;
|
||||
- нельзя: хранение CRM-состояния и прямой доступ к основной БД.
|
||||
|
||||
- можно: вся доменная модель чатов, orchestration, бизнес-правила;
|
||||
- нельзя: прямые вызовы провайдеров из sync API-контекста.
|
||||
`backend_worker`:
|
||||
- можно: периодические orchestration задачи через Hatchet;
|
||||
- нельзя: прямой доступ к основной БД (только через backend GraphQL).
|
||||
|
||||
`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`.
|
||||
- webhook отвечает `200` только после успешной постановки задачи в Hatchet;
|
||||
- при недоступности сервисов задача ретраится Hatchet;
|
||||
- inbound обработка идемпотентна через `idempotencyKey` и provider identifiers в `backend`.
|
||||
- календарный sync использует advisory-lock в `backend`, поэтому параллельные cron-run безопасны.
|
||||
|
||||
## Последствия
|
||||
|
||||
Плюсы:
|
||||
|
||||
- независимые релизы и масштабирование по ролям;
|
||||
- меньше blast radius при инцидентах;
|
||||
- проще подключать новые каналы поверх общего контракта.
|
||||
- меньше скрытых связей;
|
||||
- изоляция доменной БД в `backend`;
|
||||
- единая точка ретраев/оркестрации (Hatchet).
|
||||
|
||||
Минусы:
|
||||
|
||||
- больше инфраструктурных компонентов (очереди, мониторинг, трассировка);
|
||||
- требуется дисциплина по контрактам между сервисами.
|
||||
|
||||
## План внедрения
|
||||
|
||||
1. Вводим `omni_inbound` как отдельный сервис для Telegram Business.
|
||||
2. Потребление `receiver.flow` реализуем в `omni_chat`.
|
||||
3. Текущее исходящее API оставляем за `omni_outbound`.
|
||||
4. После стабилизации выносим оставшиеся omni endpoint'ы из `frontend` в `omni_chat`/`omni_inbound`.
|
||||
- выше требования к стабильности GraphQL-контрактов между сервисами;
|
||||
- нужна наблюдаемость по цепочкам `telegram_backend -> hatchet -> telegram_worker -> backend` и `hatchet -> backend_worker -> backend`.
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
## Single source of truth
|
||||
|
||||
- Canonical Prisma schema: `frontend/prisma/schema.prisma`.
|
||||
- Service copies:
|
||||
- `omni_chat/prisma/schema.prisma`
|
||||
- `omni_outbound/prisma/schema.prisma`
|
||||
- Service copy:
|
||||
- `backend/prisma/schema.prisma`
|
||||
|
||||
## Update flow
|
||||
|
||||
1. Edit only `frontend/prisma/schema.prisma`.
|
||||
2. Run `./scripts/prisma-sync.sh`.
|
||||
3. Run `./scripts/prisma-check.sh`.
|
||||
4. Commit changed schema copies.
|
||||
4. Commit changed schema copy.
|
||||
|
||||
## Rollout policy
|
||||
|
||||
- Schema rollout (`prisma db push` / migrations) is allowed only in `frontend`.
|
||||
- `omni_chat` and `omni_outbound` must use generated Prisma client only.
|
||||
- `backend` must use generated Prisma client only.
|
||||
|
||||
1
frontend
Submodule
1
frontend
Submodule
Submodule frontend added at 7656cf5f44
@@ -1,9 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
npm-debug.log*
|
||||
dist
|
||||
coverage
|
||||
@@ -1,35 +0,0 @@
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/clientsflow?schema=public"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# Agent (LangGraph + OpenRouter)
|
||||
OPENROUTER_API_KEY=""
|
||||
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
|
||||
OPENROUTER_MODEL="openai/gpt-4o-mini"
|
||||
# Optional headers for OpenRouter ranking/analytics
|
||||
OPENROUTER_HTTP_REFERER=""
|
||||
OPENROUTER_X_TITLE="clientsflow"
|
||||
# Enable reasoning payload for models that support it: 1 or 0
|
||||
OPENROUTER_REASONING_ENABLED="0"
|
||||
|
||||
# Langfuse local tracing (optional)
|
||||
LANGFUSE_ENABLED="true"
|
||||
LANGFUSE_BASE_URL="http://localhost:3001"
|
||||
LANGFUSE_PUBLIC_KEY="pk-lf-local"
|
||||
LANGFUSE_SECRET_KEY="sk-lf-local"
|
||||
|
||||
# Optional fallback (OpenAI-compatible)
|
||||
OPENAI_API_KEY=""
|
||||
OPENAI_MODEL="gpt-4o-mini"
|
||||
# "langgraph" (default) or "rule"
|
||||
CF_AGENT_MODE="langgraph"
|
||||
CF_WHISPER_MODEL="Xenova/whisper-small"
|
||||
CF_WHISPER_LANGUAGE="ru"
|
||||
|
||||
TELEGRAM_BOT_TOKEN=""
|
||||
TELEGRAM_WEBHOOK_SECRET=""
|
||||
TELEGRAM_DEFAULT_TEAM_ID="demo-team"
|
||||
|
||||
# Frontend GraphQL endpoint for Apollo client runtime
|
||||
GRAPHQL_HTTP_ENDPOINT="http://localhost:3000/api/graphql"
|
||||
# Remote GraphQL schema URL for codegen (used by `pnpm codegen` / `npm run codegen`)
|
||||
GRAPHQL_SCHEMA_URL=""
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { StorybookConfig } from "@storybook/vue3-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../components/**/*.stories.@(ts|tsx)"],
|
||||
addons: ["@storybook/addon-essentials", "@storybook/addon-interactions"],
|
||||
framework: {
|
||||
name: "@storybook/vue3-vite",
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Preview } from "@storybook/vue3-vite";
|
||||
import "../assets/css/main.css";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -1,33 +0,0 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts --legacy-peer-deps
|
||||
RUN set -eux; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
if [ "$arch" = "amd64" ]; then \
|
||||
npm rebuild sharp --platform=linux --arch=x64 || npm install --no-save sharp --platform=linux --arch=x64; \
|
||||
elif [ "$arch" = "arm64" ]; then \
|
||||
npm rebuild sharp --platform=linux --arch=arm64 || npm install --no-save sharp --platform=linux --arch=arm64; \
|
||||
else \
|
||||
npm rebuild sharp || true; \
|
||||
fi
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build server bundle at image build time.
|
||||
RUN npm run postinstall && npm run build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NITRO_HOST=0.0.0.0
|
||||
ENV NITRO_PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Keep schema in sync, then start Nitro production server.
|
||||
CMD ["bash", "-lc", "npx prisma db push && node .output/server/index.mjs"]
|
||||
6970
frontend/app.vue
6970
frontend/app.vue
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
:root {
|
||||
--color-accent: #1e6bff;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, rgba(30, 107, 255, 0.08), transparent 40%),
|
||||
radial-gradient(circle at 0% 100%, rgba(30, 107, 255, 0.08), transparent 40%),
|
||||
#f5f7fb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const schemaUrl = process.env.GRAPHQL_SCHEMA_URL || process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: schemaUrl,
|
||||
documents: ["graphql/operations/**/*.graphql"],
|
||||
generates: {
|
||||
"composables/graphql/generated.ts": {
|
||||
plugins: [
|
||||
"typescript",
|
||||
"typescript-operations",
|
||||
"typed-document-node",
|
||||
"typescript-vue-apollo",
|
||||
],
|
||||
config: {
|
||||
withCompositionFunctions: true,
|
||||
vueCompositionApiImportFrom: "vue",
|
||||
dedupeFragments: true,
|
||||
namingConvention: "keep",
|
||||
},
|
||||
},
|
||||
},
|
||||
ignoreNoDocuments: false,
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/vue3";
|
||||
import ContactCollaborativeEditor from "./ContactCollaborativeEditor.client.vue";
|
||||
|
||||
const meta: Meta<typeof ContactCollaborativeEditor> = {
|
||||
title: "Components/ContactCollaborativeEditor",
|
||||
component: ContactCollaborativeEditor,
|
||||
args: {
|
||||
modelValue: "<p>Client summary draft...</p>",
|
||||
room: "storybook-contact-editor-room",
|
||||
placeholder: "Type here...",
|
||||
plain: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ContactCollaborativeEditor>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,238 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { EditorContent, useEditor } from "@tiptap/vue-3";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import * as Y from "yjs";
|
||||
import { WebrtcProvider } from "y-webrtc";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
room: string;
|
||||
placeholder?: string;
|
||||
plain?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const ydoc = new Y.Doc();
|
||||
const provider = new WebrtcProvider(props.room, ydoc);
|
||||
const isBootstrapped = ref(false);
|
||||
const awarenessVersion = ref(0);
|
||||
|
||||
const userPalette = ["#2563eb", "#0ea5e9", "#14b8a6", "#16a34a", "#eab308", "#f97316", "#ef4444"];
|
||||
const currentUser = {
|
||||
name: `You ${Math.floor(Math.random() * 900 + 100)}`,
|
||||
color: userPalette[Math.floor(Math.random() * userPalette.length)],
|
||||
};
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function normalizeInitialContent(value: string) {
|
||||
const input = value.trim();
|
||||
if (!input) return "<p></p>";
|
||||
if (input.includes("<") && input.includes(">")) return value;
|
||||
|
||||
const blocks = value
|
||||
.replaceAll("\r\n", "\n")
|
||||
.split(/\n\n+/)
|
||||
.map((block) => `<p>${escapeHtml(block).replaceAll("\n", "<br />")}</p>`);
|
||||
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: props.placeholder ?? "Type here...",
|
||||
includeChildren: true,
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
field: "contact",
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
user: currentUser,
|
||||
}),
|
||||
],
|
||||
autofocus: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "contact-editor-content",
|
||||
spellcheck: "true",
|
||||
},
|
||||
},
|
||||
onCreate: ({ editor: instance }) => {
|
||||
if (instance.isEmpty) {
|
||||
instance.commands.setContent(normalizeInitialContent(props.modelValue), false);
|
||||
}
|
||||
isBootstrapped.value = true;
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
emit("update:modelValue", instance.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(incoming) => {
|
||||
const instance = editor.value;
|
||||
if (!instance || !isBootstrapped.value) return;
|
||||
|
||||
const current = instance.getHTML();
|
||||
if (incoming === current || !incoming.trim()) return;
|
||||
|
||||
if (instance.isEmpty) {
|
||||
instance.commands.setContent(normalizeInitialContent(incoming), false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const peerCount = computed(() => {
|
||||
awarenessVersion.value;
|
||||
const states = Array.from(provider.awareness.getStates().values());
|
||||
return states.length;
|
||||
});
|
||||
|
||||
const onAwarenessChange = () => {
|
||||
awarenessVersion.value += 1;
|
||||
};
|
||||
|
||||
provider.awareness.on("change", onAwarenessChange);
|
||||
|
||||
function runCommand(action: () => void) {
|
||||
const instance = editor.value;
|
||||
if (!instance) return;
|
||||
action();
|
||||
instance.commands.focus();
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
provider.awareness.off("change", onAwarenessChange);
|
||||
editor.value?.destroy();
|
||||
provider.destroy();
|
||||
ydoc.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.plain ? 'space-y-2' : 'space-y-3'">
|
||||
<div :class="props.plain ? 'flex flex-wrap items-center justify-between gap-2 bg-transparent p-0' : 'flex flex-wrap items-center justify-between gap-2 rounded-xl border border-base-300 bg-base-100 p-2'">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('bold') ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleBold().run())"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('italic') ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleItalic().run())"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('bulletList') ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleBulletList().run())"
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('heading', { level: 2 }) ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleHeading({ level: 2 }).run())"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('blockquote') ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleBlockquote().run())"
|
||||
>
|
||||
Quote
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="px-1 text-xs text-base-content/60">Live: {{ peerCount }}</p>
|
||||
</div>
|
||||
|
||||
<div :class="props.plain ? 'bg-transparent p-0' : 'rounded-xl border border-base-300 bg-base-100 p-2'">
|
||||
<EditorContent :editor="editor" class="contact-editor min-h-[420px]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-editor :deep(.ProseMirror) {
|
||||
min-height: 390px;
|
||||
padding: 0.75rem;
|
||||
outline: none;
|
||||
line-height: 1.65;
|
||||
color: rgba(17, 24, 39, 0.95);
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror p) {
|
||||
margin: 0.45rem 0;
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror h1),
|
||||
.contact-editor :deep(.ProseMirror h2),
|
||||
.contact-editor :deep(.ProseMirror h3) {
|
||||
margin: 0.75rem 0 0.45rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror ul),
|
||||
.contact-editor :deep(.ProseMirror ol) {
|
||||
margin: 0.45rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror blockquote) {
|
||||
margin: 0.6rem 0;
|
||||
border-left: 3px solid rgba(30, 107, 255, 0.5);
|
||||
padding-left: 0.75rem;
|
||||
color: rgba(55, 65, 81, 0.95);
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror .collaboration-cursor__caret) {
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
border-left: 1px solid currentColor;
|
||||
border-right: 1px solid currentColor;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror .collaboration-cursor__label) {
|
||||
position: absolute;
|
||||
top: -1.35em;
|
||||
left: -1px;
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
export const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:";
|
||||
|
||||
export function buildContactDocumentScope(contactId: string, contactName: string) {
|
||||
return `${CONTACT_DOCUMENT_SCOPE_PREFIX}${encodeURIComponent(contactId)}:${encodeURIComponent(contactName)}`;
|
||||
}
|
||||
|
||||
export function parseContactDocumentScope(scope: string) {
|
||||
const raw = String(scope ?? "").trim();
|
||||
if (!raw.startsWith(CONTACT_DOCUMENT_SCOPE_PREFIX)) return null;
|
||||
const payload = raw.slice(CONTACT_DOCUMENT_SCOPE_PREFIX.length);
|
||||
const [idRaw, ...nameParts] = payload.split(":");
|
||||
const contactId = decodeURIComponent(idRaw ?? "").trim();
|
||||
const contactName = decodeURIComponent(nameParts.join(":") ?? "").trim();
|
||||
if (!contactId) return null;
|
||||
return {
|
||||
contactId,
|
||||
contactName,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDocumentScope(scope: string) {
|
||||
const linked = parseContactDocumentScope(scope);
|
||||
if (!linked) return scope;
|
||||
return linked.contactName ? `Contact · ${linked.contactName}` : "Contact document";
|
||||
}
|
||||
|
||||
export function isDocumentLinkedToContact(
|
||||
scope: string,
|
||||
contact: { id: string; name: string } | null | undefined,
|
||||
) {
|
||||
if (!contact) return false;
|
||||
const linked = parseContactDocumentScope(scope);
|
||||
if (!linked) return false;
|
||||
if (linked.contactId) return linked.contactId === contact.id;
|
||||
return Boolean(linked.contactName && linked.contactName === contact.name);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
mutation ArchiveCalendarEventMutation($input: ArchiveCalendarEventInput!) {
|
||||
archiveCalendarEvent(input: $input) {
|
||||
id
|
||||
title
|
||||
start
|
||||
end
|
||||
contact
|
||||
note
|
||||
isArchived
|
||||
createdAt
|
||||
archiveNote
|
||||
archivedAt
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation ArchiveChatConversationMutation($id: ID!) {
|
||||
archiveChatConversation(id: $id) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
query ChatConversationsQuery {
|
||||
chatConversations {
|
||||
id
|
||||
title
|
||||
createdAt
|
||||
updatedAt
|
||||
lastMessageAt
|
||||
lastMessageText
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
query ChatMessagesQuery {
|
||||
chatMessages {
|
||||
id
|
||||
role
|
||||
text
|
||||
messageKind
|
||||
requestId
|
||||
eventType
|
||||
phase
|
||||
transient
|
||||
thinking
|
||||
tools
|
||||
toolRuns {
|
||||
name
|
||||
status
|
||||
input
|
||||
output
|
||||
at
|
||||
}
|
||||
changeSetId
|
||||
changeStatus
|
||||
changeSummary
|
||||
changeItems {
|
||||
id
|
||||
entity
|
||||
entityId
|
||||
action
|
||||
title
|
||||
before
|
||||
after
|
||||
rolledBack
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation ConfirmLatestChangeSetMutation {
|
||||
confirmLatestChangeSet {
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
mutation CreateCalendarEventMutation($input: CreateCalendarEventInput!) {
|
||||
createCalendarEvent(input: $input) {
|
||||
id
|
||||
title
|
||||
start
|
||||
end
|
||||
contact
|
||||
note
|
||||
isArchived
|
||||
createdAt
|
||||
archiveNote
|
||||
archivedAt
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
mutation CreateChatConversationMutation($title: String) {
|
||||
createChatConversation(title: $title) {
|
||||
id
|
||||
title
|
||||
createdAt
|
||||
updatedAt
|
||||
lastMessageAt
|
||||
lastMessageText
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation CreateCommunicationMutation($input: CreateCommunicationInput!) {
|
||||
createCommunication(input: $input) {
|
||||
ok
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
mutation CreateWorkspaceDocument($input: CreateWorkspaceDocumentInput!) {
|
||||
createWorkspaceDocument(input: $input) {
|
||||
id
|
||||
title
|
||||
type
|
||||
owner
|
||||
scope
|
||||
updatedAt
|
||||
summary
|
||||
body
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
query DashboardQuery {
|
||||
dashboard {
|
||||
contacts {
|
||||
id
|
||||
name
|
||||
avatar
|
||||
company
|
||||
country
|
||||
location
|
||||
channels
|
||||
lastContactAt
|
||||
description
|
||||
}
|
||||
communications {
|
||||
id
|
||||
at
|
||||
contactId
|
||||
contact
|
||||
channel
|
||||
kind
|
||||
direction
|
||||
text
|
||||
audioUrl
|
||||
duration
|
||||
transcript
|
||||
deliveryStatus
|
||||
}
|
||||
calendar {
|
||||
id
|
||||
title
|
||||
start
|
||||
end
|
||||
contact
|
||||
note
|
||||
isArchived
|
||||
createdAt
|
||||
archiveNote
|
||||
archivedAt
|
||||
}
|
||||
deals {
|
||||
id
|
||||
contact
|
||||
title
|
||||
company
|
||||
stage
|
||||
amount
|
||||
nextStep
|
||||
summary
|
||||
currentStepId
|
||||
steps {
|
||||
id
|
||||
title
|
||||
description
|
||||
status
|
||||
dueAt
|
||||
order
|
||||
completedAt
|
||||
}
|
||||
}
|
||||
feed {
|
||||
id
|
||||
at
|
||||
contact
|
||||
text
|
||||
proposal {
|
||||
title
|
||||
details
|
||||
key
|
||||
}
|
||||
decision
|
||||
decisionNote
|
||||
}
|
||||
pins {
|
||||
id
|
||||
contact
|
||||
text
|
||||
}
|
||||
documents {
|
||||
id
|
||||
title
|
||||
type
|
||||
owner
|
||||
scope
|
||||
updatedAt
|
||||
summary
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation LogPilotNoteMutation($text: String!) {
|
||||
logPilotNote(text: $text) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation LoginMutation($phone: String!, $password: String!) {
|
||||
login(phone: $phone, password: $password) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation LogoutMutation {
|
||||
logout {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
query MeQuery {
|
||||
me {
|
||||
user {
|
||||
id
|
||||
phone
|
||||
name
|
||||
}
|
||||
team {
|
||||
id
|
||||
name
|
||||
}
|
||||
conversation {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation RollbackChangeSetItemsMutation($changeSetId: ID!, $itemIds: [ID!]!) {
|
||||
rollbackChangeSetItems(changeSetId: $changeSetId, itemIds: $itemIds) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation RollbackLatestChangeSetMutation {
|
||||
rollbackLatestChangeSet {
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation SelectChatConversationMutation($id: ID!) {
|
||||
selectChatConversation(id: $id) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation SendPilotMessageMutation($text: String!) {
|
||||
sendPilotMessage(text: $text) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation ToggleContactPinMutation($contact: String!, $text: String!) {
|
||||
toggleContactPin(contact: $contact, text: $text) {
|
||||
ok
|
||||
pinned
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation UpdateCommunicationTranscriptMutation($id: ID!, $transcript: [String!]!) {
|
||||
updateCommunicationTranscript(id: $id, transcript: $transcript) {
|
||||
ok
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation UpdateFeedDecisionMutation($id: ID!, $decision: String!, $decisionNote: String) {
|
||||
updateFeedDecision(id: $id, decision: $decision, decisionNote: $decisionNote) {
|
||||
ok
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-07-15",
|
||||
devtools: { enabled: true },
|
||||
css: ["~/assets/css/main.css"],
|
||||
nitro: {
|
||||
experimental: {
|
||||
websocket: true,
|
||||
},
|
||||
},
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss() as any],
|
||||
},
|
||||
|
||||
modules: ["@nuxt/eslint", "@nuxtjs/apollo"],
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
graphqlHttpEndpoint: process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql",
|
||||
},
|
||||
},
|
||||
|
||||
apollo: {
|
||||
clients: {
|
||||
default: {
|
||||
httpEndpoint: process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql",
|
||||
connectToDevTools: process.dev,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
23266
frontend/package-lock.json
generated
23266
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"name": "crm-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "node prisma/seed.mjs",
|
||||
"dataset:export": "node scripts/export-dataset.mjs",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"postinstall": "nuxt prepare && prisma generate",
|
||||
"preview": "nuxt preview",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"codegen": "graphql-codegen --config codegen.ts",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/vue": "^3.0.91",
|
||||
"@apollo/client": "^3.14.0",
|
||||
"@langchain/core": "^0.3.77",
|
||||
"@langchain/langgraph": "^0.2.74",
|
||||
"@langchain/openai": "^0.6.9",
|
||||
"@nuxt/eslint": "^1.15.1",
|
||||
"@nuxtjs/apollo": "^5.0.0-alpha.15",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/extension-collaboration": "^2.27.2",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.27.2",
|
||||
"@tiptap/extension-placeholder": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"@tiptap/vue-3": "^2.27.2",
|
||||
"@vue/apollo-composable": "^4.2.2",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"ai": "^6.0.91",
|
||||
"bullmq": "^5.58.2",
|
||||
"daisyui": "^5.5.18",
|
||||
"graphql": "^16.12.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"ioredis": "^5.7.0",
|
||||
"langfuse": "^3.38.6",
|
||||
"langsmith": "^0.5.4",
|
||||
"nuxt": "^4.3.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.27",
|
||||
"wavesurfer.js": "^7.12.1",
|
||||
"y-prosemirror": "^1.3.7",
|
||||
"y-webrtc": "^10.3.0",
|
||||
"yjs": "^13.6.29",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^6.1.1",
|
||||
"@graphql-codegen/typed-document-node": "^6.1.6",
|
||||
"@graphql-codegen/typescript": "^5.0.8",
|
||||
"@graphql-codegen/typescript-operations": "^5.0.8",
|
||||
"@graphql-codegen/typescript-vue-apollo": "^4.1.2",
|
||||
"@storybook/addon-essentials": "^8.6.17",
|
||||
"@storybook/addon-interactions": "^8.6.17",
|
||||
"@storybook/test": "^8.6.17",
|
||||
"@storybook/vue3-vite": "^8.6.17",
|
||||
"prisma": "^6.16.1",
|
||||
"storybook": "^8.6.17",
|
||||
"tsx": "^4.20.5"
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum TeamRole {
|
||||
OWNER
|
||||
MEMBER
|
||||
}
|
||||
|
||||
enum MessageDirection {
|
||||
IN
|
||||
OUT
|
||||
}
|
||||
|
||||
enum MessageChannel {
|
||||
TELEGRAM
|
||||
WHATSAPP
|
||||
INSTAGRAM
|
||||
PHONE
|
||||
EMAIL
|
||||
INTERNAL
|
||||
}
|
||||
|
||||
enum ContactMessageKind {
|
||||
MESSAGE
|
||||
CALL
|
||||
}
|
||||
|
||||
enum ChatRole {
|
||||
USER
|
||||
ASSISTANT
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum OmniMessageStatus {
|
||||
PENDING
|
||||
SENT
|
||||
FAILED
|
||||
DELIVERED
|
||||
READ
|
||||
}
|
||||
|
||||
enum FeedCardDecision {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum WorkspaceDocumentType {
|
||||
Regulation
|
||||
Playbook
|
||||
Policy
|
||||
Template
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members TeamMember[]
|
||||
contacts Contact[]
|
||||
calendarEvents CalendarEvent[]
|
||||
deals Deal[]
|
||||
conversations ChatConversation[]
|
||||
chatMessages ChatMessage[]
|
||||
|
||||
omniThreads OmniThread[]
|
||||
omniMessages OmniMessage[]
|
||||
omniIdentities OmniContactIdentity[]
|
||||
telegramBusinessConnections TelegramBusinessConnection[]
|
||||
|
||||
feedCards FeedCard[]
|
||||
contactPins ContactPin[]
|
||||
documents WorkspaceDocument[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
phone String @unique
|
||||
passwordHash String
|
||||
email String? @unique
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships TeamMember[]
|
||||
conversations ChatConversation[] @relation("ConversationCreator")
|
||||
chatMessages ChatMessage[] @relation("ChatAuthor")
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
userId String
|
||||
role TeamRole @default(MEMBER)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Contact {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
name String
|
||||
company String?
|
||||
country String?
|
||||
location String?
|
||||
avatarUrl String?
|
||||
email String?
|
||||
phone String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
note ContactNote?
|
||||
messages ContactMessage[]
|
||||
events CalendarEvent[]
|
||||
deals Deal[]
|
||||
feedCards FeedCard[]
|
||||
pins ContactPin[]
|
||||
|
||||
omniThreads OmniThread[]
|
||||
omniMessages OmniMessage[]
|
||||
omniIdentities OmniContactIdentity[]
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model ContactNote {
|
||||
id String @id @default(cuid())
|
||||
contactId String @unique
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ContactMessage {
|
||||
id String @id @default(cuid())
|
||||
contactId String
|
||||
kind ContactMessageKind @default(MESSAGE)
|
||||
direction MessageDirection
|
||||
channel MessageChannel
|
||||
content String
|
||||
audioUrl String?
|
||||
durationSec Int?
|
||||
transcriptJson Json?
|
||||
occurredAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([contactId, occurredAt])
|
||||
}
|
||||
|
||||
model OmniContactIdentity {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
channel MessageChannel
|
||||
externalId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, channel, externalId])
|
||||
@@index([contactId])
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model OmniThread {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
channel MessageChannel
|
||||
externalChatId String
|
||||
businessConnectionId String?
|
||||
title String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
messages OmniMessage[]
|
||||
|
||||
@@unique([teamId, channel, externalChatId, businessConnectionId])
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([contactId, updatedAt])
|
||||
}
|
||||
|
||||
model OmniMessage {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
threadId String
|
||||
direction MessageDirection
|
||||
channel MessageChannel
|
||||
status OmniMessageStatus @default(PENDING)
|
||||
text String
|
||||
providerMessageId String?
|
||||
providerUpdateId String?
|
||||
rawJson Json?
|
||||
occurredAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
thread OmniThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([threadId, providerMessageId])
|
||||
@@index([teamId, occurredAt])
|
||||
@@index([threadId, occurredAt])
|
||||
}
|
||||
|
||||
model TelegramBusinessConnection {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
businessConnectionId String
|
||||
isEnabled Boolean?
|
||||
canReply Boolean?
|
||||
rawJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, businessConnectionId])
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model CalendarEvent {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String?
|
||||
title String
|
||||
startsAt DateTime
|
||||
endsAt DateTime?
|
||||
note String?
|
||||
isArchived Boolean @default(false)
|
||||
archiveNote String?
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([startsAt])
|
||||
@@index([contactId, startsAt])
|
||||
@@index([teamId, startsAt])
|
||||
}
|
||||
|
||||
model Deal {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
title String
|
||||
stage String
|
||||
amount Int?
|
||||
nextStep String?
|
||||
summary String?
|
||||
currentStepId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
steps DealStep[]
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([contactId, updatedAt])
|
||||
@@index([currentStepId])
|
||||
}
|
||||
|
||||
model DealStep {
|
||||
id String @id @default(cuid())
|
||||
dealId String
|
||||
title String
|
||||
description String?
|
||||
status String @default("todo")
|
||||
dueAt DateTime?
|
||||
order Int @default(0)
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([dealId, order])
|
||||
@@index([status, dueAt])
|
||||
}
|
||||
|
||||
model ChatConversation {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
createdByUserId String
|
||||
title String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([createdByUserId])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
conversationId String
|
||||
authorUserId String?
|
||||
role ChatRole
|
||||
text String
|
||||
planJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
authorUser User? @relation("ChatAuthor", fields: [authorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([teamId, createdAt])
|
||||
@@index([conversationId, createdAt])
|
||||
}
|
||||
|
||||
model FeedCard {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String?
|
||||
happenedAt DateTime
|
||||
text String
|
||||
proposalJson Json
|
||||
decision FeedCardDecision @default(PENDING)
|
||||
decisionNote String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([teamId, happenedAt])
|
||||
@@index([contactId, happenedAt])
|
||||
}
|
||||
|
||||
model ContactPin {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
text String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([contactId, updatedAt])
|
||||
}
|
||||
|
||||
model WorkspaceDocument {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
title String
|
||||
type WorkspaceDocumentType
|
||||
owner String
|
||||
scope String
|
||||
summary String
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
function loadEnvFromDotEnv() {
|
||||
const p = path.resolve(process.cwd(), ".env");
|
||||
if (!fs.existsSync(p)) return;
|
||||
const raw = fs.readFileSync(p, "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
let val = trimmed.slice(idx + 1).trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
if (!key) continue;
|
||||
if (key === "DATABASE_URL") {
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
continue;
|
||||
}
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFromDotEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const LOGIN_PHONE = "+15550000001";
|
||||
const LOGIN_PASSWORD = "ConnectFlow#2026";
|
||||
const LOGIN_NAME = "Владелец Connect";
|
||||
const REF_DATE_ISO = "2026-02-20T12:00:00.000Z";
|
||||
|
||||
const SCRYPT_KEY_LENGTH = 64;
|
||||
|
||||
function hashPassword(password) {
|
||||
const salt = randomBytes(16).toString("base64url");
|
||||
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
|
||||
return `scrypt$${salt}$${digest}`;
|
||||
}
|
||||
|
||||
function atOffset(days, hour, minute) {
|
||||
const d = new Date(REF_DATE_ISO);
|
||||
d.setDate(d.getDate() + days);
|
||||
d.setHours(hour, minute, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function plusMinutes(date, minutes) {
|
||||
const d = new Date(date);
|
||||
d.setMinutes(d.getMinutes() + minutes);
|
||||
return d;
|
||||
}
|
||||
|
||||
function buildOdooAiContacts(teamId) {
|
||||
const prospects = [
|
||||
{ name: "Оливия Рид", company: "РитейлНова", country: "США", location: "Нью-Йорк", email: "olivia.reed@retailnova.com", phone: "+1 555 120 0101" },
|
||||
{ name: "Даниэль Ким", company: "ФорджПик Производство", country: "США", location: "Чикаго", email: "daniel.kim@forgepeak.com", phone: "+1 555 120 0102" },
|
||||
{ name: "Марта Алонсо", company: "Иберия Фудс Групп", country: "Испания", location: "Барселона", email: "marta.alonso@iberiafoods.es", phone: "+34 91 555 0103" },
|
||||
{ name: "Юсеф Хаддад", company: "ГалфТрейд Дистрибуция", country: "ОАЭ", location: "Дубай", email: "youssef.haddad@gulftrade.ae", phone: "+971 4 555 0104" },
|
||||
{ name: "Эмма Коллинз", company: "НортБридж Логистика", country: "Великобритания", location: "Лондон", email: "emma.collins@northbridge.co.uk", phone: "+44 20 5550 0105" },
|
||||
{ name: "Ноа Фишер", company: "Бергман Автозапчасти", country: "Германия", location: "Мюнхен", email: "noah.fischer@bergmann-auto.de", phone: "+49 89 5550 0106" },
|
||||
{ name: "Ава Чой", company: "Пасифик МедТех Сапплай", country: "Сингапур", location: "Сингапур", email: "ava.choi@pacificmedtech.sg", phone: "+65 6555 0107" },
|
||||
{ name: "Лиам Дюбуа", company: "ГексаКоммерс", country: "Франция", location: "Париж", email: "liam.dubois@hexacommerce.fr", phone: "+33 1 55 50 0108" },
|
||||
{ name: "Майя Шах", company: "Зенит Консьюмер Брендс", country: "Канада", location: "Торонто", email: "maya.shah@zenithbrands.ca", phone: "+1 416 555 0109" },
|
||||
{ name: "Арман Петросян", company: "Арарат Электроникс", country: "Армения", location: "Ереван", email: "arman.petrosyan@ararat-electronics.am", phone: "+374 10 555110" },
|
||||
{ name: "София Мартинес", company: "Санлайн Товары для дома", country: "США", location: "Остин", email: "sophia.martinez@sunlinehg.com", phone: "+1 555 120 0111" },
|
||||
{ name: "Лео Новак", company: "ЦентралБилд Материалы", country: "Германия", location: "Берлин", email: "leo.novak@centralbuild.de", phone: "+49 30 5550 0112" },
|
||||
{ name: "Айла Грант", company: "БлюХарбор Фарма", country: "Великобритания", location: "Манчестер", email: "isla.grant@blueharbor.co.uk", phone: "+44 161 555 0113" },
|
||||
{ name: "Матео Росси", company: "Милано Фэшн Хаус", country: "Италия", location: "Милан", email: "mateo.rossi@milanofh.it", phone: "+39 02 5550 0114" },
|
||||
{ name: "Нина Волкова", company: "Полар АгриТех", country: "Казахстан", location: "Алматы", email: "nina.volkova@polaragri.kz", phone: "+7 727 555 0115" },
|
||||
{ name: "Итан Пак", company: "Вертекс Компонентс", country: "Южная Корея", location: "Сеул", email: "ethan.park@vertexcomponents.kr", phone: "+82 2 555 0116" },
|
||||
{ name: "Зара Хан", company: "Кресент Ритейл Чейн", country: "ОАЭ", location: "Абу-Даби", email: "zara.khan@crescentretail.ae", phone: "+971 2 555 0117" },
|
||||
{ name: "Уго Силва", company: "Лузо Индастриал Системс", country: "Португалия", location: "Лиссабон", email: "hugo.silva@lusois.pt", phone: "+351 21 555 0118" },
|
||||
{ name: "Хлоя Бернар", company: "Сантекс Сеть Клиник", country: "Франция", location: "Лион", email: "chloe.bernard@santex.fr", phone: "+33 4 55 50 0119" },
|
||||
{ name: "Джеймс Уокер", company: "Метро Оптовая Группа", country: "США", location: "Лос-Анджелес", email: "james.walker@metrowholesale.com", phone: "+1 555 120 0120" },
|
||||
];
|
||||
|
||||
return prospects.map((p, idx) => {
|
||||
const female = idx % 2 === 0;
|
||||
const picIdx = (idx % 70) + 1;
|
||||
return {
|
||||
teamId,
|
||||
name: p.name,
|
||||
company: p.company,
|
||||
country: p.country,
|
||||
location: p.location,
|
||||
avatarUrl: `https://randomuser.me/api/portraits/${female ? "women" : "men"}/${picIdx}.jpg`,
|
||||
email: p.email,
|
||||
phone: p.phone,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const passwordHash = hashPassword(LOGIN_PASSWORD);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { id: "demo-user" },
|
||||
update: { phone: LOGIN_PHONE, passwordHash, name: LOGIN_NAME, email: "owner@clientsflow.local" },
|
||||
create: {
|
||||
id: "demo-user",
|
||||
phone: LOGIN_PHONE,
|
||||
passwordHash,
|
||||
name: LOGIN_NAME,
|
||||
email: "owner@clientsflow.local",
|
||||
},
|
||||
});
|
||||
|
||||
const team = await prisma.team.upsert({
|
||||
where: { id: "demo-team" },
|
||||
update: { name: "Connect Рабочее пространство" },
|
||||
create: { id: "demo-team", name: "Connect Рабочее пространство" },
|
||||
});
|
||||
|
||||
await prisma.teamMember.upsert({
|
||||
where: { teamId_userId: { teamId: team.id, userId: user.id } },
|
||||
update: { role: "OWNER" },
|
||||
create: { teamId: team.id, userId: user.id, role: "OWNER" },
|
||||
});
|
||||
|
||||
const conversation = await prisma.chatConversation.upsert({
|
||||
where: { id: `pilot-${team.id}` },
|
||||
update: { title: "Пилот" },
|
||||
create: { id: `pilot-${team.id}`, teamId: team.id, createdByUserId: user.id, title: "Пилот" },
|
||||
});
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.feedCard.deleteMany({ where: { teamId: team.id } }),
|
||||
prisma.contactPin.deleteMany({ where: { teamId: team.id } }),
|
||||
prisma.workspaceDocument.deleteMany({ where: { teamId: team.id } }),
|
||||
prisma.deal.deleteMany({ where: { teamId: team.id } }),
|
||||
prisma.calendarEvent.deleteMany({ where: { teamId: team.id } }),
|
||||
prisma.contactMessage.deleteMany({ where: { contact: { teamId: team.id } } }),
|
||||
prisma.chatMessage.deleteMany({ where: { teamId: team.id, conversationId: conversation.id } }),
|
||||
prisma.omniMessage.deleteMany({ where: { teamId: team.id } }),
|
||||
prisma.omniThread.deleteMany({ where: { teamId: team.id } }),
|
||||
prisma.omniContactIdentity.deleteMany({ where: { teamId: team.id } }),
|
||||
prisma.telegramBusinessConnection.deleteMany({ where: { teamId: team.id } }),
|
||||
prisma.contact.deleteMany({ where: { teamId: team.id } }),
|
||||
]);
|
||||
|
||||
const contacts = await prisma.contact.createManyAndReturn({
|
||||
data: buildOdooAiContacts(team.id),
|
||||
select: { id: true, name: true, company: true },
|
||||
});
|
||||
|
||||
const integrationModules = [
|
||||
"Продажи + CRM + копилот прогнозирования",
|
||||
"Склад + прогноз спроса",
|
||||
"Закупки + оценка рисков поставщиков",
|
||||
"Бухгалтерия + AI-детекция аномалий",
|
||||
"Поддержка + ассистент триажа заявок",
|
||||
"Производство + AI-планирование мощностей",
|
||||
];
|
||||
|
||||
await prisma.contactNote.createMany({
|
||||
data: contacts.map((c, idx) => ({
|
||||
contactId: c.id,
|
||||
content:
|
||||
`${c.company ?? c.name} рассматривает внедрение Odoo с AI-расширениями. ` +
|
||||
`Основной контур интеграции: ${integrationModules[idx % integrationModules.length]}. ` +
|
||||
`Ключевой драйвер покупки: сократить ручные операции и ускорить цикл принятия решений. ` +
|
||||
`Следующая веха: провести сессию уточнения, согласовать владельцев данных и утвердить KPI пилота.`,
|
||||
})),
|
||||
});
|
||||
|
||||
const channels = ["TELEGRAM", "WHATSAPP", "INSTAGRAM", "EMAIL"];
|
||||
const contactMessages = [];
|
||||
for (let i = 0; i < contacts.length; i += 1) {
|
||||
const contact = contacts[i];
|
||||
const base = atOffset(-(i % 18), 9 + (i % 7), (i * 7) % 60);
|
||||
|
||||
contactMessages.push({
|
||||
contactId: contact.id,
|
||||
kind: "MESSAGE",
|
||||
direction: "IN",
|
||||
channel: channels[i % channels.length],
|
||||
content: `Здравствуйте! Мы рассматриваем запуск Odoo + AI для ${contact.company}. Можем согласовать план интеграции на этой неделе?`,
|
||||
occurredAt: base,
|
||||
});
|
||||
|
||||
contactMessages.push({
|
||||
contactId: contact.id,
|
||||
kind: "MESSAGE",
|
||||
direction: "OUT",
|
||||
channel: channels[(i + 1) % channels.length],
|
||||
content: "Да, предлагаю 45-минутный разбор: процессы, ограничения API и KPI пилота.",
|
||||
occurredAt: plusMinutes(base, 22),
|
||||
});
|
||||
|
||||
contactMessages.push({
|
||||
contactId: contact.id,
|
||||
kind: "MESSAGE",
|
||||
direction: i % 3 === 0 ? "OUT" : "IN",
|
||||
channel: channels[(i + 2) % channels.length],
|
||||
content: "Обновление статуса: технический объём ясен; блокер — согласование бюджета и анкета по безопасности.",
|
||||
occurredAt: plusMinutes(base, 65),
|
||||
});
|
||||
|
||||
if (i % 3 === 0) {
|
||||
contactMessages.push({
|
||||
contactId: contact.id,
|
||||
kind: "CALL",
|
||||
direction: "OUT",
|
||||
channel: "PHONE",
|
||||
content: "Созвон по уточнению: модули Odoo, потоки данных и AI-сценарии",
|
||||
audioUrl: "/audio-samples/national-road-9.m4a",
|
||||
durationSec: 180 + ((i * 23) % 420),
|
||||
occurredAt: plusMinutes(base, 110),
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.contactMessage.createMany({ data: contactMessages });
|
||||
|
||||
await prisma.calendarEvent.createMany({
|
||||
data: contacts.flatMap((c, idx) => {
|
||||
// Историческая неделя до 20 Feb 2026: все сидовые встречи завершены.
|
||||
const firstStart = atOffset(-6 + (idx % 5), 10 + (idx % 6), (idx * 5) % 60);
|
||||
const secondStart = atOffset(-5 + (idx % 5), 14 + (idx % 4), (idx * 3) % 60);
|
||||
return [
|
||||
{
|
||||
teamId: team.id,
|
||||
contactId: c.id,
|
||||
title: `Сессия уточнения: Odoo + AI с ${c.company ?? c.name}`,
|
||||
startsAt: firstStart,
|
||||
endsAt: plusMinutes(firstStart, 30),
|
||||
note: "Подтвердить рамки интеграции, текущий стек и метрики успеха пилота.",
|
||||
},
|
||||
{
|
||||
teamId: team.id,
|
||||
contactId: c.id,
|
||||
title: `Архитектурный воркшоп: ${c.company ?? c.name}`,
|
||||
startsAt: secondStart,
|
||||
endsAt: plusMinutes(secondStart, 45),
|
||||
note: "Проверить маппинг API, границы ETL и ограничения для AI-ассистента.",
|
||||
},
|
||||
];
|
||||
}),
|
||||
});
|
||||
|
||||
const stages = ["Лид", "Уточнение", "Подбор решения", "Коммерческое предложение", "Переговоры", "Пилот", "Проверка договора"];
|
||||
for (const [idx, c] of contacts.entries()) {
|
||||
const nextStepText =
|
||||
idx % 4 === 0
|
||||
? "Отправить предложение по пилоту и зафиксировать список задач интеграции."
|
||||
: "Провести воркшоп по решению и согласовать сроки с коммерческим владельцем.";
|
||||
|
||||
const deal = await prisma.deal.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
contactId: c.id,
|
||||
title: `${c.company ?? "Клиент"}: интеграция Odoo + AI`,
|
||||
stage: stages[idx % stages.length],
|
||||
amount: 18000 + (idx % 8) * 7000,
|
||||
nextStep: nextStepText,
|
||||
summary:
|
||||
"Потенциальная сделка на поэтапное внедрение Odoo с AI-копилотами для операций, продаж и планирования. " +
|
||||
"Коммерческая модель: уточнение + пилот + тиражирование.",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const dueBase = atOffset((idx % 5) + 1, 11 + (idx % 4), 0);
|
||||
const steps = [
|
||||
{
|
||||
dealId: deal.id,
|
||||
title: "Собрать уточняющие требования",
|
||||
description: "Подтвердить модули Odoo, владельцев данных и критерии успеха.",
|
||||
status: "done",
|
||||
order: 1,
|
||||
completedAt: atOffset(-2 - (idx % 3), 16, 0),
|
||||
dueAt: atOffset(-1, 12, 0),
|
||||
},
|
||||
{
|
||||
dealId: deal.id,
|
||||
title: "Провести воркшоп по решению",
|
||||
description: "Согласовать границы интеграции и план пилота.",
|
||||
status: idx % 3 === 0 ? "in_progress" : "todo",
|
||||
order: 2,
|
||||
dueAt: dueBase,
|
||||
},
|
||||
{
|
||||
dealId: deal.id,
|
||||
title: "Согласовать и отправить договор",
|
||||
description: "Выслать договор и зафиксировать дату подписи.",
|
||||
status: "todo",
|
||||
order: 3,
|
||||
dueAt: atOffset((idx % 5) + 6, 15, 0),
|
||||
},
|
||||
];
|
||||
|
||||
await prisma.dealStep.createMany({ data: steps });
|
||||
const current = await prisma.dealStep.findFirst({
|
||||
where: { dealId: deal.id, status: { not: "done" } },
|
||||
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.deal.update({
|
||||
where: { id: deal.id },
|
||||
data: { currentStepId: current?.id ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.contactPin.createMany({
|
||||
data: contacts.map((c, idx) => ({
|
||||
teamId: team.id,
|
||||
contactId: c.id,
|
||||
text:
|
||||
idx % 3 === 0
|
||||
? "Уточнить владельца ERP, владельца данных и целевой квартал запуска."
|
||||
: "Держать коммуникацию вокруг одного KPI и следующего шага.",
|
||||
})),
|
||||
});
|
||||
|
||||
const proposalKeys = ["create_followup", "open_comm", "call", "draft_message", "run_summary", "prepare_question"];
|
||||
await prisma.feedCard.createMany({
|
||||
data: contacts
|
||||
.filter((_, idx) => idx % 3 === 0)
|
||||
.slice(0, 80)
|
||||
.map((c, idx) => ({
|
||||
teamId: team.id,
|
||||
contactId: c.id,
|
||||
happenedAt: atOffset(-(idx % 6), 9 + (idx % 8), (idx * 9) % 60),
|
||||
text:
|
||||
`Я проверил активность по аккаунту ${c.company ?? c.name} в рамках сделки Odoo + AI. ` +
|
||||
"Есть достаточный импульс, чтобы перевести сделку на следующий этап при чётком следующем шаге.",
|
||||
proposalJson: {
|
||||
title: idx % 2 === 0 ? "Назначить созвон по рамкам пилота" : "Отправить сообщение для разблокировки у владельца бюджета",
|
||||
details: [
|
||||
`Контакт: ${c.name}`,
|
||||
idx % 2 === 0 ? "Когда: на этой неделе, 45 минут" : "Когда: сегодня в основном канале",
|
||||
"Цель: подтвердить объём, владельца и следующую коммерческую контрольную точку",
|
||||
],
|
||||
key: proposalKeys[idx % proposalKeys.length],
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.workspaceDocument.createMany({
|
||||
data: [
|
||||
{
|
||||
teamId: team.id,
|
||||
title: "Чеклист уточнения для интеграции Odoo",
|
||||
type: "Regulation",
|
||||
owner: "Команда решений",
|
||||
scope: "Предпродажное уточнение",
|
||||
summary: "Обязательные вопросы перед оценкой запуска Odoo + AI.",
|
||||
body: "## Нужно зафиксировать\n- Текущие модули ERP\n- Точки интеграции\n- Владельца данных по каждому домену\n- Ограничения безопасности\n- Базовые KPI пилота",
|
||||
updatedAt: atOffset(-1, 11, 10),
|
||||
},
|
||||
{
|
||||
teamId: team.id,
|
||||
title: "Плейбук AI-копилота для Odoo",
|
||||
type: "Playbook",
|
||||
owner: "Лид AI-практики",
|
||||
scope: "Квалификация сценариев",
|
||||
summary: "Как позиционировать прогнозирование, ассистента и детекцию аномалий.",
|
||||
body: "## Поток\n1. Боль процесса\n2. Качество данных\n3. Целевая модель\n4. KPI успеха\n5. Объём пилота",
|
||||
updatedAt: atOffset(-2, 15, 0),
|
||||
},
|
||||
{
|
||||
teamId: team.id,
|
||||
title: "Матрица цен для пилота",
|
||||
type: "Policy",
|
||||
owner: "Коммерческие операции",
|
||||
scope: "Контракты уточнения и пилота",
|
||||
summary: "Диапазоны цен для уточнения, пилота и продуктивной фазы.",
|
||||
body: "## Типовые диапазоны\n- Уточнение: 5k-12k\n- Пилот: 15k-45k\n- Тиражирование: 50k+\n\nВсегда привязывай стоимость к объёму и срокам.",
|
||||
updatedAt: atOffset(-3, 9, 30),
|
||||
},
|
||||
{
|
||||
teamId: team.id,
|
||||
title: "Шаблон по безопасности и комплаенсу",
|
||||
type: "Template",
|
||||
owner: "Офис внедрения",
|
||||
scope: "Крупные клиенты",
|
||||
summary: "Шаблон ответов по data residency, RBAC, аудиту и обработке PII.",
|
||||
body: "## Разделы\n- Модель хостинга\n- Контроль доступа\n- Логирование и аудит\n- Срок хранения данных\n- Реакция на инциденты",
|
||||
updatedAt: atOffset(-4, 13, 45),
|
||||
},
|
||||
{
|
||||
teamId: team.id,
|
||||
title: "Референс интеграционной архитектуры",
|
||||
type: "Playbook",
|
||||
owner: "Архитектурная команда",
|
||||
scope: "Технические воркшопы",
|
||||
summary: "Референс-архитектура для коннекторов Odoo, ETL и AI-сервисного слоя.",
|
||||
body: "## Слои\n- Базовые модули Odoo\n- Интеграционная шина\n- Хранилище данных\n- Эндпоинты AI-сервиса\n- Мониторинг",
|
||||
updatedAt: atOffset(-5, 10, 0),
|
||||
},
|
||||
{
|
||||
teamId: team.id,
|
||||
title: "Чеклист готовности к запуску",
|
||||
type: "Regulation",
|
||||
owner: "PMO",
|
||||
scope: "Переход от пилота к продакшену",
|
||||
summary: "Чеклист перехода от приёмки пилота к запуску в прод.",
|
||||
body: "## Обязательно\n- KPI пилота утверждены\n- Backlog тиражирования приоритизирован\n- Владельцы назначены\n- Модель поддержки определена",
|
||||
updatedAt: atOffset(-6, 16, 15),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log("Seed completed.");
|
||||
console.log(`Login phone: ${LOGIN_PHONE}`);
|
||||
console.log(`Login password: ${LOGIN_PASSWORD}`);
|
||||
console.log(`Team: ${team.name}`);
|
||||
console.log(`Contacts created: ${contacts.length}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Clean previous build artifacts before production build.
|
||||
mkdir -p .nuxt .output
|
||||
find .nuxt -mindepth 1 -maxdepth 1 -exec rm -rf {} + || true
|
||||
find .output -mindepth 1 -maxdepth 1 -exec rm -rf {} + || true
|
||||
rm -rf node_modules/.cache node_modules/.vite
|
||||
|
||||
# Install deps (container starts from a clean image).
|
||||
# This workspace has mixed Apollo/Nuxt peer graphs; keep install deterministic in Docker.
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
# sharp is a native module and can break when cached node_modules were installed
|
||||
# for a different CPU variant (for example arm64v8). Force a local rebuild.
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||
npm rebuild sharp --platform=linux --arch=arm64v8 \
|
||||
|| npm rebuild sharp --platform=linux --arch=arm64 \
|
||||
|| npm install sharp --platform=linux --arch=arm64v8 --save-exact=false \
|
||||
|| npm install sharp --platform=linux --arch=arm64 --save-exact=false
|
||||
elif [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "amd64" ]; then
|
||||
npm rebuild sharp --platform=linux --arch=x64 \
|
||||
|| npm install sharp --platform=linux --arch=x64 --save-exact=false
|
||||
else
|
||||
npm rebuild sharp || true
|
||||
fi
|
||||
|
||||
# Wait until PostgreSQL is reachable before applying schema.
|
||||
until node -e "const u=new URL(process.env.DATABASE_URL||''); const net=require('net'); const s=net.createConnection({host:u.hostname,port:Number(u.port||5432)}); s.on('connect',()=>{s.end(); process.exit(0);}); s.on('error',()=>process.exit(1));" ; do
|
||||
echo "Waiting for PostgreSQL..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
npx prisma db push
|
||||
|
||||
# Run Nuxt in production mode (Nitro server), no dev/preview runtime.
|
||||
npm run build
|
||||
export NITRO_HOST=0.0.0.0
|
||||
export NITRO_PORT=3000
|
||||
exec node .output/server/index.mjs
|
||||
@@ -1,182 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
function loadEnvFromDotEnv() {
|
||||
const p = path.resolve(process.cwd(), ".env");
|
||||
if (!fsSync.existsSync(p)) return;
|
||||
const raw = fsSync.readFileSync(p, "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
let val = trimmed.slice(idx + 1).trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
if (!key) continue;
|
||||
if (key === "DATABASE_URL") {
|
||||
process.env[key] = val;
|
||||
continue;
|
||||
}
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFromDotEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function datasetRoot() {
|
||||
const teamId = process.env.TEAM_ID || "demo-team";
|
||||
const userId = process.env.USER_ID || "demo-user";
|
||||
return path.resolve(process.cwd(), "..", ".data", "crmfs", "teams", teamId, "users", userId);
|
||||
}
|
||||
|
||||
async function ensureDir(p) {
|
||||
await fs.mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeJson(p, value) {
|
||||
await fs.writeFile(p, JSON.stringify(value, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function jsonlLine(value) {
|
||||
return JSON.stringify(value) + "\n";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = datasetRoot();
|
||||
const tmp = root + ".tmp";
|
||||
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
await ensureDir(tmp);
|
||||
|
||||
const contactsDir = path.join(tmp, "contacts");
|
||||
const notesDir = path.join(tmp, "notes");
|
||||
const messagesDir = path.join(tmp, "messages");
|
||||
const eventsDir = path.join(tmp, "events");
|
||||
const indexDir = path.join(tmp, "index");
|
||||
await Promise.all([
|
||||
ensureDir(contactsDir),
|
||||
ensureDir(notesDir),
|
||||
ensureDir(messagesDir),
|
||||
ensureDir(eventsDir),
|
||||
ensureDir(indexDir),
|
||||
]);
|
||||
|
||||
const teamId = process.env.TEAM_ID || "demo-team";
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: { teamId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: {
|
||||
note: { select: { content: true, updatedAt: true } },
|
||||
messages: {
|
||||
select: {
|
||||
kind: true,
|
||||
direction: true,
|
||||
channel: true,
|
||||
content: true,
|
||||
durationSec: true,
|
||||
transcriptJson: true,
|
||||
occurredAt: true,
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
},
|
||||
events: {
|
||||
select: { title: true, startsAt: true, endsAt: true, status: true, note: true },
|
||||
orderBy: { startsAt: "asc" },
|
||||
},
|
||||
},
|
||||
take: 5000,
|
||||
});
|
||||
|
||||
const contactIndex = [];
|
||||
for (const c of contacts) {
|
||||
await writeJson(path.join(contactsDir, `${c.id}.json`), {
|
||||
id: c.id,
|
||||
teamId: c.teamId,
|
||||
name: c.name,
|
||||
company: c.company ?? null,
|
||||
country: c.country ?? null,
|
||||
location: c.location ?? null,
|
||||
avatarUrl: c.avatarUrl ?? null,
|
||||
email: c.email ?? null,
|
||||
phone: c.phone ?? null,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
});
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(notesDir, `${c.id}.md`),
|
||||
(c.note?.content?.trim() ? c.note.content.trim() : "") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(messagesDir, `${c.id}.jsonl`),
|
||||
c.messages
|
||||
.map((m) =>
|
||||
jsonlLine({
|
||||
kind: m.kind,
|
||||
direction: m.direction,
|
||||
channel: m.channel,
|
||||
occurredAt: m.occurredAt,
|
||||
content: m.content,
|
||||
durationSec: m.durationSec ?? null,
|
||||
transcript: m.transcriptJson ?? null,
|
||||
}),
|
||||
)
|
||||
.join(""),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(eventsDir, `${c.id}.jsonl`),
|
||||
c.events
|
||||
.map((e) =>
|
||||
jsonlLine({
|
||||
title: e.title,
|
||||
startsAt: e.startsAt,
|
||||
endsAt: e.endsAt,
|
||||
status: e.status ?? null,
|
||||
note: e.note ?? null,
|
||||
}),
|
||||
)
|
||||
.join(""),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const lastMessageAt = c.messages.length ? c.messages[c.messages.length - 1].occurredAt : null;
|
||||
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
|
||||
contactIndex.push({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
company: c.company ?? null,
|
||||
lastMessageAt,
|
||||
nextEventAt,
|
||||
updatedAt: c.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
await writeJson(path.join(indexDir, "contacts.json"), contactIndex);
|
||||
await writeJson(path.join(tmp, "meta.json"), { exportedAt: new Date().toISOString(), version: 1 });
|
||||
|
||||
await ensureDir(path.dirname(root));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rename(tmp, root);
|
||||
|
||||
console.log("exported", root);
|
||||
}
|
||||
|
||||
await main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -1,296 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ChatRole, Prisma } from "@prisma/client";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { datasetRoot } from "../dataset/paths";
|
||||
import { ensureDataset } from "../dataset/exporter";
|
||||
import { runLangGraphCrmAgentFor } from "./langgraphCrmAgent";
|
||||
import type { ChangeSet } from "../utils/changeSet";
|
||||
|
||||
type ContactIndexRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string | null;
|
||||
lastMessageAt: string | null;
|
||||
nextEventAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AgentReply = {
|
||||
text: string;
|
||||
plan: string[];
|
||||
tools: string[];
|
||||
thinking?: string[];
|
||||
toolRuns?: Array<{
|
||||
name: string;
|
||||
status: "ok" | "error";
|
||||
input: string;
|
||||
output: string;
|
||||
at: string;
|
||||
}>;
|
||||
dbWrites?: Array<{ kind: string; detail: string }>;
|
||||
};
|
||||
|
||||
export type AgentTraceEvent = {
|
||||
text: string;
|
||||
toolRun?: {
|
||||
name: string;
|
||||
status: "ok" | "error";
|
||||
input: string;
|
||||
output: string;
|
||||
at: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PilotContextPayload = {
|
||||
scopes: Array<"summary" | "deal" | "message" | "calendar">;
|
||||
summary?: {
|
||||
contactId: string;
|
||||
name: string;
|
||||
};
|
||||
deal?: {
|
||||
dealId: string;
|
||||
title: string;
|
||||
contact: string;
|
||||
};
|
||||
message?: {
|
||||
contactId?: string;
|
||||
contact?: string;
|
||||
intent: "add_message_or_reminder";
|
||||
};
|
||||
calendar?: {
|
||||
view: "day" | "week" | "month" | "year" | "agenda";
|
||||
period: string;
|
||||
selectedDateKey: string;
|
||||
focusedEventId?: string;
|
||||
eventIds: string[];
|
||||
};
|
||||
};
|
||||
|
||||
function normalize(s: string) {
|
||||
return s.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isToday(date: Date) {
|
||||
const now = new Date();
|
||||
return (
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
async function readContactIndex(): Promise<ContactIndexRow[]> {
|
||||
throw new Error("readContactIndex now requires dataset root");
|
||||
}
|
||||
|
||||
async function readContactIndexFrom(root: string): Promise<ContactIndexRow[]> {
|
||||
const p = path.join(root, "index", "contacts.json");
|
||||
const raw = await fs.readFile(p, "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function countJsonlLines(p: string): Promise<number> {
|
||||
const raw = await fs.readFile(p, "utf8");
|
||||
if (!raw.trim()) return 0;
|
||||
// cheap line count (JSONL is 1 item per line)
|
||||
return raw.trimEnd().split("\n").length;
|
||||
}
|
||||
|
||||
async function readJsonl(p: string): Promise<any[]> {
|
||||
const raw = await fs.readFile(p, "utf8");
|
||||
if (!raw.trim()) return [];
|
||||
return raw
|
||||
.trimEnd()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function formatContactLine(c: ContactIndexRow) {
|
||||
const company = c.company ? ` (${c.company})` : "";
|
||||
const lastAt = c.lastMessageAt ? new Date(c.lastMessageAt).toLocaleString("ru-RU") : "нет";
|
||||
return `- ${c.name}${company} · последнее: ${lastAt}`;
|
||||
}
|
||||
|
||||
export async function runCrmAgent(userText: string): Promise<AgentReply> {
|
||||
throw new Error("runCrmAgent now requires auth context");
|
||||
}
|
||||
|
||||
export async function runCrmAgentFor(
|
||||
input: {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
userText: string;
|
||||
contextPayload?: PilotContextPayload | null;
|
||||
requestId?: string;
|
||||
conversationId?: string;
|
||||
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;
|
||||
},
|
||||
): Promise<AgentReply> {
|
||||
const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase();
|
||||
const llmApiKey =
|
||||
process.env.OPENROUTER_API_KEY ||
|
||||
process.env.LLM_API_KEY ||
|
||||
process.env.OPENAI_API_KEY ||
|
||||
process.env.DASHSCOPE_API_KEY ||
|
||||
process.env.QWEN_API_KEY;
|
||||
const hasGigachat = Boolean((process.env.GIGACHAT_AUTH_KEY ?? "").trim() && (process.env.GIGACHAT_SCOPE ?? "").trim());
|
||||
|
||||
if (mode !== "rule") {
|
||||
return runLangGraphCrmAgentFor(input);
|
||||
}
|
||||
|
||||
if (!llmApiKey && !hasGigachat) {
|
||||
throw new Error("LLM API key is not configured. Set OPENROUTER_API_KEY or GIGACHAT_AUTH_KEY/GIGACHAT_SCOPE.");
|
||||
}
|
||||
|
||||
await ensureDataset({ teamId: input.teamId, userId: input.userId });
|
||||
const q = normalize(input.userText);
|
||||
const root = datasetRoot({ teamId: input.teamId, userId: input.userId });
|
||||
const contacts = await readContactIndexFrom(root);
|
||||
|
||||
// "10 лучших клиентов"
|
||||
if (q.includes("10 лучших") || (q.includes("топ") && q.includes("клиент"))) {
|
||||
const ranked = await Promise.all(
|
||||
contacts.map(async (c) => {
|
||||
const msgPath = path.join(root, "messages", `${c.id}.jsonl`);
|
||||
const evPath = path.join(root, "events", `${c.id}.jsonl`);
|
||||
const msgCount = await countJsonlLines(msgPath).catch(() => 0);
|
||||
const ev = await readJsonl(evPath).catch(() => []);
|
||||
const todayEvCount = ev.filter((e) => (e?.startsAt ? isToday(new Date(e.startsAt)) : false)).length;
|
||||
const score = msgCount * 2 + todayEvCount * 3;
|
||||
return { c, score };
|
||||
}),
|
||||
);
|
||||
|
||||
ranked.sort((a, b) => b.score - a.score);
|
||||
const top = ranked.slice(0, 10).map((x) => x.c);
|
||||
|
||||
return {
|
||||
plan: [
|
||||
"Загрузить индекс контактов из файлового датасета",
|
||||
"Посчитать активность по JSONL (сообщения/события сегодня)",
|
||||
"Отсортировать и показать топ",
|
||||
],
|
||||
tools: ["read index/contacts.json", "read messages/{contactId}.jsonl", "read events/{contactId}.jsonl"],
|
||||
toolRuns: [
|
||||
{
|
||||
name: "dataset:index_contacts",
|
||||
status: "ok",
|
||||
input: "index/contacts.json",
|
||||
output: "Loaded contacts index",
|
||||
at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
text:
|
||||
`Топ-10 по активности (сообщения + события):\n` +
|
||||
top.map(formatContactLine).join("\n") +
|
||||
`\n\nЕсли хочешь, скажи критерий "лучший" (выручка/стадия/вероятность/давность) и я пересчитаю.`,
|
||||
};
|
||||
}
|
||||
|
||||
// "чем заняться сегодня"
|
||||
if (q.includes("чем") && (q.includes("сегодня") || q.includes("заняться"))) {
|
||||
const todayEvents: Array<{ who: string; title: string; at: Date; note?: string | null }> = [];
|
||||
|
||||
for (const c of contacts) {
|
||||
const evPath = path.join(root, "events", `${c.id}.jsonl`);
|
||||
const ev = await readJsonl(evPath).catch(() => []);
|
||||
for (const e of ev) {
|
||||
if (!e?.startsAt) continue;
|
||||
const at = new Date(e.startsAt);
|
||||
if (!isToday(at)) continue;
|
||||
todayEvents.push({ who: c.name, title: e.title ?? "Event", at, note: e.note ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
todayEvents.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
|
||||
const followups = [...contacts]
|
||||
.map((c) => ({ c, last: c.lastMessageAt ? new Date(c.lastMessageAt).getTime() : 0 }))
|
||||
.sort((a, b) => a.last - b.last)
|
||||
.slice(0, 3)
|
||||
.map((x) => x.c);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (todayEvents.length > 0) {
|
||||
lines.push("Сегодня по календарю:");
|
||||
for (const e of todayEvents) {
|
||||
const hhmm = e.at.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
lines.push(`- ${hhmm} · ${e.title} · ${e.who}${e.note ? ` · ${e.note}` : ""}`);
|
||||
}
|
||||
} else {
|
||||
lines.push("Сегодня нет запланированных событий в календаре.");
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Фокус дня (если нужно добить прогресс):");
|
||||
for (const c of followups) {
|
||||
lines.push(`- Написать follow-up: ${c.name}${c.company ? ` (${c.company})` : ""}`);
|
||||
}
|
||||
|
||||
return {
|
||||
plan: [
|
||||
"Прочитать события на сегодня из файлового датасета",
|
||||
"Найти контакты без свежего касания (по lastMessageAt)",
|
||||
"Сформировать короткий список действий",
|
||||
],
|
||||
tools: ["read index/contacts.json", "read events/{contactId}.jsonl"],
|
||||
toolRuns: [
|
||||
{
|
||||
name: "dataset:query_events",
|
||||
status: "ok",
|
||||
input: "events/*.jsonl (today)",
|
||||
output: `Found ${todayEvents.length} events`,
|
||||
at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
text: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Rule mode supports only structured built-in queries. Use a supported query or switch to langgraph mode with a configured LLM API key.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function persistChatMessage(input: {
|
||||
role: ChatRole;
|
||||
text: string;
|
||||
plan?: string[];
|
||||
tools?: string[];
|
||||
thinking?: string[];
|
||||
toolRuns?: Array<{
|
||||
name: string;
|
||||
status: "ok" | "error";
|
||||
input: string;
|
||||
output: string;
|
||||
at: string;
|
||||
}>;
|
||||
changeSet?: ChangeSet | null;
|
||||
requestId?: string;
|
||||
eventType?: "user" | "trace" | "assistant" | "note";
|
||||
phase?: "pending" | "running" | "final" | "error";
|
||||
transient?: boolean;
|
||||
messageKind?: "change_set_summary";
|
||||
teamId: string;
|
||||
conversationId: string;
|
||||
authorUserId?: string | null;
|
||||
}) {
|
||||
const hasStoredPayload = Boolean(input.changeSet || input.messageKind);
|
||||
const data: Prisma.ChatMessageCreateInput = {
|
||||
team: { connect: { id: input.teamId } },
|
||||
conversation: { connect: { id: input.conversationId } },
|
||||
authorUser: input.authorUserId ? { connect: { id: input.authorUserId } } : undefined,
|
||||
role: input.role,
|
||||
text: input.text,
|
||||
planJson: hasStoredPayload
|
||||
? ({
|
||||
messageKind: input.messageKind ?? null,
|
||||
changeSet: input.changeSet ?? null,
|
||||
} as any)
|
||||
: undefined,
|
||||
};
|
||||
return prisma.chatMessage.create({ data });
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
||||
import { readBody } from "h3";
|
||||
import { graphql } from "graphql";
|
||||
import { getAuthContext } from "../utils/auth";
|
||||
import { crmGraphqlRoot, crmGraphqlSchema } from "../graphql/schema";
|
||||
|
||||
type GraphqlBody = {
|
||||
query?: string;
|
||||
operationName?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<GraphqlBody>(event);
|
||||
|
||||
if (!body?.query || !body.query.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: "GraphQL query is required" });
|
||||
}
|
||||
|
||||
let auth = null;
|
||||
try {
|
||||
auth = await getAuthContext(event);
|
||||
} catch {
|
||||
auth = null;
|
||||
}
|
||||
|
||||
const result = await graphql({
|
||||
schema: crmGraphqlSchema,
|
||||
source: body.query,
|
||||
rootValue: crmGraphqlRoot,
|
||||
contextValue: { auth, event },
|
||||
variableValues: body.variables,
|
||||
operationName: body.operationName,
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data ?? null,
|
||||
errors: result.errors?.map((error) => ({ message: error.message })) ?? undefined,
|
||||
};
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { readBody } from "h3";
|
||||
import { getAuthContext } from "../../../utils/auth";
|
||||
import { prisma } from "../../../utils/prisma";
|
||||
import { enqueueOutboundDelivery } from "../../../queues/outboundDelivery";
|
||||
|
||||
type EnqueueBody = {
|
||||
omniMessageId?: string;
|
||||
endpoint?: string;
|
||||
method?: "POST" | "PUT" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
payload?: unknown;
|
||||
provider?: string;
|
||||
channel?: string;
|
||||
attempts?: number;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const body = await readBody<EnqueueBody>(event);
|
||||
|
||||
const omniMessageId = String(body?.omniMessageId ?? "").trim();
|
||||
const endpoint = String(body?.endpoint ?? "").trim();
|
||||
if (!omniMessageId) {
|
||||
throw createError({ statusCode: 400, statusMessage: "omniMessageId is required" });
|
||||
}
|
||||
if (!endpoint) {
|
||||
throw createError({ statusCode: 400, statusMessage: "endpoint is required" });
|
||||
}
|
||||
|
||||
const msg = await prisma.omniMessage.findFirst({
|
||||
where: { id: omniMessageId, teamId: auth.teamId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!msg) {
|
||||
throw createError({ statusCode: 404, statusMessage: "omni message not found" });
|
||||
}
|
||||
|
||||
const attempts = Math.max(1, Math.min(Number(body?.attempts ?? 12), 50));
|
||||
const job = await enqueueOutboundDelivery(
|
||||
{
|
||||
omniMessageId,
|
||||
endpoint,
|
||||
method: body?.method ?? "POST",
|
||||
headers: body?.headers ?? {},
|
||||
payload: body?.payload ?? {},
|
||||
provider: body?.provider ?? undefined,
|
||||
channel: body?.channel ?? undefined,
|
||||
},
|
||||
{
|
||||
attempts,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
queue: process.env.SENDER_FLOW_QUEUE_NAME || process.env.OUTBOUND_DELIVERY_QUEUE_NAME || "sender.flow",
|
||||
jobId: job.id,
|
||||
omniMessageId,
|
||||
};
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { readBody } from "h3";
|
||||
import { prisma } from "../../../../../utils/prisma";
|
||||
|
||||
type CompleteBody = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<CompleteBody>(event);
|
||||
const token = String(body?.token ?? "").trim();
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 400, statusMessage: "token is required" });
|
||||
}
|
||||
|
||||
const pendingId = `pending:${token}`;
|
||||
const pending = await prisma.telegramBusinessConnection.findFirst({
|
||||
where: {
|
||||
businessConnectionId: pendingId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!pending) {
|
||||
return { ok: false, status: "session_not_found" };
|
||||
}
|
||||
|
||||
const raw = (pending.rawJson ?? {}) as any;
|
||||
const exp = Number(raw?.link?.exp ?? 0);
|
||||
if (Number.isFinite(exp) && exp > 0 && Math.floor(Date.now() / 1000) > exp) {
|
||||
return { ok: false, status: "invalid_or_expired_token" };
|
||||
}
|
||||
|
||||
const telegramUserId = raw?.link?.telegramUserId != null ? String(raw.link.telegramUserId).trim() : "";
|
||||
if (!telegramUserId) {
|
||||
return { ok: false, status: "awaiting_telegram_start" };
|
||||
}
|
||||
|
||||
const linkedConnectionId = `link:${telegramUserId}`;
|
||||
await prisma.$transaction([
|
||||
prisma.telegramBusinessConnection.upsert({
|
||||
where: {
|
||||
teamId_businessConnectionId: {
|
||||
teamId: pending.teamId,
|
||||
businessConnectionId: linkedConnectionId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId: pending.teamId,
|
||||
businessConnectionId: linkedConnectionId,
|
||||
isEnabled: true,
|
||||
canReply: true,
|
||||
rawJson: {
|
||||
state: "connected",
|
||||
mode: "token_link",
|
||||
linkedAt: new Date().toISOString(),
|
||||
telegramUserId,
|
||||
tokenNonce: token,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
isEnabled: true,
|
||||
canReply: true,
|
||||
rawJson: {
|
||||
state: "connected",
|
||||
mode: "token_link",
|
||||
linkedAt: new Date().toISOString(),
|
||||
telegramUserId,
|
||||
tokenNonce: token,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.telegramBusinessConnection.delete({ where: { id: pending.id } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "connected",
|
||||
businessConnectionId: linkedConnectionId,
|
||||
};
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { readBody } from "h3";
|
||||
import { getAuthContext } from "../../../../../utils/auth";
|
||||
import { prisma } from "../../../../../utils/prisma";
|
||||
import { telegramBotApi } from "../../../../../utils/telegram";
|
||||
|
||||
type RefreshBody = {
|
||||
businessConnectionId?: string;
|
||||
};
|
||||
|
||||
function mapFlags(raw: any) {
|
||||
const isEnabled = typeof raw?.is_enabled === "boolean" ? raw.is_enabled : null;
|
||||
const canReply = typeof raw?.can_reply === "boolean"
|
||||
? raw.can_reply
|
||||
: typeof raw?.rights?.can_reply === "boolean"
|
||||
? raw.rights.can_reply
|
||||
: null;
|
||||
return { isEnabled, canReply };
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const body = await readBody<RefreshBody>(event);
|
||||
|
||||
const businessConnectionId = String(body?.businessConnectionId ?? "").trim();
|
||||
if (!businessConnectionId) {
|
||||
throw createError({ statusCode: 400, statusMessage: "businessConnectionId is required" });
|
||||
}
|
||||
|
||||
const existing = await prisma.telegramBusinessConnection.findFirst({
|
||||
where: {
|
||||
teamId: auth.teamId,
|
||||
businessConnectionId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, statusMessage: "business connection not found" });
|
||||
}
|
||||
|
||||
const response = await telegramBotApi<any>("getBusinessConnection", { business_connection_id: businessConnectionId });
|
||||
const { isEnabled, canReply } = mapFlags(response);
|
||||
|
||||
const updated = await prisma.telegramBusinessConnection.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
isEnabled,
|
||||
canReply,
|
||||
rawJson: {
|
||||
state: "connected",
|
||||
refreshedAt: new Date().toISOString(),
|
||||
businessConnection: response,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
businessConnectionId: true,
|
||||
isEnabled: true,
|
||||
canReply: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
connection: {
|
||||
businessConnectionId: updated.businessConnectionId,
|
||||
isEnabled: updated.isEnabled,
|
||||
canReply: updated.canReply,
|
||||
updatedAt: updated.updatedAt.toISOString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getAuthContext } from "../../../../../utils/auth";
|
||||
import { prisma } from "../../../../../utils/prisma";
|
||||
import { buildTelegramStartUrl, issueLinkToken } from "../../../../../utils/telegramBusinessConnect";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const { token, payload } = issueLinkToken({ teamId: auth.teamId, userId: auth.userId });
|
||||
|
||||
const pendingId = `pending:${payload.nonce}`;
|
||||
await prisma.telegramBusinessConnection.upsert({
|
||||
where: {
|
||||
teamId_businessConnectionId: {
|
||||
teamId: auth.teamId,
|
||||
businessConnectionId: pendingId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId: auth.teamId,
|
||||
businessConnectionId: pendingId,
|
||||
rawJson: {
|
||||
state: "pending_link",
|
||||
link: {
|
||||
nonce: payload.nonce,
|
||||
exp: payload.exp,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdByUserId: auth.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
update: {
|
||||
isEnabled: null,
|
||||
canReply: null,
|
||||
rawJson: {
|
||||
state: "pending_link",
|
||||
link: {
|
||||
nonce: payload.nonce,
|
||||
exp: payload.exp,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdByUserId: auth.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "pending_link",
|
||||
connectUrl: buildTelegramStartUrl(token),
|
||||
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
||||
};
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { getAuthContext } from "../../../../../utils/auth";
|
||||
import { prisma } from "../../../../../utils/prisma";
|
||||
|
||||
function normalizeStatus(input: {
|
||||
pendingCount: number;
|
||||
linkedPendingCount: number;
|
||||
connectedCount: number;
|
||||
enabledCount: number;
|
||||
replyEnabledCount: number;
|
||||
}) {
|
||||
if (input.connectedCount > 0) {
|
||||
if (input.replyEnabledCount > 0 && input.enabledCount > 0) return "connected";
|
||||
if (input.enabledCount === 0) return "disabled";
|
||||
return "no_reply_rights";
|
||||
}
|
||||
if (input.linkedPendingCount > 0) return "pending_business_connection";
|
||||
if (input.pendingCount > 0) return "pending_link";
|
||||
return "not_connected";
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const rows = await prisma.telegramBusinessConnection.findMany({
|
||||
where: { teamId: auth.teamId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const pending = rows.filter((r) => r.businessConnectionId.startsWith("pending:"));
|
||||
const active = rows.filter((r) => !r.businessConnectionId.startsWith("pending:"));
|
||||
|
||||
const linkedPendingCount = pending.filter((r) => {
|
||||
const raw = (r.rawJson ?? {}) as any;
|
||||
return Boolean(raw?.link?.telegramUserId || raw?.link?.chatId);
|
||||
}).length;
|
||||
|
||||
const enabledCount = active.filter((r) => r.isEnabled !== false).length;
|
||||
const replyEnabledCount = active.filter((r) => r.canReply === true).length;
|
||||
|
||||
const status = normalizeStatus({
|
||||
pendingCount: pending.length,
|
||||
linkedPendingCount,
|
||||
connectedCount: active.length,
|
||||
enabledCount,
|
||||
replyEnabledCount,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status,
|
||||
pendingCount: pending.length,
|
||||
connectedCount: active.length,
|
||||
connections: active.map((r) => ({
|
||||
businessConnectionId: r.businessConnectionId,
|
||||
isEnabled: r.isEnabled,
|
||||
canReply: r.canReply,
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
import { getHeader, readBody } from "h3";
|
||||
import { prisma } from "../../../../utils/prisma";
|
||||
import { telegramBotApi } from "../../../../utils/telegram";
|
||||
import {
|
||||
extractLinkTokenFromStartText,
|
||||
getBusinessConnectionFromUpdate,
|
||||
getTelegramChatIdFromUpdate,
|
||||
} from "../../../../utils/telegramBusinessConnect";
|
||||
|
||||
function hasValidSecret(event: any) {
|
||||
const expected = String(process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
|
||||
if (!expected) return true;
|
||||
const incoming = String(getHeader(event, "x-telegram-bot-api-secret-token") || "").trim();
|
||||
return incoming !== "" && incoming === expected;
|
||||
}
|
||||
|
||||
function pickStartText(update: any): string | null {
|
||||
const text =
|
||||
update?.message?.text ??
|
||||
update?.business_message?.text ??
|
||||
update?.edited_business_message?.text ??
|
||||
null;
|
||||
if (typeof text !== "string") return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
function crmConnectUrl() {
|
||||
return String(process.env.CRM_APP_URL || "https://clientsflow.dsrptlab.com").trim();
|
||||
}
|
||||
|
||||
function crmConnectButton(linkToken?: string) {
|
||||
const base = crmConnectUrl();
|
||||
let target = base;
|
||||
if (linkToken) {
|
||||
try {
|
||||
const u = new URL(base);
|
||||
u.searchParams.set("tg_link_token", linkToken);
|
||||
target = u.toString();
|
||||
} catch {
|
||||
target = `${base}${base.includes("?") ? "&" : "?"}tg_link_token=${encodeURIComponent(linkToken)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: "Открыть CRM и подтвердить",
|
||||
url: target,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (!hasValidSecret(event)) {
|
||||
throw createError({ statusCode: 401, statusMessage: "invalid webhook secret" });
|
||||
}
|
||||
|
||||
const update = await readBody<any>(event);
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
const startText = pickStartText(update);
|
||||
const linkToken = startText ? extractLinkTokenFromStartText(startText) : null;
|
||||
const startChatId = getTelegramChatIdFromUpdate(update);
|
||||
|
||||
if (startText && !linkToken) {
|
||||
if (startChatId) {
|
||||
void telegramBotApi("sendMessage", {
|
||||
chat_id: startChatId,
|
||||
text: "Чтобы привязать Telegram Business к CRM, открой CRM → Settings → Telegram Business → Connect. Кнопка сгенерирует персональную ссылку привязки.",
|
||||
reply_markup: crmConnectButton(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { ok: true, accepted: true, type: "start_without_link_token" };
|
||||
}
|
||||
|
||||
if (linkToken) {
|
||||
const pendingId = `pending:${linkToken}`;
|
||||
const pending = await prisma.telegramBusinessConnection.findFirst({
|
||||
where: {
|
||||
businessConnectionId: pendingId,
|
||||
},
|
||||
});
|
||||
if (!pending) {
|
||||
if (startChatId) {
|
||||
void telegramBotApi("sendMessage", {
|
||||
chat_id: startChatId,
|
||||
text: "Ссылка привязки недействительна или истекла. Вернись в CRM и нажми Connect заново.",
|
||||
reply_markup: crmConnectButton(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { ok: true, accepted: false, reason: "invalid_or_expired_link_token" };
|
||||
}
|
||||
|
||||
const rawPending = (pending.rawJson ?? {}) as any;
|
||||
const exp = Number(rawPending?.link?.exp ?? 0);
|
||||
if (Number.isFinite(exp) && exp > 0 && Math.floor(Date.now() / 1000) > exp) {
|
||||
if (startChatId) {
|
||||
void telegramBotApi("sendMessage", {
|
||||
chat_id: startChatId,
|
||||
text: "Ссылка привязки истекла. Вернись в CRM и нажми Connect заново.",
|
||||
reply_markup: crmConnectButton(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { ok: true, accepted: false, reason: "invalid_or_expired_link_token" };
|
||||
}
|
||||
|
||||
const chatId = startChatId;
|
||||
|
||||
await prisma.telegramBusinessConnection.updateMany({
|
||||
where: {
|
||||
teamId: pending.teamId,
|
||||
businessConnectionId: pendingId,
|
||||
},
|
||||
data: {
|
||||
rawJson: {
|
||||
state: "pending_business_connection",
|
||||
link: {
|
||||
...(rawPending?.link ?? {}),
|
||||
linkedAt: nowIso,
|
||||
telegramUserId: chatId,
|
||||
chatId,
|
||||
},
|
||||
lastStartUpdate: update,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (chatId) {
|
||||
void telegramBotApi("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "CRM: связка аккаунта получена. Нажми кнопку ниже и вернись в CRM для подтверждения.",
|
||||
reply_markup: crmConnectButton(linkToken),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return { ok: true, accepted: true, type: "start_link" };
|
||||
}
|
||||
|
||||
const businessConnection = getBusinessConnectionFromUpdate(update);
|
||||
if (businessConnection) {
|
||||
const pendingRows = await prisma.telegramBusinessConnection.findMany({
|
||||
where: {
|
||||
businessConnectionId: {
|
||||
startsWith: "pending:",
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const matchedPending = pendingRows.find((row) => {
|
||||
const raw = (row.rawJson ?? {}) as any;
|
||||
const linkedTelegramUserId = raw?.link?.telegramUserId != null ? String(raw.link.telegramUserId) : null;
|
||||
if (!businessConnection.userChatId) return false;
|
||||
return linkedTelegramUserId === businessConnection.userChatId;
|
||||
});
|
||||
|
||||
if (!matchedPending) {
|
||||
return { ok: true, accepted: false, reason: "team_not_linked_for_business_connection" };
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.telegramBusinessConnection.upsert({
|
||||
where: {
|
||||
teamId_businessConnectionId: {
|
||||
teamId: matchedPending.teamId,
|
||||
businessConnectionId: businessConnection.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId: matchedPending.teamId,
|
||||
businessConnectionId: businessConnection.id,
|
||||
isEnabled: businessConnection.isEnabled,
|
||||
canReply: businessConnection.canReply,
|
||||
rawJson: {
|
||||
state: "connected",
|
||||
connectedAt: nowIso,
|
||||
userChatId: businessConnection.userChatId,
|
||||
businessConnection: businessConnection.raw,
|
||||
update,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
isEnabled: businessConnection.isEnabled,
|
||||
canReply: businessConnection.canReply,
|
||||
rawJson: {
|
||||
state: "connected",
|
||||
connectedAt: nowIso,
|
||||
userChatId: businessConnection.userChatId,
|
||||
businessConnection: businessConnection.raw,
|
||||
update,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.telegramBusinessConnection.delete({ where: { id: matchedPending.id } }),
|
||||
]);
|
||||
|
||||
if (businessConnection.userChatId) {
|
||||
void telegramBotApi("sendMessage", {
|
||||
chat_id: businessConnection.userChatId,
|
||||
text: "CRM: Telegram Business подключен. Теперь входящие сообщения будут появляться в CRM.",
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return { ok: true, accepted: true, type: "business_connection" };
|
||||
}
|
||||
|
||||
return { ok: true, accepted: true, type: "ignored" };
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { readBody } from "h3";
|
||||
import { getAuthContext } from "../../../utils/auth";
|
||||
import { prisma } from "../../../utils/prisma";
|
||||
import { enqueueTelegramSend } from "../../../queues/telegramSend";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const body = await readBody<{ omniMessageId?: string; attempts?: number }>(event);
|
||||
|
||||
const omniMessageId = String(body?.omniMessageId ?? "").trim();
|
||||
if (!omniMessageId) {
|
||||
throw createError({ statusCode: 400, statusMessage: "omniMessageId is required" });
|
||||
}
|
||||
|
||||
const msg = await prisma.omniMessage.findFirst({
|
||||
where: { id: omniMessageId, teamId: auth.teamId, channel: "TELEGRAM", direction: "OUT" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!msg) {
|
||||
throw createError({ statusCode: 404, statusMessage: "telegram outbound message not found" });
|
||||
}
|
||||
|
||||
const attempts = Math.max(1, Math.min(Number(body?.attempts ?? 12), 50));
|
||||
const job = await enqueueTelegramSend({ omniMessageId }, { attempts });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
queue: process.env.SENDER_FLOW_QUEUE_NAME || process.env.OUTBOUND_DELIVERY_QUEUE_NAME || "sender.flow",
|
||||
jobId: job.id,
|
||||
omniMessageId,
|
||||
};
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
import { readBody } from "h3";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||
import { getAuthContext } from "../utils/auth";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
|
||||
import { persistChatMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent";
|
||||
import type { PilotContextPayload } from "../agent/crmAgent";
|
||||
import type { ChangeSet } from "../utils/changeSet";
|
||||
|
||||
function extractMessageText(message: any): string {
|
||||
if (!message || !Array.isArray(message.parts)) return "";
|
||||
return message.parts
|
||||
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
||||
.map((part: any) => part.text)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getLastUserText(messages: any[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message?.role !== "user") continue;
|
||||
const text = extractMessageText(message);
|
||||
if (text) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function sanitizeContextPayload(raw: unknown): PilotContextPayload | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const item = raw as Record<string, any>;
|
||||
const scopesRaw = Array.isArray(item.scopes) ? item.scopes : [];
|
||||
const scopes = scopesRaw
|
||||
.map((scope) => String(scope))
|
||||
.filter((scope) => scope === "summary" || scope === "deal" || scope === "message" || scope === "calendar") as PilotContextPayload["scopes"];
|
||||
if (!scopes.length) return null;
|
||||
|
||||
const payload: PilotContextPayload = { scopes };
|
||||
|
||||
if (item.summary && typeof item.summary === "object") {
|
||||
const contactId = String(item.summary.contactId ?? "").trim();
|
||||
const name = String(item.summary.name ?? "").trim();
|
||||
if (contactId && name) payload.summary = { contactId, name };
|
||||
}
|
||||
|
||||
if (item.deal && typeof item.deal === "object") {
|
||||
const dealId = String(item.deal.dealId ?? "").trim();
|
||||
const title = String(item.deal.title ?? "").trim();
|
||||
const contact = String(item.deal.contact ?? "").trim();
|
||||
if (dealId && title && contact) payload.deal = { dealId, title, contact };
|
||||
}
|
||||
|
||||
if (item.message && typeof item.message === "object") {
|
||||
const contactId = String(item.message.contactId ?? "").trim();
|
||||
const contact = String(item.message.contact ?? "").trim();
|
||||
const intent = String(item.message.intent ?? "").trim();
|
||||
if (intent === "add_message_or_reminder") {
|
||||
payload.message = {
|
||||
...(contactId ? { contactId } : {}),
|
||||
...(contact ? { contact } : {}),
|
||||
intent: "add_message_or_reminder",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (item.calendar && typeof item.calendar === "object") {
|
||||
const view = String(item.calendar.view ?? "").trim();
|
||||
const period = String(item.calendar.period ?? "").trim();
|
||||
const selectedDateKey = String(item.calendar.selectedDateKey ?? "").trim();
|
||||
const focusedEventId = String(item.calendar.focusedEventId ?? "").trim();
|
||||
const eventIds = Array.isArray(item.calendar.eventIds)
|
||||
? item.calendar.eventIds.map((id: any) => String(id ?? "").trim()).filter(Boolean)
|
||||
: [];
|
||||
if (
|
||||
(view === "day" || view === "week" || view === "month" || view === "year" || view === "agenda") &&
|
||||
period &&
|
||||
selectedDateKey
|
||||
) {
|
||||
payload.calendar = {
|
||||
view,
|
||||
period,
|
||||
selectedDateKey,
|
||||
...(focusedEventId ? { focusedEventId } : {}),
|
||||
eventIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function humanizeTraceText(trace: AgentTraceEvent): string {
|
||||
if (trace.toolRun?.name) {
|
||||
return `Использую инструмент: ${trace.toolRun.name}`;
|
||||
}
|
||||
|
||||
const text = (trace.text ?? "").trim();
|
||||
if (!text) return "Агент работает с данными CRM.";
|
||||
|
||||
if (text.toLowerCase().includes("ошиб")) return "Возникла ошибка шага, пробую другой путь.";
|
||||
if (text.toLowerCase().includes("итог")) return "Готовлю финальный ответ.";
|
||||
return text;
|
||||
}
|
||||
|
||||
function renderChangeSetSummary(changeSet: ChangeSet): string {
|
||||
const totals = { created: 0, updated: 0, deleted: 0 };
|
||||
for (const item of changeSet.items) {
|
||||
if (item.action === "created") totals.created += 1;
|
||||
else if (item.action === "updated") totals.updated += 1;
|
||||
else if (item.action === "deleted") totals.deleted += 1;
|
||||
}
|
||||
|
||||
const byEntity = new Map<string, number>();
|
||||
for (const item of changeSet.items) {
|
||||
byEntity.set(item.entity, (byEntity.get(item.entity) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"Technical change summary",
|
||||
`Total: ${changeSet.items.length} · Created: ${totals.created} · Updated: ${totals.updated} · Archived: ${totals.deleted}`,
|
||||
...[...byEntity.entries()].map(([entity, count]) => `- ${entity}: ${count}`),
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const auth = await getAuthContext(event);
|
||||
const body = await readBody<{ messages?: any[]; contextPayload?: unknown }>(event);
|
||||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||
const userText = getLastUserText(messages);
|
||||
const contextPayload = sanitizeContextPayload(body?.contextPayload);
|
||||
|
||||
if (!userText) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
|
||||
}
|
||||
const requestId = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
const textId = `text-${Date.now()}`;
|
||||
writer.write({ type: "start" });
|
||||
try {
|
||||
const snapshotBefore = await captureSnapshot(prisma, auth.teamId);
|
||||
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: auth.userId,
|
||||
role: "USER",
|
||||
text: userText,
|
||||
requestId,
|
||||
eventType: "user",
|
||||
phase: "final",
|
||||
transient: false,
|
||||
});
|
||||
|
||||
const reply = await runCrmAgentFor({
|
||||
teamId: auth.teamId,
|
||||
userId: auth.userId,
|
||||
userText,
|
||||
contextPayload,
|
||||
requestId,
|
||||
conversationId: auth.conversationId,
|
||||
onTrace: async (trace: AgentTraceEvent) => {
|
||||
writer.write({
|
||||
type: "data-agent-log",
|
||||
data: {
|
||||
requestId,
|
||||
at: new Date().toISOString(),
|
||||
text: humanizeTraceText(trace),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const snapshotAfter = await captureSnapshot(prisma, auth.teamId);
|
||||
const changeSet = buildChangeSet(snapshotBefore, snapshotAfter);
|
||||
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: null,
|
||||
role: "ASSISTANT",
|
||||
text: reply.text,
|
||||
requestId,
|
||||
eventType: "assistant",
|
||||
phase: "final",
|
||||
transient: false,
|
||||
});
|
||||
|
||||
if (changeSet) {
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: null,
|
||||
role: "ASSISTANT",
|
||||
text: renderChangeSetSummary(changeSet),
|
||||
requestId,
|
||||
eventType: "note",
|
||||
phase: "final",
|
||||
transient: false,
|
||||
messageKind: "change_set_summary",
|
||||
changeSet,
|
||||
});
|
||||
}
|
||||
|
||||
writer.write({ type: "text-start", id: textId });
|
||||
writer.write({ type: "text-delta", id: textId, delta: reply.text });
|
||||
writer.write({ type: "text-end", id: textId });
|
||||
writer.write({ type: "finish", finishReason: "stop" });
|
||||
} catch (error: any) {
|
||||
const errorText = String(error?.message ?? error);
|
||||
|
||||
await persistChatMessage({
|
||||
teamId: auth.teamId,
|
||||
conversationId: auth.conversationId,
|
||||
authorUserId: null,
|
||||
role: "ASSISTANT",
|
||||
text: errorText,
|
||||
requestId,
|
||||
eventType: "assistant",
|
||||
phase: "error",
|
||||
transient: false,
|
||||
});
|
||||
|
||||
writer.write({
|
||||
type: "data-agent-log",
|
||||
data: {
|
||||
requestId,
|
||||
at: new Date().toISOString(),
|
||||
text: "Ошибка выполнения агентского цикла.",
|
||||
},
|
||||
});
|
||||
writer.write({ type: "text-start", id: textId });
|
||||
writer.write({
|
||||
type: "text-delta",
|
||||
id: textId,
|
||||
delta: errorText,
|
||||
});
|
||||
writer.write({ type: "text-end", id: textId });
|
||||
writer.write({ type: "finish", finishReason: "stop" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { readBody } from "h3";
|
||||
import { getAuthContext } from "../utils/auth";
|
||||
import { transcribeWithWhisper } from "../utils/whisper";
|
||||
|
||||
type TranscribeBody = {
|
||||
audioBase64?: string;
|
||||
sampleRate?: number;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
function decodeBase64Pcm16(audioBase64: string) {
|
||||
const pcmBuffer = Buffer.from(audioBase64, "base64");
|
||||
if (pcmBuffer.length < 2) return new Float32Array();
|
||||
|
||||
const sampleCount = Math.floor(pcmBuffer.length / 2);
|
||||
const out = new Float32Array(sampleCount);
|
||||
|
||||
for (let i = 0; i < sampleCount; i += 1) {
|
||||
const lo = pcmBuffer[i * 2]!;
|
||||
const hi = pcmBuffer[i * 2 + 1]!;
|
||||
const int16 = (hi << 8) | lo;
|
||||
const signed = int16 >= 0x8000 ? int16 - 0x10000 : int16;
|
||||
out[i] = signed / 32768;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await getAuthContext(event);
|
||||
|
||||
const body = await readBody<TranscribeBody>(event);
|
||||
const audioBase64 = String(body?.audioBase64 ?? "").trim();
|
||||
const sampleRateRaw = Number(body?.sampleRate ?? 0);
|
||||
const language = String(body?.language ?? "").trim() || undefined;
|
||||
|
||||
if (!audioBase64) {
|
||||
throw createError({ statusCode: 400, statusMessage: "audioBase64 is required" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(sampleRateRaw) || sampleRateRaw < 8000 || sampleRateRaw > 48000) {
|
||||
throw createError({ statusCode: 400, statusMessage: "sampleRate must be between 8000 and 48000" });
|
||||
}
|
||||
|
||||
const samples = decodeBase64Pcm16(audioBase64);
|
||||
if (!samples.length) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Audio is empty" });
|
||||
}
|
||||
|
||||
const maxSamples = Math.floor(sampleRateRaw * 120);
|
||||
if (samples.length > maxSamples) {
|
||||
throw createError({ statusCode: 413, statusMessage: "Audio is too long (max 120s)" });
|
||||
}
|
||||
|
||||
const text = await transcribeWithWhisper({
|
||||
samples,
|
||||
sampleRate: sampleRateRaw,
|
||||
language,
|
||||
});
|
||||
|
||||
return { text };
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { datasetRoot } from "./paths";
|
||||
|
||||
type ExportMeta = {
|
||||
exportedAt: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
async function ensureDir(p: string) {
|
||||
await fs.mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeJson(p: string, value: unknown) {
|
||||
await fs.writeFile(p, JSON.stringify(value, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function jsonlLine(value: unknown) {
|
||||
return JSON.stringify(value) + "\n";
|
||||
}
|
||||
|
||||
export async function exportDatasetFromPrisma() {
|
||||
throw new Error("exportDatasetFromPrisma now requires { teamId, userId }");
|
||||
}
|
||||
|
||||
export async function exportDatasetFromPrismaFor(input: { teamId: string; userId: string }) {
|
||||
const root = datasetRoot(input);
|
||||
const tmp = root + ".tmp";
|
||||
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
await ensureDir(tmp);
|
||||
|
||||
const contactsDir = path.join(tmp, "contacts");
|
||||
const notesDir = path.join(tmp, "notes");
|
||||
const messagesDir = path.join(tmp, "messages");
|
||||
const eventsDir = path.join(tmp, "events");
|
||||
const indexDir = path.join(tmp, "index");
|
||||
await Promise.all([
|
||||
ensureDir(contactsDir),
|
||||
ensureDir(notesDir),
|
||||
ensureDir(messagesDir),
|
||||
ensureDir(eventsDir),
|
||||
ensureDir(indexDir),
|
||||
]);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: { teamId: input.teamId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: {
|
||||
note: { select: { content: true, updatedAt: true } },
|
||||
messages: {
|
||||
select: {
|
||||
kind: true,
|
||||
direction: true,
|
||||
channel: true,
|
||||
content: true,
|
||||
durationSec: true,
|
||||
transcriptJson: true,
|
||||
occurredAt: true,
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
},
|
||||
events: {
|
||||
select: { title: true, startsAt: true, endsAt: true, isArchived: true, note: true },
|
||||
orderBy: { startsAt: "asc" },
|
||||
},
|
||||
},
|
||||
take: 5000,
|
||||
});
|
||||
|
||||
const contactIndex = [];
|
||||
|
||||
for (const c of contacts) {
|
||||
const contactFile = path.join(contactsDir, `${c.id}.json`);
|
||||
await writeJson(contactFile, {
|
||||
id: c.id,
|
||||
teamId: c.teamId,
|
||||
name: c.name,
|
||||
company: c.company ?? null,
|
||||
country: c.country ?? null,
|
||||
location: c.location ?? null,
|
||||
avatarUrl: c.avatarUrl ?? null,
|
||||
email: c.email ?? null,
|
||||
phone: c.phone ?? null,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
});
|
||||
|
||||
const noteFile = path.join(notesDir, `${c.id}.md`);
|
||||
await fs.writeFile(
|
||||
noteFile,
|
||||
(c.note?.content?.trim() ? c.note.content.trim() : "") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const msgFile = path.join(messagesDir, `${c.id}.jsonl`);
|
||||
const msgLines = c.messages.map((m) =>
|
||||
jsonlLine({
|
||||
kind: m.kind,
|
||||
direction: m.direction,
|
||||
channel: m.channel,
|
||||
occurredAt: m.occurredAt,
|
||||
content: m.content,
|
||||
durationSec: m.durationSec ?? null,
|
||||
transcript: m.transcriptJson ?? null,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(msgFile, msgLines.join(""), "utf8");
|
||||
|
||||
const evFile = path.join(eventsDir, `${c.id}.jsonl`);
|
||||
const evLines = c.events.map((e) =>
|
||||
jsonlLine({
|
||||
title: e.title,
|
||||
startsAt: e.startsAt,
|
||||
endsAt: e.endsAt,
|
||||
isArchived: e.isArchived,
|
||||
note: e.note ?? null,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(evFile, evLines.join(""), "utf8");
|
||||
|
||||
const lastMessageAt = c.messages.at(-1)?.occurredAt ?? null;
|
||||
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
|
||||
|
||||
contactIndex.push({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
company: c.company ?? null,
|
||||
lastMessageAt,
|
||||
nextEventAt,
|
||||
updatedAt: c.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
await writeJson(path.join(indexDir, "contacts.json"), contactIndex);
|
||||
|
||||
const meta: ExportMeta = { exportedAt: new Date().toISOString(), version: 1 };
|
||||
await writeJson(path.join(tmp, "meta.json"), meta);
|
||||
|
||||
await ensureDir(path.dirname(root));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rename(tmp, root);
|
||||
}
|
||||
|
||||
export async function ensureDataset(input: { teamId: string; userId: string }) {
|
||||
const root = datasetRoot(input);
|
||||
try {
|
||||
const metaPath = path.join(root, "meta.json");
|
||||
await fs.access(metaPath);
|
||||
return;
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
await exportDatasetFromPrismaFor(input);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function datasetRoot(input: { teamId: string; userId: string }) {
|
||||
// Keep it outside frontend so it can be easily ignored and shared.
|
||||
return path.resolve(process.cwd(), "..", ".data", "crmfs", "teams", input.teamId, "users", input.userId);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { startTelegramSendWorker } from "../queues/telegramSend";
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
// Disabled by default. If you need background processing, wire it explicitly.
|
||||
if (process.env.RUN_QUEUE_WORKER !== "1") return;
|
||||
|
||||
startTelegramSendWorker();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,216 +0,0 @@
|
||||
import { Queue, Worker, type JobsOptions, type ConnectionOptions } from "bullmq";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
export const OUTBOUND_DELIVERY_QUEUE_NAME = (
|
||||
process.env.SENDER_FLOW_QUEUE_NAME ||
|
||||
process.env.OUTBOUND_DELIVERY_QUEUE_NAME ||
|
||||
"sender.flow"
|
||||
).trim();
|
||||
|
||||
export type OutboundDeliveryJob = {
|
||||
omniMessageId: string;
|
||||
endpoint: string;
|
||||
method?: "POST" | "PUT" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
payload: unknown;
|
||||
channel?: string;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
function redisConnectionFromEnv(): ConnectionOptions {
|
||||
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
|
||||
const parsed = new URL(raw);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : 6379,
|
||||
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
||||
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
||||
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureHttpUrl(value: string) {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) throw new Error("endpoint is required");
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Unsupported endpoint protocol: ${parsed.protocol}`);
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function compactError(error: unknown) {
|
||||
if (!error) return "unknown_error";
|
||||
if (typeof error === "string") return error;
|
||||
const anyErr = error as any;
|
||||
return String(anyErr?.message ?? anyErr);
|
||||
}
|
||||
|
||||
function extractProviderMessageId(body: unknown): string | null {
|
||||
const obj = body as any;
|
||||
if (!obj || typeof obj !== "object") return null;
|
||||
const candidate =
|
||||
obj?.message_id ??
|
||||
obj?.messageId ??
|
||||
obj?.id ??
|
||||
obj?.result?.message_id ??
|
||||
obj?.result?.id ??
|
||||
null;
|
||||
if (candidate == null) return null;
|
||||
return String(candidate);
|
||||
}
|
||||
|
||||
export function outboundDeliveryQueue() {
|
||||
return new Queue<OutboundDeliveryJob, unknown, "deliver">(OUTBOUND_DELIVERY_QUEUE_NAME, {
|
||||
connection: redisConnectionFromEnv(),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: { count: 1000 },
|
||||
removeOnFail: { count: 5000 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?: JobsOptions) {
|
||||
const endpoint = ensureHttpUrl(input.endpoint);
|
||||
const q = outboundDeliveryQueue();
|
||||
|
||||
const payload = (input.payload ?? null) as Prisma.InputJsonValue;
|
||||
// Keep source message in pending before actual send starts.
|
||||
await prisma.omniMessage.update({
|
||||
where: { id: input.omniMessageId },
|
||||
data: {
|
||||
status: "PENDING",
|
||||
rawJson: {
|
||||
queue: {
|
||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
},
|
||||
deliveryRequest: {
|
||||
endpoint,
|
||||
method: input.method ?? "POST",
|
||||
channel: input.channel ?? null,
|
||||
provider: input.provider ?? null,
|
||||
payload,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return q.add("deliver", { ...input, endpoint }, {
|
||||
jobId: `omni-${input.omniMessageId}`,
|
||||
attempts: 12,
|
||||
backoff: { type: "exponential", delay: 1000 },
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
export function startOutboundDeliveryWorker() {
|
||||
return new Worker<OutboundDeliveryJob, unknown, "deliver">(
|
||||
OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
async (job) => {
|
||||
const msg = await prisma.omniMessage.findUnique({
|
||||
where: { id: job.data.omniMessageId },
|
||||
include: { thread: true },
|
||||
});
|
||||
if (!msg) return;
|
||||
|
||||
// Idempotency: if already sent/delivered, do not resend.
|
||||
if ((msg.status === "SENT" || msg.status === "DELIVERED" || msg.status === "READ") && msg.providerMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = ensureHttpUrl(job.data.endpoint);
|
||||
const method = job.data.method ?? "POST";
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
...(job.data.headers ?? {}),
|
||||
};
|
||||
|
||||
const requestPayload = (job.data.payload ?? null) as Prisma.InputJsonValue;
|
||||
const requestStartedAt = new Date().toISOString();
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(requestPayload ?? {}),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const responseBody = (() => {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${typeof responseBody === "string" ? responseBody : JSON.stringify(responseBody)}`);
|
||||
}
|
||||
|
||||
const providerMessageId = extractProviderMessageId(responseBody);
|
||||
await prisma.omniMessage.update({
|
||||
where: { id: msg.id },
|
||||
data: {
|
||||
status: "SENT",
|
||||
providerMessageId,
|
||||
rawJson: {
|
||||
queue: {
|
||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
completedAt: new Date().toISOString(),
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
},
|
||||
deliveryRequest: {
|
||||
endpoint,
|
||||
method,
|
||||
channel: job.data.channel ?? null,
|
||||
provider: job.data.provider ?? null,
|
||||
startedAt: requestStartedAt,
|
||||
payload: requestPayload,
|
||||
},
|
||||
deliveryResponse: {
|
||||
status: response.status,
|
||||
body: responseBody,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const isLastAttempt =
|
||||
typeof job.opts.attempts === "number" && job.attemptsMade + 1 >= job.opts.attempts;
|
||||
|
||||
if (isLastAttempt) {
|
||||
await prisma.omniMessage.update({
|
||||
where: { id: msg.id },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
rawJson: {
|
||||
queue: {
|
||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
||||
failedAt: new Date().toISOString(),
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
},
|
||||
deliveryRequest: {
|
||||
endpoint,
|
||||
method,
|
||||
channel: job.data.channel ?? null,
|
||||
provider: job.data.provider ?? null,
|
||||
startedAt: requestStartedAt,
|
||||
payload: requestPayload,
|
||||
},
|
||||
deliveryError: {
|
||||
message: compactError(error),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ connection: redisConnectionFromEnv() },
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { JobsOptions } from "bullmq";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { telegramApiBase, requireTelegramBotToken } from "../utils/telegram";
|
||||
import { enqueueOutboundDelivery, startOutboundDeliveryWorker } from "./outboundDelivery";
|
||||
|
||||
type TelegramSendJob = {
|
||||
omniMessageId: string;
|
||||
};
|
||||
|
||||
export async function enqueueTelegramSend(input: TelegramSendJob, opts?: JobsOptions) {
|
||||
const msg = await prisma.omniMessage.findUnique({
|
||||
where: { id: input.omniMessageId },
|
||||
include: { thread: true },
|
||||
});
|
||||
if (!msg) throw new Error(`omni message not found: ${input.omniMessageId}`);
|
||||
if (msg.channel !== "TELEGRAM" || msg.direction !== "OUT") {
|
||||
throw new Error(`Invalid omni message for telegram send: ${msg.id}`);
|
||||
}
|
||||
|
||||
const token = requireTelegramBotToken();
|
||||
const endpoint = `${telegramApiBase()}/bot${token}/sendMessage`;
|
||||
const payload = {
|
||||
chat_id: msg.thread.externalChatId,
|
||||
text: msg.text,
|
||||
...(msg.thread.businessConnectionId ? { business_connection_id: msg.thread.businessConnectionId } : {}),
|
||||
};
|
||||
|
||||
return enqueueOutboundDelivery(
|
||||
{
|
||||
omniMessageId: msg.id,
|
||||
endpoint,
|
||||
method: "POST",
|
||||
payload,
|
||||
provider: "telegram_business",
|
||||
channel: "TELEGRAM",
|
||||
},
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function startTelegramSendWorker() {
|
||||
return startOutboundDeliveryWorker();
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { OUTBOUND_DELIVERY_QUEUE_NAME, startOutboundDeliveryWorker } from "./outboundDelivery";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { getRedis } from "../utils/redis";
|
||||
|
||||
const worker = startOutboundDeliveryWorker();
|
||||
console.log(`[omni_outbound(legacy-in-frontend)] started queue ${OUTBOUND_DELIVERY_QUEUE_NAME}`);
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[omni_outbound(legacy-in-frontend)] shutting down by ${signal}`);
|
||||
try {
|
||||
await worker.close();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
try {
|
||||
const redis = getRedis();
|
||||
await redis.quit();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { prisma } from "../../utils/prisma";
|
||||
|
||||
const COOKIE_USER = "cf_user";
|
||||
const COOKIE_TEAM = "cf_team";
|
||||
const COOKIE_CONV = "cf_conv";
|
||||
|
||||
const TEAM_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
const peersByTeam = new Map<string, Set<any>>();
|
||||
const peerTeamById = new Map<string, string>();
|
||||
const lastSignatureByTeam = new Map<string, string>();
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function parseCookies(raw: string | null) {
|
||||
const out = new Map<string, string>();
|
||||
for (const part of String(raw ?? "").split(";")) {
|
||||
const [key, ...rest] = part.trim().split("=");
|
||||
if (!key) continue;
|
||||
const value = rest.join("=");
|
||||
try {
|
||||
out.set(key, decodeURIComponent(value));
|
||||
} catch {
|
||||
out.set(key, value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function attachPeerToTeam(peer: any, teamId: string) {
|
||||
if (!peersByTeam.has(teamId)) peersByTeam.set(teamId, new Set());
|
||||
peersByTeam.get(teamId)?.add(peer);
|
||||
peerTeamById.set(String(peer.id), teamId);
|
||||
}
|
||||
|
||||
function detachPeer(peer: any) {
|
||||
const key = String(peer.id);
|
||||
const teamId = peerTeamById.get(key);
|
||||
if (!teamId) return;
|
||||
peerTeamById.delete(key);
|
||||
|
||||
const peers = peersByTeam.get(teamId);
|
||||
if (!peers) return;
|
||||
peers.delete(peer);
|
||||
if (peers.size === 0) {
|
||||
peersByTeam.delete(teamId);
|
||||
lastSignatureByTeam.delete(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPollIfIdle() {
|
||||
if (peersByTeam.size > 0 || !pollTimer) return;
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
|
||||
async function validateSessionFromPeer(peer: any) {
|
||||
const cookieHeader = peer?.request?.headers?.get?.("cookie") ?? null;
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
|
||||
const userId = String(cookies.get(COOKIE_USER) ?? "").trim();
|
||||
const teamId = String(cookies.get(COOKIE_TEAM) ?? "").trim();
|
||||
const conversationId = String(cookies.get(COOKIE_CONV) ?? "").trim();
|
||||
if (!userId || !teamId || !conversationId) return null;
|
||||
|
||||
const [user, team, conv] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { id: true } }),
|
||||
prisma.team.findUnique({ where: { id: teamId }, select: { id: true } }),
|
||||
prisma.chatConversation.findFirst({
|
||||
where: { id: conversationId, teamId, createdByUserId: userId },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!user || !team || !conv) return null;
|
||||
return { teamId };
|
||||
}
|
||||
|
||||
async function computeTeamSignature(teamId: string) {
|
||||
const [omniMessageMax, contactMax, contactMessageMax, telegramConnectionMax] = await Promise.all([
|
||||
prisma.omniMessage.aggregate({
|
||||
where: { teamId },
|
||||
_max: { updatedAt: true },
|
||||
}),
|
||||
prisma.contact.aggregate({
|
||||
where: { teamId },
|
||||
_max: { updatedAt: true },
|
||||
}),
|
||||
prisma.contactMessage.aggregate({
|
||||
where: { contact: { teamId } },
|
||||
_max: { createdAt: true },
|
||||
}),
|
||||
prisma.telegramBusinessConnection.aggregate({
|
||||
where: { teamId },
|
||||
_max: { updatedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return [
|
||||
omniMessageMax._max.updatedAt?.toISOString() ?? "",
|
||||
contactMax._max.updatedAt?.toISOString() ?? "",
|
||||
contactMessageMax._max.createdAt?.toISOString() ?? "",
|
||||
telegramConnectionMax._max.updatedAt?.toISOString() ?? "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function sendJson(peer: any, payload: Record<string, unknown>) {
|
||||
try {
|
||||
peer.send(JSON.stringify(payload));
|
||||
} catch {
|
||||
// ignore socket write errors
|
||||
}
|
||||
}
|
||||
|
||||
async function pollAndBroadcast() {
|
||||
for (const [teamId, peers] of peersByTeam.entries()) {
|
||||
if (!peers.size) continue;
|
||||
const signature = await computeTeamSignature(teamId);
|
||||
const previous = lastSignatureByTeam.get(teamId);
|
||||
if (signature === previous) continue;
|
||||
|
||||
lastSignatureByTeam.set(teamId, signature);
|
||||
const payload = {
|
||||
type: "dashboard.changed",
|
||||
teamId,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
for (const peer of peers) {
|
||||
sendJson(peer, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePoll() {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(() => {
|
||||
void pollAndBroadcast();
|
||||
}, TEAM_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export default defineWebSocketHandler({
|
||||
async open(peer) {
|
||||
const session = await validateSessionFromPeer(peer);
|
||||
if (!session) {
|
||||
peer.close(4401, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
attachPeerToTeam(peer, session.teamId);
|
||||
ensurePoll();
|
||||
sendJson(peer, { type: "realtime.connected", at: new Date().toISOString() });
|
||||
void pollAndBroadcast();
|
||||
},
|
||||
|
||||
close(peer) {
|
||||
detachPeer(peer);
|
||||
stopPollIfIdle();
|
||||
},
|
||||
|
||||
error(peer) {
|
||||
detachPeer(peer);
|
||||
stopPollIfIdle();
|
||||
},
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { H3Event } from "h3";
|
||||
import { getCookie, setCookie, deleteCookie, getHeader } from "h3";
|
||||
import { prisma } from "./prisma";
|
||||
import { hashPassword } from "./password";
|
||||
|
||||
export type AuthContext = {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
const COOKIE_USER = "cf_user";
|
||||
const COOKIE_TEAM = "cf_team";
|
||||
const COOKIE_CONV = "cf_conv";
|
||||
|
||||
function cookieOpts() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
};
|
||||
}
|
||||
|
||||
export function clearAuthSession(event: H3Event) {
|
||||
deleteCookie(event, COOKIE_USER, { path: "/" });
|
||||
deleteCookie(event, COOKIE_TEAM, { path: "/" });
|
||||
deleteCookie(event, COOKIE_CONV, { path: "/" });
|
||||
}
|
||||
|
||||
export function setSession(event: H3Event, ctx: AuthContext) {
|
||||
setCookie(event, COOKIE_USER, ctx.userId, cookieOpts());
|
||||
setCookie(event, COOKIE_TEAM, ctx.teamId, cookieOpts());
|
||||
setCookie(event, COOKIE_CONV, ctx.conversationId, cookieOpts());
|
||||
}
|
||||
|
||||
export async function getAuthContext(event: H3Event): Promise<AuthContext> {
|
||||
const cookieUser = getCookie(event, COOKIE_USER)?.trim();
|
||||
const cookieTeam = getCookie(event, COOKIE_TEAM)?.trim();
|
||||
const cookieConv = getCookie(event, COOKIE_CONV)?.trim();
|
||||
|
||||
// Temporary compatibility: allow passing via headers for debugging/dev tools.
|
||||
const hdrTeam = getHeader(event, "x-team-id")?.trim();
|
||||
const hdrUser = getHeader(event, "x-user-id")?.trim();
|
||||
const hdrConv = getHeader(event, "x-conversation-id")?.trim();
|
||||
|
||||
const hasAnySession = Boolean(cookieUser || cookieTeam || cookieConv || hdrTeam || hdrUser || hdrConv);
|
||||
if (!hasAnySession) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const userId = cookieUser || hdrUser;
|
||||
const teamId = cookieTeam || hdrTeam;
|
||||
const conversationId = cookieConv || hdrConv;
|
||||
|
||||
if (!userId || !teamId || !conversationId) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
const team = await prisma.team.findUnique({ where: { id: teamId } });
|
||||
|
||||
if (!user || !team) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const conv = await prisma.chatConversation.findFirst({
|
||||
where: { id: conversationId, teamId: team.id, createdByUserId: user.id },
|
||||
});
|
||||
|
||||
if (!conv) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
return { teamId: team.id, userId: user.id, conversationId: conv.id };
|
||||
}
|
||||
|
||||
export async function ensureDemoAuth() {
|
||||
const demoPasswordHash = hashPassword("DemoPass123!");
|
||||
const user = await prisma.user.upsert({
|
||||
where: { id: "demo-user" },
|
||||
update: { phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
||||
create: { id: "demo-user", phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
||||
});
|
||||
const team = await prisma.team.upsert({
|
||||
where: { id: "demo-team" },
|
||||
update: { name: "Demo Team" },
|
||||
create: { id: "demo-team", name: "Demo Team" },
|
||||
});
|
||||
await prisma.teamMember.upsert({
|
||||
where: { teamId_userId: { teamId: team.id, userId: user.id } },
|
||||
update: {},
|
||||
create: { teamId: team.id, userId: user.id, role: "OWNER" },
|
||||
});
|
||||
const conv = await prisma.chatConversation.upsert({
|
||||
where: { id: `pilot-${team.id}` },
|
||||
update: {},
|
||||
create: { id: `pilot-${team.id}`, teamId: team.id, createdByUserId: user.id, title: "Pilot" },
|
||||
});
|
||||
return { teamId: team.id, userId: user.id, conversationId: conv.id };
|
||||
}
|
||||
@@ -1,600 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
type CalendarSnapshotRow = {
|
||||
id: string;
|
||||
teamId: string;
|
||||
contactId: string | null;
|
||||
title: string;
|
||||
startsAt: string;
|
||||
endsAt: string | null;
|
||||
note: string | null;
|
||||
isArchived: boolean;
|
||||
archiveNote: string | null;
|
||||
archivedAt: string | null;
|
||||
};
|
||||
|
||||
type ContactNoteSnapshotRow = {
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type MessageSnapshotRow = {
|
||||
id: string;
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
kind: string;
|
||||
direction: string;
|
||||
channel: string;
|
||||
content: string;
|
||||
durationSec: number | null;
|
||||
occurredAt: string;
|
||||
};
|
||||
|
||||
type DealSnapshotRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
contactName: string;
|
||||
stage: string;
|
||||
nextStep: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
|
||||
type WorkspaceDocumentSnapshotRow = {
|
||||
id: string;
|
||||
teamId: string;
|
||||
title: string;
|
||||
type: string;
|
||||
owner: string;
|
||||
scope: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type SnapshotState = {
|
||||
calendarById: Map<string, CalendarSnapshotRow>;
|
||||
noteByContactId: Map<string, ContactNoteSnapshotRow>;
|
||||
messageById: Map<string, MessageSnapshotRow>;
|
||||
dealById: Map<string, DealSnapshotRow>;
|
||||
documentById: Map<string, WorkspaceDocumentSnapshotRow>;
|
||||
};
|
||||
|
||||
export type ChangeItem = {
|
||||
id: string;
|
||||
entity: "calendar_event" | "contact_note" | "message" | "deal" | "workspace_document";
|
||||
entityId: string | null;
|
||||
action: "created" | "updated" | "deleted";
|
||||
title: string;
|
||||
before: string;
|
||||
after: string;
|
||||
undo: UndoOp[];
|
||||
};
|
||||
|
||||
type UndoOp =
|
||||
| { kind: "delete_calendar_event"; id: string }
|
||||
| { kind: "restore_calendar_event"; data: CalendarSnapshotRow }
|
||||
| { kind: "delete_contact_message"; id: string }
|
||||
| { kind: "restore_contact_message"; data: MessageSnapshotRow }
|
||||
| { kind: "restore_contact_note"; contactId: string; content: string | null }
|
||||
| { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null }
|
||||
| { kind: "delete_workspace_document"; id: string }
|
||||
| { kind: "restore_workspace_document"; data: WorkspaceDocumentSnapshotRow };
|
||||
|
||||
export type ChangeSet = {
|
||||
id: string;
|
||||
status: "pending" | "confirmed" | "rolled_back";
|
||||
createdAt: string;
|
||||
summary: string;
|
||||
items: ChangeItem[];
|
||||
undo: UndoOp[];
|
||||
rolledBackItemIds: string[];
|
||||
};
|
||||
|
||||
function fmt(val: string | null | undefined) {
|
||||
return (val ?? "").trim();
|
||||
}
|
||||
|
||||
function toCalendarText(row: CalendarSnapshotRow) {
|
||||
const when = new Date(row.startsAt).toLocaleString("ru-RU");
|
||||
return `${row.title} · ${when}${row.note ? ` · ${row.note}` : ""}`;
|
||||
}
|
||||
|
||||
function toMessageText(row: MessageSnapshotRow) {
|
||||
const when = new Date(row.occurredAt).toLocaleString("ru-RU");
|
||||
return `${row.contactName} · ${row.channel} · ${row.kind.toLowerCase()} · ${when} · ${row.content}`;
|
||||
}
|
||||
|
||||
function toDealText(row: DealSnapshotRow) {
|
||||
return `${row.title} (${row.contactName}) · ${row.stage}${row.nextStep ? ` · next: ${row.nextStep}` : ""}`;
|
||||
}
|
||||
|
||||
function toWorkspaceDocumentText(row: WorkspaceDocumentSnapshotRow) {
|
||||
return `${row.title} · ${row.type} · ${row.owner} · ${row.scope} · ${row.summary}`;
|
||||
}
|
||||
|
||||
export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise<SnapshotState> {
|
||||
const [calendar, notes, messages, deals, documents] = await Promise.all([
|
||||
prisma.calendarEvent.findMany({
|
||||
where: { teamId },
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
contactId: true,
|
||||
title: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
note: true,
|
||||
isArchived: true,
|
||||
archiveNote: true,
|
||||
archivedAt: true,
|
||||
},
|
||||
take: 4000,
|
||||
}),
|
||||
prisma.contactNote.findMany({
|
||||
where: { contact: { teamId } },
|
||||
select: { contactId: true, content: true, contact: { select: { name: true } } },
|
||||
take: 4000,
|
||||
}),
|
||||
prisma.contactMessage.findMany({
|
||||
where: { contact: { teamId } },
|
||||
include: { contact: { select: { name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 6000,
|
||||
}),
|
||||
prisma.deal.findMany({
|
||||
where: { teamId },
|
||||
include: { contact: { select: { name: true } } },
|
||||
take: 4000,
|
||||
}),
|
||||
prisma.workspaceDocument.findMany({
|
||||
where: { teamId },
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
title: true,
|
||||
type: true,
|
||||
owner: true,
|
||||
scope: true,
|
||||
summary: true,
|
||||
body: true,
|
||||
},
|
||||
take: 4000,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
calendarById: new Map(
|
||||
calendar.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
teamId: row.teamId,
|
||||
contactId: row.contactId ?? null,
|
||||
title: row.title,
|
||||
startsAt: row.startsAt.toISOString(),
|
||||
endsAt: row.endsAt?.toISOString() ?? null,
|
||||
note: row.note ?? null,
|
||||
isArchived: Boolean(row.isArchived),
|
||||
archiveNote: row.archiveNote ?? null,
|
||||
archivedAt: row.archivedAt?.toISOString() ?? null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
noteByContactId: new Map(
|
||||
notes.map((row) => [
|
||||
row.contactId,
|
||||
{
|
||||
contactId: row.contactId,
|
||||
contactName: row.contact.name,
|
||||
content: row.content,
|
||||
},
|
||||
]),
|
||||
),
|
||||
messageById: new Map(
|
||||
messages.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
contactId: row.contactId,
|
||||
contactName: row.contact.name,
|
||||
kind: row.kind,
|
||||
direction: row.direction,
|
||||
channel: row.channel,
|
||||
content: row.content,
|
||||
durationSec: row.durationSec ?? null,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
},
|
||||
]),
|
||||
),
|
||||
dealById: new Map(
|
||||
deals.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
contactName: row.contact.name,
|
||||
stage: row.stage,
|
||||
nextStep: row.nextStep ?? null,
|
||||
summary: row.summary ?? null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
documentById: new Map(
|
||||
documents.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
id: row.id,
|
||||
teamId: row.teamId,
|
||||
title: row.title,
|
||||
type: row.type,
|
||||
owner: row.owner,
|
||||
scope: row.scope,
|
||||
summary: row.summary,
|
||||
body: row.body,
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChangeSet(before: SnapshotState, after: SnapshotState): ChangeSet | null {
|
||||
const items: ChangeItem[] = [];
|
||||
const undo: UndoOp[] = [];
|
||||
const pushItem = (item: Omit<ChangeItem, "id">) => {
|
||||
const next: ChangeItem = { ...item, id: randomUUID() };
|
||||
items.push(next);
|
||||
undo.push(...next.undo);
|
||||
};
|
||||
|
||||
for (const [id, row] of after.calendarById) {
|
||||
const prev = before.calendarById.get(id);
|
||||
if (!prev) {
|
||||
pushItem({
|
||||
entity: "calendar_event",
|
||||
entityId: row.id,
|
||||
action: "created",
|
||||
title: `Event created: ${row.title}`,
|
||||
before: "",
|
||||
after: toCalendarText(row),
|
||||
undo: [{ kind: "delete_calendar_event", id }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
prev.title !== row.title ||
|
||||
prev.startsAt !== row.startsAt ||
|
||||
prev.endsAt !== row.endsAt ||
|
||||
fmt(prev.note) !== fmt(row.note) ||
|
||||
prev.isArchived !== row.isArchived ||
|
||||
fmt(prev.archiveNote) !== fmt(row.archiveNote) ||
|
||||
fmt(prev.archivedAt) !== fmt(row.archivedAt) ||
|
||||
prev.contactId !== row.contactId
|
||||
) {
|
||||
pushItem({
|
||||
entity: "calendar_event",
|
||||
entityId: row.id,
|
||||
action: "updated",
|
||||
title: `Event updated: ${row.title}`,
|
||||
before: toCalendarText(prev),
|
||||
after: toCalendarText(row),
|
||||
undo: [{ kind: "restore_calendar_event", data: prev }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, row] of before.calendarById) {
|
||||
if (after.calendarById.has(id)) continue;
|
||||
pushItem({
|
||||
entity: "calendar_event",
|
||||
entityId: row.id,
|
||||
action: "deleted",
|
||||
title: `Event archived: ${row.title}`,
|
||||
before: toCalendarText(row),
|
||||
after: "",
|
||||
undo: [{ kind: "restore_calendar_event", data: row }],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [contactId, row] of after.noteByContactId) {
|
||||
const prev = before.noteByContactId.get(contactId);
|
||||
if (!prev) {
|
||||
pushItem({
|
||||
entity: "contact_note",
|
||||
entityId: contactId,
|
||||
action: "created",
|
||||
title: `Summary added: ${row.contactName}`,
|
||||
before: "",
|
||||
after: row.content,
|
||||
undo: [{ kind: "restore_contact_note", contactId, content: null }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (prev.content !== row.content) {
|
||||
pushItem({
|
||||
entity: "contact_note",
|
||||
entityId: contactId,
|
||||
action: "updated",
|
||||
title: `Summary updated: ${row.contactName}`,
|
||||
before: prev.content,
|
||||
after: row.content,
|
||||
undo: [{ kind: "restore_contact_note", contactId, content: prev.content }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [contactId, row] of before.noteByContactId) {
|
||||
if (after.noteByContactId.has(contactId)) continue;
|
||||
pushItem({
|
||||
entity: "contact_note",
|
||||
entityId: contactId,
|
||||
action: "deleted",
|
||||
title: `Summary cleared: ${row.contactName}`,
|
||||
before: row.content,
|
||||
after: "",
|
||||
undo: [{ kind: "restore_contact_note", contactId, content: row.content }],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, row] of after.messageById) {
|
||||
if (before.messageById.has(id)) continue;
|
||||
pushItem({
|
||||
entity: "message",
|
||||
entityId: row.id,
|
||||
action: "created",
|
||||
title: `Message created: ${row.contactName}`,
|
||||
before: "",
|
||||
after: toMessageText(row),
|
||||
undo: [{ kind: "delete_contact_message", id }],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, row] of after.dealById) {
|
||||
const prev = before.dealById.get(id);
|
||||
if (!prev) continue;
|
||||
if (prev.stage !== row.stage || fmt(prev.nextStep) !== fmt(row.nextStep) || fmt(prev.summary) !== fmt(row.summary)) {
|
||||
pushItem({
|
||||
entity: "deal",
|
||||
entityId: row.id,
|
||||
action: "updated",
|
||||
title: `Deal updated: ${row.title}`,
|
||||
before: toDealText(prev),
|
||||
after: toDealText(row),
|
||||
undo: [
|
||||
{
|
||||
kind: "restore_deal",
|
||||
id,
|
||||
stage: prev.stage,
|
||||
nextStep: prev.nextStep,
|
||||
summary: prev.summary,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, row] of after.documentById) {
|
||||
const prev = before.documentById.get(id);
|
||||
if (!prev) {
|
||||
pushItem({
|
||||
entity: "workspace_document",
|
||||
entityId: row.id,
|
||||
action: "created",
|
||||
title: `Document created: ${row.title}`,
|
||||
before: "",
|
||||
after: toWorkspaceDocumentText(row),
|
||||
undo: [{ kind: "delete_workspace_document", id }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
prev.title !== row.title ||
|
||||
prev.type !== row.type ||
|
||||
prev.owner !== row.owner ||
|
||||
prev.scope !== row.scope ||
|
||||
prev.summary !== row.summary ||
|
||||
prev.body !== row.body
|
||||
) {
|
||||
pushItem({
|
||||
entity: "workspace_document",
|
||||
entityId: row.id,
|
||||
action: "updated",
|
||||
title: `Document updated: ${row.title}`,
|
||||
before: toWorkspaceDocumentText(prev),
|
||||
after: toWorkspaceDocumentText(row),
|
||||
undo: [{ kind: "restore_workspace_document", data: prev }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, row] of before.documentById) {
|
||||
if (after.documentById.has(id)) continue;
|
||||
pushItem({
|
||||
entity: "workspace_document",
|
||||
entityId: row.id,
|
||||
action: "deleted",
|
||||
title: `Document deleted: ${row.title}`,
|
||||
before: toWorkspaceDocumentText(row),
|
||||
after: "",
|
||||
undo: [{ kind: "restore_workspace_document", data: row }],
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const created = items.filter((x) => x.action === "created").length;
|
||||
const updated = items.filter((x) => x.action === "updated").length;
|
||||
const deleted = items.filter((x) => x.action === "deleted").length;
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
summary: `Created: ${created}, Updated: ${updated}, Archived: ${deleted}`,
|
||||
items,
|
||||
undo,
|
||||
rolledBackItemIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function applyUndoOps(prisma: PrismaClient, teamId: string, undoOps: UndoOp[]) {
|
||||
const ops = [...undoOps].reverse();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const op of ops) {
|
||||
if (op.kind === "delete_calendar_event") {
|
||||
await tx.calendarEvent.deleteMany({ where: { id: op.id, teamId } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_calendar_event") {
|
||||
const row = op.data;
|
||||
await tx.calendarEvent.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
teamId: row.teamId,
|
||||
contactId: row.contactId,
|
||||
title: row.title,
|
||||
startsAt: new Date(row.startsAt),
|
||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
||||
note: row.note,
|
||||
isArchived: row.isArchived,
|
||||
archiveNote: row.archiveNote,
|
||||
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
teamId: row.teamId,
|
||||
contactId: row.contactId,
|
||||
title: row.title,
|
||||
startsAt: new Date(row.startsAt),
|
||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
||||
note: row.note,
|
||||
isArchived: row.isArchived,
|
||||
archiveNote: row.archiveNote,
|
||||
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "delete_contact_message") {
|
||||
await tx.contactMessage.deleteMany({ where: { id: op.id } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_contact_message") {
|
||||
const row = op.data;
|
||||
await tx.contactMessage.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
contactId: row.contactId,
|
||||
kind: row.kind as any,
|
||||
direction: row.direction as any,
|
||||
channel: row.channel as any,
|
||||
content: row.content,
|
||||
durationSec: row.durationSec,
|
||||
occurredAt: new Date(row.occurredAt),
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
contactId: row.contactId,
|
||||
kind: row.kind as any,
|
||||
direction: row.direction as any,
|
||||
channel: row.channel as any,
|
||||
content: row.content,
|
||||
durationSec: row.durationSec,
|
||||
occurredAt: new Date(row.occurredAt),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_contact_note") {
|
||||
const contact = await tx.contact.findFirst({ where: { id: op.contactId, teamId }, select: { id: true } });
|
||||
if (!contact) continue;
|
||||
if (op.content === null) {
|
||||
await tx.contactNote.deleteMany({ where: { contactId: op.contactId } });
|
||||
} else {
|
||||
await tx.contactNote.upsert({
|
||||
where: { contactId: op.contactId },
|
||||
update: { content: op.content },
|
||||
create: { contactId: op.contactId, content: op.content },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_deal") {
|
||||
await tx.deal.updateMany({
|
||||
where: { id: op.id, teamId },
|
||||
data: {
|
||||
stage: op.stage,
|
||||
nextStep: op.nextStep,
|
||||
summary: op.summary,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "delete_workspace_document") {
|
||||
await tx.workspaceDocument.deleteMany({ where: { id: op.id, teamId } });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.kind === "restore_workspace_document") {
|
||||
const row = op.data;
|
||||
await tx.workspaceDocument.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
teamId: row.teamId,
|
||||
title: row.title,
|
||||
type: row.type as any,
|
||||
owner: row.owner,
|
||||
scope: row.scope,
|
||||
summary: row.summary,
|
||||
body: row.body,
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
teamId: row.teamId,
|
||||
title: row.title,
|
||||
type: row.type as any,
|
||||
owner: row.owner,
|
||||
scope: row.scope,
|
||||
summary: row.summary,
|
||||
body: row.body,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, changeSet: ChangeSet) {
|
||||
await applyUndoOps(prisma, teamId, changeSet.undo);
|
||||
}
|
||||
|
||||
export async function rollbackChangeSetItems(
|
||||
prisma: PrismaClient,
|
||||
teamId: string,
|
||||
changeSet: ChangeSet,
|
||||
itemIds: string[],
|
||||
) {
|
||||
const wanted = new Set(itemIds.filter(Boolean));
|
||||
if (!wanted.size) return;
|
||||
|
||||
const itemUndoOps = changeSet.items
|
||||
.filter((item) => wanted.has(item.id))
|
||||
.flatMap((item) => (Array.isArray(item.undo) ? item.undo : []));
|
||||
|
||||
if (itemUndoOps.length > 0) {
|
||||
await applyUndoOps(prisma, teamId, itemUndoOps);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy fallback for old change sets without per-item undo.
|
||||
if (wanted.size >= changeSet.items.length && Array.isArray(changeSet.undo) && changeSet.undo.length > 0) {
|
||||
await applyUndoOps(prisma, teamId, changeSet.undo);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Langfuse } from "langfuse";
|
||||
|
||||
let client: Langfuse | null = null;
|
||||
|
||||
function isTruthy(value: string | undefined) {
|
||||
const v = (value ?? "").trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "yes" || v === "on";
|
||||
}
|
||||
|
||||
export function isLangfuseEnabled() {
|
||||
const enabledRaw = process.env.LANGFUSE_ENABLED;
|
||||
if (enabledRaw && !isTruthy(enabledRaw)) return false;
|
||||
return Boolean((process.env.LANGFUSE_PUBLIC_KEY ?? "").trim() && (process.env.LANGFUSE_SECRET_KEY ?? "").trim());
|
||||
}
|
||||
|
||||
export function getLangfuseClient() {
|
||||
if (!isLangfuseEnabled()) return null;
|
||||
if (client) return client;
|
||||
|
||||
client = new Langfuse({
|
||||
publicKey: (process.env.LANGFUSE_PUBLIC_KEY ?? "").trim(),
|
||||
secretKey: (process.env.LANGFUSE_SECRET_KEY ?? "").trim(),
|
||||
baseUrl: (process.env.LANGFUSE_BASE_URL ?? "http://langfuse-web:3000").trim(),
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const SCRYPT_KEY_LENGTH = 64;
|
||||
|
||||
export function normalizePhone(raw: string) {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const hasPlus = trimmed.startsWith("+");
|
||||
const digits = trimmed.replace(/\D/g, "");
|
||||
if (!digits) return "";
|
||||
return `${hasPlus ? "+" : ""}${digits}`;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string) {
|
||||
const salt = randomBytes(16).toString("base64url");
|
||||
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
|
||||
return `scrypt$${salt}$${digest}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, encodedHash: string) {
|
||||
const [algo, salt, digest] = (encodedHash ?? "").split("$");
|
||||
if (algo !== "scrypt" || !salt || !digest) return false;
|
||||
|
||||
const actual = scryptSync(password, salt, SCRYPT_KEY_LENGTH);
|
||||
const expected = Buffer.from(digest, "base64url");
|
||||
if (actual.byteLength !== expected.byteLength) return false;
|
||||
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalThis.__prisma ??
|
||||
new PrismaClient({
|
||||
log: ["error", "warn"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__prisma = prisma;
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __redis: Redis | undefined;
|
||||
}
|
||||
|
||||
export function getRedis() {
|
||||
if (globalThis.__redis) return globalThis.__redis;
|
||||
|
||||
const url = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
const client = new Redis(url, {
|
||||
maxRetriesPerRequest: null, // recommended for BullMQ
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__redis = client;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export type TelegramUpdate = Record<string, any>;
|
||||
|
||||
export function telegramApiBase() {
|
||||
return process.env.TELEGRAM_API_BASE || "https://api.telegram.org";
|
||||
}
|
||||
|
||||
export function requireTelegramBotToken() {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
if (!token) throw new Error("TELEGRAM_BOT_TOKEN is required");
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function telegramBotApi<T>(method: string, body: unknown): Promise<T> {
|
||||
const token = requireTelegramBotToken();
|
||||
const res = await fetch(`${telegramApiBase()}/bot${token}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const json = (await res.json().catch(() => null)) as any;
|
||||
if (!res.ok || !json?.ok) {
|
||||
const desc = json?.description || `HTTP ${res.status}`;
|
||||
throw new Error(`Telegram API ${method} failed: ${desc}`);
|
||||
}
|
||||
|
||||
return json.result as T;
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
export type LinkTokenPayloadV1 = {
|
||||
v: 1;
|
||||
nonce: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
const TOKEN_TTL_SEC = Number(process.env.TELEGRAM_LINK_TOKEN_TTL_SEC || 10 * 60);
|
||||
|
||||
export function requireBotUsername() {
|
||||
const botUsername = String(process.env.TELEGRAM_BOT_USERNAME || "").trim().replace(/^@/, "");
|
||||
if (!botUsername) {
|
||||
throw createError({ statusCode: 500, statusMessage: "TELEGRAM_BOT_USERNAME is required" });
|
||||
}
|
||||
return botUsername;
|
||||
}
|
||||
|
||||
export function issueLinkToken(input: { teamId: string; userId: string }) {
|
||||
void input;
|
||||
// Telegram deep-link `start` parameter is limited, keep token very short.
|
||||
const token = randomBytes(12).toString("hex");
|
||||
const payload: LinkTokenPayloadV1 = {
|
||||
v: 1,
|
||||
nonce: token,
|
||||
exp: Math.floor(Date.now() / 1000) + Math.max(60, TOKEN_TTL_SEC),
|
||||
};
|
||||
return { token, payload };
|
||||
}
|
||||
|
||||
export function verifyLinkToken(token: string): LinkTokenPayloadV1 | null {
|
||||
const raw = String(token || "").trim();
|
||||
if (!/^[a-f0-9]{24}$/.test(raw)) return null;
|
||||
return {
|
||||
v: 1,
|
||||
nonce: raw,
|
||||
exp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractLinkTokenFromStartText(text: string) {
|
||||
const trimmed = String(text || "").trim();
|
||||
if (!trimmed.startsWith("/start")) return null;
|
||||
const parts = trimmed.split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
const arg = parts[1] || "";
|
||||
if (!arg.startsWith("link_")) return null;
|
||||
return arg.slice("link_".length);
|
||||
}
|
||||
|
||||
export function buildTelegramStartUrl(token: string) {
|
||||
const botUsername = requireBotUsername();
|
||||
return `https://t.me/${botUsername}?start=link_${token}`;
|
||||
}
|
||||
|
||||
export function getTelegramChatIdFromUpdate(update: any): string | null {
|
||||
const candidates = [
|
||||
update?.message?.chat?.id,
|
||||
update?.business_message?.chat?.id,
|
||||
update?.edited_business_message?.chat?.id,
|
||||
update?.business_connection?.user_chat_id,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (c == null) continue;
|
||||
const v = String(c).trim();
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getBusinessConnectionFromUpdate(update: any): {
|
||||
id: string;
|
||||
userChatId: string | null;
|
||||
isEnabled: boolean | null;
|
||||
canReply: boolean | null;
|
||||
raw: any;
|
||||
} | null {
|
||||
const bc = (update?.business_connection ?? null) as any;
|
||||
if (!bc || typeof bc !== "object") return null;
|
||||
|
||||
const id = String(bc.id ?? "").trim();
|
||||
if (!id) return null;
|
||||
|
||||
const userChatId = bc.user_chat_id != null ? String(bc.user_chat_id) : null;
|
||||
const isEnabled = typeof bc.is_enabled === "boolean" ? bc.is_enabled : null;
|
||||
const canReply = typeof bc.can_reply === "boolean"
|
||||
? bc.can_reply
|
||||
: typeof bc.rights?.can_reply === "boolean"
|
||||
? bc.rights.can_reply
|
||||
: null;
|
||||
|
||||
return {
|
||||
id,
|
||||
userChatId,
|
||||
isEnabled,
|
||||
canReply,
|
||||
raw: bc,
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
type WhisperTranscribeInput = {
|
||||
samples: Float32Array;
|
||||
sampleRate: number;
|
||||
language?: string;
|
||||
};
|
||||
let whisperPipelinePromise: Promise<any> | null = null;
|
||||
let transformersPromise: Promise<any> | null = null;
|
||||
|
||||
function getWhisperModelId() {
|
||||
return (process.env.CF_WHISPER_MODEL ?? "Xenova/whisper-small").trim() || "Xenova/whisper-small";
|
||||
}
|
||||
|
||||
function getWhisperLanguage() {
|
||||
const value = (process.env.CF_WHISPER_LANGUAGE ?? "ru").trim();
|
||||
return value || "ru";
|
||||
}
|
||||
|
||||
async function getWhisperPipeline() {
|
||||
if (!transformersPromise) {
|
||||
transformersPromise = import("@xenova/transformers");
|
||||
}
|
||||
|
||||
const { env, pipeline } = await transformersPromise;
|
||||
|
||||
if (!whisperPipelinePromise) {
|
||||
env.allowRemoteModels = true;
|
||||
env.allowLocalModels = true;
|
||||
env.cacheDir = "/app/.data/transformers";
|
||||
|
||||
const modelId = getWhisperModelId();
|
||||
whisperPipelinePromise = pipeline("automatic-speech-recognition", modelId);
|
||||
}
|
||||
|
||||
return whisperPipelinePromise;
|
||||
}
|
||||
|
||||
export async function transcribeWithWhisper(input: WhisperTranscribeInput) {
|
||||
const transcriber = (await getWhisperPipeline()) as any;
|
||||
const result = await transcriber(
|
||||
input.samples,
|
||||
{
|
||||
sampling_rate: input.sampleRate,
|
||||
language: (input.language ?? getWhisperLanguage()) || "ru",
|
||||
task: "transcribe",
|
||||
chunk_length_s: 20,
|
||||
stride_length_s: 5,
|
||||
return_timestamps: false,
|
||||
},
|
||||
);
|
||||
|
||||
const text = String((result as any)?.text ?? "").trim();
|
||||
return text;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
|
||||
34
hatchet/README.md
Normal file
34
hatchet/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Hatchet (Dokploy)
|
||||
|
||||
Compose-стек для self-hosted Hatchet, перенесенный из соседнего проекта `gl` и адаптированный под ENV.
|
||||
|
||||
## Файлы
|
||||
|
||||
- `docker-compose.yml` — сервисы Hatchet (Postgres, RabbitMQ, migration, setup-config, engine, dashboard).
|
||||
|
||||
## Обязательные ENV (Dokploy UI)
|
||||
|
||||
- `HATCHET_POSTGRES_USER` (default: `hatchet`)
|
||||
- `HATCHET_POSTGRES_PASSWORD` (default: `hatchet`)
|
||||
- `HATCHET_POSTGRES_DB` (default: `hatchet`)
|
||||
- `HATCHET_DATABASE_URL` (default: `postgres://hatchet:hatchet@postgres:5432/hatchet`)
|
||||
- `HATCHET_RABBITMQ_USER` (default: `user`)
|
||||
- `HATCHET_RABBITMQ_PASSWORD` (default: `password`)
|
||||
- `HATCHET_RABBITMQ_URL` (default: `amqp://user:password@rabbitmq:5672/`)
|
||||
- `HATCHET_SERVER_AUTH_COOKIE_DOMAIN` (например, `hatchet.<ваш-домен>`)
|
||||
- `HATCHET_SERVER_AUTH_COOKIE_INSECURE` (`t`/`f`)
|
||||
- `HATCHET_SERVER_GRPC_INSECURE` (`t`/`f`)
|
||||
- `HATCHET_SERVER_GRPC_BROADCAST_ADDRESS` (например, `hatchet-engine:7070` внутри сети)
|
||||
|
||||
## ENV для приложений-воркеров (Node SDK)
|
||||
|
||||
- `HATCHET_CLIENT_TOKEN` — токен клиента из Hatchet.
|
||||
- `HATCHET_CLIENT_TLS_STRATEGY` — для self-host без TLS: `none`.
|
||||
- `HATCHET_CLIENT_HOST_PORT` — gRPC адрес (например, `hatchet-engine:7070` в одной Docker-сети).
|
||||
- `HATCHET_CLIENT_API_URL` — URL API Hatchet dashboard/api.
|
||||
|
||||
## Развертывание
|
||||
|
||||
Сервис описан в `deploy-map.toml` как:
|
||||
|
||||
`hatchet = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "hatchet/docker-compose.yml" }`
|
||||
121
hatchet/docker-compose.yml
Normal file
121
hatchet/docker-compose.yml
Normal file
@@ -0,0 +1,121 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15.6
|
||||
command: postgres -c "max_connections=1000"
|
||||
restart: always
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${HATCHET_POSTGRES_USER:-hatchet}
|
||||
POSTGRES_PASSWORD: ${HATCHET_POSTGRES_PASSWORD:-hatchet}
|
||||
POSTGRES_DB: ${HATCHET_POSTGRES_DB:-hatchet}
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- hatchet_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d ${HATCHET_POSTGRES_DB:-hatchet} -U ${HATCHET_POSTGRES_USER:-hatchet}"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management
|
||||
hostname: rabbitmq
|
||||
expose:
|
||||
- "5672"
|
||||
- "15672"
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: ${HATCHET_RABBITMQ_USER:-user}
|
||||
RABBITMQ_DEFAULT_PASS: ${HATCHET_RABBITMQ_PASSWORD:-password}
|
||||
volumes:
|
||||
- hatchet_rabbitmq_data:/var/lib/rabbitmq
|
||||
- hatchet_rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmqctl", "status"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
migration:
|
||||
image: ghcr.io/hatchet-dev/hatchet/hatchet-migrate:latest
|
||||
command: /hatchet/hatchet-migrate
|
||||
environment:
|
||||
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
setup-config:
|
||||
image: ghcr.io/hatchet-dev/hatchet/hatchet-admin:latest
|
||||
command: /hatchet/hatchet-admin quickstart --skip certs --generated-config-dir /hatchet/config --overwrite=false
|
||||
environment:
|
||||
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||
SERVER_MSGQUEUE_RABBITMQ_URL: ${HATCHET_RABBITMQ_URL:-amqp://user:password@rabbitmq:5672/}
|
||||
SERVER_AUTH_COOKIE_DOMAIN: ${HATCHET_SERVER_AUTH_COOKIE_DOMAIN:-hatchet.local}
|
||||
SERVER_AUTH_COOKIE_INSECURE: ${HATCHET_SERVER_AUTH_COOKIE_INSECURE:-t}
|
||||
SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
|
||||
SERVER_GRPC_INSECURE: ${HATCHET_SERVER_GRPC_INSECURE:-t}
|
||||
SERVER_GRPC_BROADCAST_ADDRESS: ${HATCHET_SERVER_GRPC_BROADCAST_ADDRESS:-hatchet-engine:7070}
|
||||
SERVER_DEFAULT_ENGINE_VERSION: V1
|
||||
SERVER_INTERNAL_CLIENT_INTERNAL_GRPC_BROADCAST_ADDRESS: hatchet-engine:7070
|
||||
volumes:
|
||||
- hatchet_certs:/hatchet/certs
|
||||
- hatchet_config:/hatchet/config
|
||||
depends_on:
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
hatchet-engine:
|
||||
image: ghcr.io/hatchet-dev/hatchet/hatchet-engine:latest
|
||||
command: /hatchet/hatchet-engine --config /hatchet/config
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
setup-config:
|
||||
condition: service_completed_successfully
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
expose:
|
||||
- "7070"
|
||||
environment:
|
||||
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||
SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
|
||||
SERVER_GRPC_INSECURE: ${HATCHET_SERVER_GRPC_INSECURE:-t}
|
||||
volumes:
|
||||
- hatchet_certs:/hatchet/certs
|
||||
- hatchet_config:/hatchet/config
|
||||
networks:
|
||||
- default
|
||||
- dokploy-network
|
||||
|
||||
hatchet-dashboard:
|
||||
image: ghcr.io/hatchet-dev/hatchet/hatchet-dashboard:latest
|
||||
command: sh ./entrypoint.sh --config /hatchet/config
|
||||
expose:
|
||||
- "80"
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
setup-config:
|
||||
condition: service_completed_successfully
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||
volumes:
|
||||
- hatchet_certs:/hatchet/certs
|
||||
- hatchet_config:/hatchet/config
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
hatchet_postgres_data:
|
||||
hatchet_rabbitmq_data:
|
||||
hatchet_rabbitmq.conf:
|
||||
hatchet_config:
|
||||
hatchet_certs:
|
||||
Submodule instructions updated: da748fa24c...19bbaf3e08
@@ -1,11 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -1,27 +0,0 @@
|
||||
# omni_chat
|
||||
|
||||
Изолированный сервис chat-core (домен диалогов).
|
||||
|
||||
## Назначение
|
||||
|
||||
- потребляет входящие события из `receiver.flow`;
|
||||
- применяет бизнес-логику диалогов;
|
||||
- публикует исходящие команды в `sender.flow`.
|
||||
|
||||
Текущий шаг: выделен отдельный сервисный контур и health endpoint.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health`
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
- `PORT` (default: `8090`)
|
||||
- `RECEIVER_FLOW_QUEUE_NAME` (default: `receiver.flow`)
|
||||
- `SENDER_FLOW_QUEUE_NAME` (default: `sender.flow`)
|
||||
|
||||
## Prisma policy
|
||||
|
||||
- Источник схемы: `frontend/prisma/schema.prisma`.
|
||||
- Локальная копия в `omni_chat/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
|
||||
- Миграции/`db push` выполняются только в `frontend`.
|
||||
1334
omni_chat/package-lock.json
generated
1334
omni_chat/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "crm-omni-chat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"postinstall": "node ./node_modules/prisma/build/index.js generate --schema ./prisma/schema.prisma"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.9",
|
||||
"prisma": "^6.16.1",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.1",
|
||||
"bullmq": "^5.70.0"
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum TeamRole {
|
||||
OWNER
|
||||
MEMBER
|
||||
}
|
||||
|
||||
enum MessageDirection {
|
||||
IN
|
||||
OUT
|
||||
}
|
||||
|
||||
enum MessageChannel {
|
||||
TELEGRAM
|
||||
WHATSAPP
|
||||
INSTAGRAM
|
||||
PHONE
|
||||
EMAIL
|
||||
INTERNAL
|
||||
}
|
||||
|
||||
enum ContactMessageKind {
|
||||
MESSAGE
|
||||
CALL
|
||||
}
|
||||
|
||||
enum ChatRole {
|
||||
USER
|
||||
ASSISTANT
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum OmniMessageStatus {
|
||||
PENDING
|
||||
SENT
|
||||
FAILED
|
||||
DELIVERED
|
||||
READ
|
||||
}
|
||||
|
||||
enum FeedCardDecision {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum WorkspaceDocumentType {
|
||||
Regulation
|
||||
Playbook
|
||||
Policy
|
||||
Template
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members TeamMember[]
|
||||
contacts Contact[]
|
||||
calendarEvents CalendarEvent[]
|
||||
deals Deal[]
|
||||
conversations ChatConversation[]
|
||||
chatMessages ChatMessage[]
|
||||
|
||||
omniThreads OmniThread[]
|
||||
omniMessages OmniMessage[]
|
||||
omniIdentities OmniContactIdentity[]
|
||||
telegramBusinessConnections TelegramBusinessConnection[]
|
||||
|
||||
feedCards FeedCard[]
|
||||
contactPins ContactPin[]
|
||||
documents WorkspaceDocument[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
phone String @unique
|
||||
passwordHash String
|
||||
email String? @unique
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships TeamMember[]
|
||||
conversations ChatConversation[] @relation("ConversationCreator")
|
||||
chatMessages ChatMessage[] @relation("ChatAuthor")
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
userId String
|
||||
role TeamRole @default(MEMBER)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Contact {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
name String
|
||||
company String?
|
||||
country String?
|
||||
location String?
|
||||
avatarUrl String?
|
||||
email String?
|
||||
phone String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
note ContactNote?
|
||||
messages ContactMessage[]
|
||||
events CalendarEvent[]
|
||||
deals Deal[]
|
||||
feedCards FeedCard[]
|
||||
pins ContactPin[]
|
||||
|
||||
omniThreads OmniThread[]
|
||||
omniMessages OmniMessage[]
|
||||
omniIdentities OmniContactIdentity[]
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model ContactNote {
|
||||
id String @id @default(cuid())
|
||||
contactId String @unique
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ContactMessage {
|
||||
id String @id @default(cuid())
|
||||
contactId String
|
||||
kind ContactMessageKind @default(MESSAGE)
|
||||
direction MessageDirection
|
||||
channel MessageChannel
|
||||
content String
|
||||
audioUrl String?
|
||||
durationSec Int?
|
||||
transcriptJson Json?
|
||||
occurredAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([contactId, occurredAt])
|
||||
}
|
||||
|
||||
model OmniContactIdentity {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
channel MessageChannel
|
||||
externalId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, channel, externalId])
|
||||
@@index([contactId])
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model OmniThread {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
channel MessageChannel
|
||||
externalChatId String
|
||||
businessConnectionId String?
|
||||
title String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
messages OmniMessage[]
|
||||
|
||||
@@unique([teamId, channel, externalChatId, businessConnectionId])
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([contactId, updatedAt])
|
||||
}
|
||||
|
||||
model OmniMessage {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
threadId String
|
||||
direction MessageDirection
|
||||
channel MessageChannel
|
||||
status OmniMessageStatus @default(PENDING)
|
||||
text String
|
||||
providerMessageId String?
|
||||
providerUpdateId String?
|
||||
rawJson Json?
|
||||
occurredAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
thread OmniThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([threadId, providerMessageId])
|
||||
@@index([teamId, occurredAt])
|
||||
@@index([threadId, occurredAt])
|
||||
}
|
||||
|
||||
model TelegramBusinessConnection {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
businessConnectionId String
|
||||
isEnabled Boolean?
|
||||
canReply Boolean?
|
||||
rawJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, businessConnectionId])
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model CalendarEvent {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String?
|
||||
title String
|
||||
startsAt DateTime
|
||||
endsAt DateTime?
|
||||
note String?
|
||||
isArchived Boolean @default(false)
|
||||
archiveNote String?
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([startsAt])
|
||||
@@index([contactId, startsAt])
|
||||
@@index([teamId, startsAt])
|
||||
}
|
||||
|
||||
model Deal {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
title String
|
||||
stage String
|
||||
amount Int?
|
||||
nextStep String?
|
||||
summary String?
|
||||
currentStepId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
steps DealStep[]
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([contactId, updatedAt])
|
||||
@@index([currentStepId])
|
||||
}
|
||||
|
||||
model DealStep {
|
||||
id String @id @default(cuid())
|
||||
dealId String
|
||||
title String
|
||||
description String?
|
||||
status String @default("todo")
|
||||
dueAt DateTime?
|
||||
order Int @default(0)
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([dealId, order])
|
||||
@@index([status, dueAt])
|
||||
}
|
||||
|
||||
model ChatConversation {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
createdByUserId String
|
||||
title String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([createdByUserId])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
conversationId String
|
||||
authorUserId String?
|
||||
role ChatRole
|
||||
text String
|
||||
planJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
authorUser User? @relation("ChatAuthor", fields: [authorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([teamId, createdAt])
|
||||
@@index([conversationId, createdAt])
|
||||
}
|
||||
|
||||
model FeedCard {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String?
|
||||
happenedAt DateTime
|
||||
text String
|
||||
proposalJson Json
|
||||
decision FeedCardDecision @default(PENDING)
|
||||
decisionNote String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([teamId, happenedAt])
|
||||
@@index([contactId, happenedAt])
|
||||
}
|
||||
|
||||
model ContactPin {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
text String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([contactId, updatedAt])
|
||||
}
|
||||
|
||||
model WorkspaceDocument {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
title String
|
||||
type WorkspaceDocumentType
|
||||
owner String
|
||||
scope String
|
||||
summary String
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { closeReceiverWorker, RECEIVER_FLOW_QUEUE_NAME, receiverQueue, startReceiverWorker } from "./worker";
|
||||
|
||||
const port = Number(process.env.PORT || 8090);
|
||||
const service = "omni_chat";
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
const q = receiverQueue();
|
||||
const counts = await q.getJobCounts("wait", "active", "failed", "completed", "delayed");
|
||||
await q.close();
|
||||
const payload = JSON.stringify({
|
||||
ok: true,
|
||||
service,
|
||||
receiverFlow: RECEIVER_FLOW_QUEUE_NAME,
|
||||
senderFlow: process.env.SENDER_FLOW_QUEUE_NAME || "sender.flow",
|
||||
queue: counts,
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: false, error: "not_found" }));
|
||||
});
|
||||
|
||||
startReceiverWorker();
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`[omni_chat] listening on :${port}`);
|
||||
console.log(`[omni_chat] receiver worker started for queue ${RECEIVER_FLOW_QUEUE_NAME}`);
|
||||
});
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[omni_chat] shutting down by ${signal}`);
|
||||
try {
|
||||
await closeReceiverWorker();
|
||||
} finally {
|
||||
server.close(() => process.exit(0));
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __omniChatPrisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalThis.__omniChatPrisma ??
|
||||
new PrismaClient({
|
||||
log: ["error", "warn"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__omniChatPrisma = prisma;
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
import { Queue, Worker, type ConnectionOptions, type Job } from "bullmq";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./utils/prisma";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type OmniInboundEnvelopeV1 = {
|
||||
version: 1;
|
||||
idempotencyKey: string;
|
||||
provider: string;
|
||||
channel: "TELEGRAM" | "WHATSAPP" | "INSTAGRAM" | "PHONE" | "EMAIL" | "INTERNAL";
|
||||
direction: "IN" | "OUT";
|
||||
providerEventId: string;
|
||||
providerMessageId: string | null;
|
||||
eventType: string;
|
||||
occurredAt: string;
|
||||
receivedAt: string;
|
||||
payloadRaw: unknown;
|
||||
payloadNormalized: {
|
||||
threadExternalId: string | null;
|
||||
contactExternalId: string | null;
|
||||
text: string | null;
|
||||
businessConnectionId: string | null;
|
||||
updateId?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const RECEIVER_FLOW_QUEUE_NAME = (process.env.RECEIVER_FLOW_QUEUE_NAME || "receiver.flow").trim();
|
||||
const TELEGRAM_PLACEHOLDER_PREFIX = "Telegram ";
|
||||
|
||||
function redisConnectionFromEnv(): ConnectionOptions {
|
||||
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
|
||||
const parsed = new URL(raw);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : 6379,
|
||||
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
||||
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
||||
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeText(input: unknown) {
|
||||
const t = String(input ?? "").trim();
|
||||
return t || "[no text]";
|
||||
}
|
||||
|
||||
function parseOccurredAt(input: string | null | undefined) {
|
||||
const d = new Date(String(input ?? ""));
|
||||
if (Number.isNaN(d.getTime())) return new Date();
|
||||
return d;
|
||||
}
|
||||
|
||||
function asString(input: unknown) {
|
||||
if (typeof input !== "string") return null;
|
||||
const trimmed = input.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function safeDirection(input: unknown): "IN" | "OUT" {
|
||||
return input === "OUT" ? "OUT" : "IN";
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
|
||||
}
|
||||
|
||||
type ContactProfile = {
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
function buildContactProfile(
|
||||
normalized: OmniInboundEnvelopeV1["payloadNormalized"],
|
||||
externalContactId: string,
|
||||
): ContactProfile {
|
||||
const firstName =
|
||||
asString(normalized.contactFirstName) ??
|
||||
asString(normalized.fromFirstName) ??
|
||||
asString(normalized.chatFirstName);
|
||||
const lastName =
|
||||
asString(normalized.contactLastName) ??
|
||||
asString(normalized.fromLastName) ??
|
||||
asString(normalized.chatLastName);
|
||||
const username =
|
||||
asString(normalized.contactUsername) ??
|
||||
asString(normalized.fromUsername) ??
|
||||
asString(normalized.chatUsername);
|
||||
const title = asString(normalized.contactTitle) ?? asString(normalized.chatTitle);
|
||||
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
||||
const displayName =
|
||||
fullName ||
|
||||
(username ? `@${username.replace(/^@/, "")}` : null) ||
|
||||
title ||
|
||||
`${TELEGRAM_PLACEHOLDER_PREFIX}${externalContactId}`;
|
||||
|
||||
return {
|
||||
displayName,
|
||||
avatarUrl: asString(normalized.contactAvatarUrl),
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeHydrateContact(contactId: string, profile: ContactProfile) {
|
||||
const current = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
select: { name: true, avatarUrl: true },
|
||||
});
|
||||
if (!current) return;
|
||||
|
||||
const updates: Prisma.ContactUpdateInput = {};
|
||||
const currentName = asString(current.name);
|
||||
const nextName = asString(profile.displayName);
|
||||
|
||||
if (nextName && (!currentName || currentName.startsWith(TELEGRAM_PLACEHOLDER_PREFIX)) && currentName !== nextName) {
|
||||
updates.name = nextName;
|
||||
}
|
||||
|
||||
const currentAvatar = asString(current.avatarUrl);
|
||||
if (profile.avatarUrl && !currentAvatar) {
|
||||
updates.avatarUrl = profile.avatarUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
await prisma.contact.update({
|
||||
where: { id: contactId },
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveTeamId(env: OmniInboundEnvelopeV1) {
|
||||
const n = env.payloadNormalized ?? ({} as OmniInboundEnvelopeV1["payloadNormalized"]);
|
||||
const bcId = String(n.businessConnectionId ?? "").trim();
|
||||
if (bcId) {
|
||||
const linked = await prisma.telegramBusinessConnection.findFirst({
|
||||
where: { businessConnectionId: bcId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { teamId: true },
|
||||
});
|
||||
if (linked?.teamId) return linked.teamId;
|
||||
}
|
||||
|
||||
const externalContactId = String(n.contactExternalId ?? n.threadExternalId ?? "").trim();
|
||||
if (externalContactId) {
|
||||
const pseudo = `link:${externalContactId}`;
|
||||
const linked = await prisma.telegramBusinessConnection.findFirst({
|
||||
where: { businessConnectionId: pseudo },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { teamId: true },
|
||||
});
|
||||
if (linked?.teamId) return linked.teamId;
|
||||
}
|
||||
|
||||
const fallbackTeamId = String(process.env.DEFAULT_TEAM_ID || "").trim();
|
||||
if (fallbackTeamId) return fallbackTeamId;
|
||||
|
||||
const demo = await prisma.team.findFirst({
|
||||
where: { id: "demo-team" },
|
||||
select: { id: true },
|
||||
});
|
||||
return demo?.id ?? null;
|
||||
}
|
||||
|
||||
async function resolveContact(input: {
|
||||
teamId: string;
|
||||
externalContactId: string;
|
||||
profile: ContactProfile;
|
||||
}) {
|
||||
const existingIdentity = await prisma.omniContactIdentity.findFirst({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
channel: "TELEGRAM",
|
||||
externalId: input.externalContactId,
|
||||
},
|
||||
select: { contactId: true },
|
||||
});
|
||||
if (existingIdentity?.contactId) {
|
||||
await maybeHydrateContact(existingIdentity.contactId, input.profile);
|
||||
return existingIdentity.contactId;
|
||||
}
|
||||
|
||||
const contact = await prisma.contact.create({
|
||||
data: {
|
||||
teamId: input.teamId,
|
||||
name: input.profile.displayName,
|
||||
avatarUrl: input.profile.avatarUrl,
|
||||
company: null,
|
||||
country: null,
|
||||
location: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.omniContactIdentity.create({
|
||||
data: {
|
||||
teamId: input.teamId,
|
||||
contactId: contact.id,
|
||||
channel: "TELEGRAM",
|
||||
externalId: input.externalContactId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) throw error;
|
||||
|
||||
const concurrentIdentity = await prisma.omniContactIdentity.findFirst({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
channel: "TELEGRAM",
|
||||
externalId: input.externalContactId,
|
||||
},
|
||||
select: { contactId: true },
|
||||
});
|
||||
if (!concurrentIdentity?.contactId) throw error;
|
||||
|
||||
await prisma.contact.delete({ where: { id: contact.id } }).catch(() => undefined);
|
||||
await maybeHydrateContact(concurrentIdentity.contactId, input.profile);
|
||||
return concurrentIdentity.contactId;
|
||||
}
|
||||
|
||||
return contact.id;
|
||||
}
|
||||
|
||||
async function upsertThread(input: {
|
||||
teamId: string;
|
||||
contactId: string;
|
||||
externalChatId: string;
|
||||
businessConnectionId: string | null;
|
||||
title: string | null;
|
||||
}) {
|
||||
const existing = await prisma.omniThread.findFirst({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
channel: "TELEGRAM",
|
||||
externalChatId: input.externalChatId,
|
||||
businessConnectionId: input.businessConnectionId,
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const data: Prisma.OmniThreadUpdateInput = {
|
||||
contactId: input.contactId,
|
||||
};
|
||||
if (input.title && !existing.title) {
|
||||
data.title = input.title;
|
||||
}
|
||||
|
||||
await prisma.omniThread.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
|
||||
try {
|
||||
return await prisma.omniThread.create({
|
||||
data: {
|
||||
teamId: input.teamId,
|
||||
contactId: input.contactId,
|
||||
channel: "TELEGRAM",
|
||||
externalChatId: input.externalChatId,
|
||||
businessConnectionId: input.businessConnectionId,
|
||||
title: input.title,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) throw error;
|
||||
|
||||
const concurrentThread = await prisma.omniThread.findFirst({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
channel: "TELEGRAM",
|
||||
externalChatId: input.externalChatId,
|
||||
businessConnectionId: input.businessConnectionId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!concurrentThread) throw error;
|
||||
|
||||
await prisma.omniThread.update({
|
||||
where: { id: concurrentThread.id },
|
||||
data: { contactId: input.contactId },
|
||||
});
|
||||
return concurrentThread;
|
||||
}
|
||||
}
|
||||
|
||||
async function ingestInbound(env: OmniInboundEnvelopeV1) {
|
||||
if (env.channel !== "TELEGRAM") return;
|
||||
|
||||
const teamId = await resolveTeamId(env);
|
||||
if (!teamId) {
|
||||
console.warn("[omni_chat] skip inbound: team not resolved", env.providerEventId);
|
||||
return;
|
||||
}
|
||||
|
||||
const n = env.payloadNormalized ?? ({} as OmniInboundEnvelopeV1["payloadNormalized"]);
|
||||
const externalContactId = String(n.contactExternalId ?? n.threadExternalId ?? "").trim();
|
||||
const externalChatId = String(n.threadExternalId ?? n.contactExternalId ?? "").trim();
|
||||
|
||||
if (!externalContactId || !externalChatId) {
|
||||
console.warn("[omni_chat] skip inbound: missing contact/chat ids", env.providerEventId);
|
||||
return;
|
||||
}
|
||||
|
||||
const businessConnectionId = String(n.businessConnectionId ?? "").trim() || null;
|
||||
const text = normalizeText(n.text);
|
||||
const occurredAt = parseOccurredAt(env.occurredAt);
|
||||
const direction = safeDirection(env.direction);
|
||||
const contactProfile = buildContactProfile(n, externalContactId);
|
||||
|
||||
const contactId = await resolveContact({
|
||||
teamId,
|
||||
externalContactId,
|
||||
profile: contactProfile,
|
||||
});
|
||||
const thread = await upsertThread({
|
||||
teamId,
|
||||
contactId,
|
||||
externalChatId,
|
||||
businessConnectionId,
|
||||
title: asString(n.chatTitle),
|
||||
});
|
||||
|
||||
if (env.providerMessageId) {
|
||||
await prisma.omniMessage.upsert({
|
||||
where: {
|
||||
threadId_providerMessageId: {
|
||||
threadId: thread.id,
|
||||
providerMessageId: env.providerMessageId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
contactId,
|
||||
threadId: thread.id,
|
||||
direction,
|
||||
channel: "TELEGRAM",
|
||||
status: "DELIVERED",
|
||||
text,
|
||||
providerMessageId: env.providerMessageId,
|
||||
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
|
||||
rawJson: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
|
||||
occurredAt,
|
||||
},
|
||||
update: {
|
||||
text,
|
||||
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
|
||||
rawJson: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
|
||||
occurredAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.omniMessage.create({
|
||||
data: {
|
||||
teamId,
|
||||
contactId,
|
||||
threadId: thread.id,
|
||||
direction,
|
||||
channel: "TELEGRAM",
|
||||
status: "DELIVERED",
|
||||
text,
|
||||
providerMessageId: null,
|
||||
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
|
||||
rawJson: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
|
||||
occurredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.contactMessage.create({
|
||||
data: {
|
||||
contactId,
|
||||
kind: "MESSAGE",
|
||||
direction,
|
||||
channel: "TELEGRAM",
|
||||
content: text,
|
||||
occurredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let workerInstance: Worker<OmniInboundEnvelopeV1, unknown, "ingest"> | null = null;
|
||||
|
||||
export function startReceiverWorker() {
|
||||
if (workerInstance) return workerInstance;
|
||||
|
||||
const worker = new Worker<OmniInboundEnvelopeV1, unknown, "ingest">(
|
||||
RECEIVER_FLOW_QUEUE_NAME,
|
||||
async (job) => {
|
||||
await ingestInbound(job.data);
|
||||
},
|
||||
{
|
||||
connection: redisConnectionFromEnv(),
|
||||
concurrency: Number(process.env.OMNI_CHAT_WORKER_CONCURRENCY || 4),
|
||||
},
|
||||
);
|
||||
|
||||
worker.on("failed", (job: Job<OmniInboundEnvelopeV1, unknown, "ingest"> | undefined, err: Error) => {
|
||||
console.error(`[omni_chat] receiver job failed id=${job?.id || "unknown"}: ${err?.message || err}`);
|
||||
});
|
||||
|
||||
workerInstance = worker;
|
||||
return worker;
|
||||
}
|
||||
|
||||
export async function closeReceiverWorker() {
|
||||
if (!workerInstance) return;
|
||||
await workerInstance.close();
|
||||
workerInstance = null;
|
||||
}
|
||||
|
||||
export function receiverQueue() {
|
||||
return new Queue<OmniInboundEnvelopeV1, unknown, "ingest">(RECEIVER_FLOW_QUEUE_NAME, {
|
||||
connection: redisConnectionFromEnv(),
|
||||
});
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -1,48 +0,0 @@
|
||||
# omni_inbound
|
||||
|
||||
Отдельный сервис приема входящих webhook-событий каналов (первый канал: Telegram Business).
|
||||
|
||||
## Задача сервиса
|
||||
|
||||
- принимать webhook;
|
||||
- валидировать секрет;
|
||||
- нормализовать событие в универсальный envelope;
|
||||
- делать durable enqueue в BullMQ (`receiver.flow`);
|
||||
- возвращать `200` только после успешного enqueue.
|
||||
|
||||
Сервис **не** содержит бизнес-логику CRM и не вызывает provider API для исходящих сообщений.
|
||||
|
||||
## API
|
||||
|
||||
### `GET /health`
|
||||
Проверка живости сервиса.
|
||||
|
||||
### `POST /webhooks/telegram/business`
|
||||
Прием Telegram Business webhook.
|
||||
|
||||
При активном `TELEGRAM_WEBHOOK_SECRET` ожидается заголовок:
|
||||
|
||||
- `x-telegram-bot-api-secret-token: <TELEGRAM_WEBHOOK_SECRET>`
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
- `PORT` (default: `8080`)
|
||||
- `REDIS_URL` (default: `redis://localhost:6379`)
|
||||
- `RECEIVER_FLOW_QUEUE_NAME` (default: `receiver.flow`)
|
||||
- `INBOUND_QUEUE_NAME` (legacy alias, optional)
|
||||
- `TELEGRAM_WEBHOOK_SECRET` (optional, но обязателен для production)
|
||||
- `TELEGRAM_CONNECT_WEBHOOK_FORWARD_URL` (optional; URL CRM endpoint для линковки Telegram Business)
|
||||
- `MAX_BODY_SIZE_BYTES` (default: `1048576`)
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Надежность
|
||||
|
||||
- Идемпотентность: `jobId` строится из `idempotencyKey` (SHA-256).
|
||||
- Дубликаты webhook безопасны и не приводят к повторной постановке события.
|
||||
- При ошибке enqueue сервис возвращает `503`, чтобы провайдер повторил доставку.
|
||||
908
omni_inbound/package-lock.json
generated
908
omni_inbound/package-lock.json
generated
@@ -1,908 +0,0 @@
|
||||
{
|
||||
"name": "crm-omni-inbound",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "crm-omni-inbound",
|
||||
"dependencies": {
|
||||
"bullmq": "^5.58.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.9",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bullmq": {
|
||||
"version": "5.69.4",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.69.4.tgz",
|
||||
"integrity": "sha512-Lp7ymp875I/rtjMm6oxzQ3PrvDDHkgge0oaAznmZsKtGyglfdrg9zbidPSszTXgWFkS2rCgMcTRNJfM3uUMOjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "4.9.0",
|
||||
"ioredis": "5.9.2",
|
||||
"msgpackr": "1.11.5",
|
||||
"node-abort-controller": "3.1.1",
|
||||
"semver": "7.7.4",
|
||||
"tslib": "2.8.1",
|
||||
"uuid": "11.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
|
||||
"integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.5.0",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.5",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
|
||||
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abort-controller": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Queue, type ConnectionOptions } from "bullmq";
|
||||
import type { OmniInboundEnvelopeV1 } from "./types";
|
||||
|
||||
export const RECEIVER_FLOW_QUEUE_NAME = (
|
||||
process.env.RECEIVER_FLOW_QUEUE_NAME ||
|
||||
process.env.INBOUND_QUEUE_NAME ||
|
||||
"receiver.flow"
|
||||
).trim();
|
||||
|
||||
let queueInstance: Queue<OmniInboundEnvelopeV1, unknown, "ingest"> | null = null;
|
||||
|
||||
function redisConnectionFromEnv(): ConnectionOptions {
|
||||
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
|
||||
const parsed = new URL(raw);
|
||||
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : 6379,
|
||||
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
||||
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
||||
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toJobId(idempotencyKey: string) {
|
||||
const hash = createHash("sha256").update(idempotencyKey).digest("hex");
|
||||
return `inbound-${hash}`;
|
||||
}
|
||||
|
||||
export function inboundQueue() {
|
||||
if (queueInstance) return queueInstance;
|
||||
|
||||
queueInstance = new Queue<OmniInboundEnvelopeV1, unknown, "ingest">(RECEIVER_FLOW_QUEUE_NAME, {
|
||||
connection: redisConnectionFromEnv(),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: { count: 10000 },
|
||||
removeOnFail: { count: 20000 },
|
||||
attempts: 8,
|
||||
backoff: { type: "exponential", delay: 1000 },
|
||||
},
|
||||
});
|
||||
|
||||
return queueInstance;
|
||||
}
|
||||
|
||||
export async function enqueueInboundEvent(envelope: OmniInboundEnvelopeV1) {
|
||||
const q = inboundQueue();
|
||||
const jobId = toJobId(envelope.idempotencyKey);
|
||||
|
||||
return q.add("ingest", envelope, {
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
|
||||
export function isDuplicateJobError(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const message = String((error as { message?: string }).message || "").toLowerCase();
|
||||
return message.includes("job") && message.includes("exists");
|
||||
}
|
||||
|
||||
export async function closeInboundQueue() {
|
||||
if (!queueInstance) return;
|
||||
await queueInstance.close();
|
||||
queueInstance = null;
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { RECEIVER_FLOW_QUEUE_NAME, enqueueInboundEvent, isDuplicateJobError } from "./queue";
|
||||
import { parseTelegramBusinessUpdate } from "./telegram";
|
||||
|
||||
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 1024 * 1024);
|
||||
|
||||
function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
|
||||
const payload = JSON.stringify(body);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buf.length;
|
||||
if (total > MAX_BODY_SIZE_BYTES) {
|
||||
throw new Error(`payload_too_large:${MAX_BODY_SIZE_BYTES}`);
|
||||
}
|
||||
chunks.push(buf);
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function validateTelegramSecret(req: IncomingMessage): boolean {
|
||||
const expected = (process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
|
||||
if (!expected) return true;
|
||||
|
||||
const incoming = String(req.headers["x-telegram-bot-api-secret-token"] || "").trim();
|
||||
return incoming !== "" && incoming === expected;
|
||||
}
|
||||
|
||||
async function forwardTelegramConnectWebhook(rawBody: unknown) {
|
||||
const url = (process.env.TELEGRAM_CONNECT_WEBHOOK_FORWARD_URL || "").trim();
|
||||
if (!url) return;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
const secret = (process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
|
||||
if (secret) {
|
||||
headers["x-telegram-bot-api-secret-token"] = secret;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(rawBody ?? {}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.warn(`[omni_inbound] telegram connect forward failed: ${res.status} ${text.slice(0, 300)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[omni_inbound] telegram connect forward error: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startServer() {
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (!req.url || !req.method) {
|
||||
writeJson(res, 404, { ok: false, error: "not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/health" && req.method === "GET") {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: "omni_inbound",
|
||||
queue: RECEIVER_FLOW_QUEUE_NAME,
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/webhooks/telegram/business" && req.method === "POST") {
|
||||
if (!validateTelegramSecret(req)) {
|
||||
writeJson(res, 401, { ok: false, error: "invalid_webhook_secret" });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
let envelope: ReturnType<typeof parseTelegramBusinessUpdate> | null = null;
|
||||
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
envelope = parseTelegramBusinessUpdate(body);
|
||||
|
||||
await enqueueInboundEvent(envelope);
|
||||
|
||||
void forwardTelegramConnectWebhook(body);
|
||||
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
queued: true,
|
||||
duplicate: false,
|
||||
providerEventId: envelope.providerEventId,
|
||||
idempotencyKey: envelope.idempotencyKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDuplicateJobError(error)) {
|
||||
void forwardTelegramConnectWebhook(body);
|
||||
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
queued: false,
|
||||
duplicate: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const statusCode = message.startsWith("payload_too_large:") ? 413 : 503;
|
||||
writeJson(res, statusCode, {
|
||||
ok: false,
|
||||
error: "receiver_enqueue_failed",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, { ok: false, error: "not_found" });
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`[omni_inbound] listening on :${port}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app/delivery
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY src ./src
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -1,22 +0,0 @@
|
||||
# omni_outbound
|
||||
|
||||
Изолированный сервис исходящей доставки.
|
||||
|
||||
## Назначение
|
||||
|
||||
- потребляет задачи из `sender.flow`;
|
||||
- выполняет отправку в провайдеров;
|
||||
- применяет retry/backoff и финальный fail-status.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
- `REDIS_URL`
|
||||
- `DATABASE_URL`
|
||||
- `SENDER_FLOW_QUEUE_NAME` (default: `sender.flow`)
|
||||
- `OUTBOUND_DELIVERY_QUEUE_NAME` (legacy alias, optional)
|
||||
|
||||
## Prisma policy
|
||||
|
||||
- Источник схемы: `frontend/prisma/schema.prisma`.
|
||||
- Локальная копия в `omni_outbound/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
|
||||
- Миграции/`db push` выполняются только в `frontend`.
|
||||
1358
omni_outbound/package-lock.json
generated
1358
omni_outbound/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user