refactor chat delivery to graphql + hatchet services

This commit is contained in:
Ruslan Bakiev
2026-03-08 18:55:58 +07:00
parent fe4bd59248
commit 7d1bed0d67
61 changed files with 5007 additions and 5004 deletions

35
backend/README.md Normal file
View File

@@ -0,0 +1,35 @@
# backend
Core CRM/omni-домен с единственной Prisma-базой.
## Назначение
- принимает входящие telegram-события через GraphQL mutation `ingestTelegramInbound`;
- создает исходящую задачу через GraphQL mutation `requestTelegramOutbound``telegram_backend`, далее в Hatchet);
- принимает отчет о доставке через GraphQL mutation `reportTelegramOutbound`.
## API
- `GET /health`
- `POST /graphql`
## GraphQL auth
Если задан `BACKEND_GRAPHQL_SHARED_SECRET`, запросы на `/graphql` должны содержать заголовок:
- `x-graphql-secret: <BACKEND_GRAPHQL_SHARED_SECRET>`
## Переменные окружения
- `PORT` (default: `8090`)
- `MAX_BODY_SIZE_BYTES` (default: `2097152`)
- `BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `TELEGRAM_BACKEND_GRAPHQL_URL` (required для `requestTelegramOutbound`)
- `TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `DEFAULT_TEAM_ID` (optional fallback для inbound маршрутизации)
## Prisma policy
- Источник схемы: `Frontend/prisma/schema.prisma`.
- Локальная копия в `backend/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
- Миграции/`db push` выполняются только в `Frontend`.

View File

@@ -1,14 +1,13 @@
{
"name": "crm-omni-chat",
"name": "crm-backend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crm-omni-chat",
"hasInstallScript": true,
"name": "crm-backend",
"dependencies": {
"@prisma/client": "^6.16.1",
"bullmq": "^5.70.0"
"graphql": "^16.13.1"
},
"devDependencies": {
"@types/node": "^22.13.9",
@@ -459,90 +458,6 @@
"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/@prisma/client": {
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.1.tgz",
@@ -645,21 +560,6 @@
"undici-types": "~6.21.0"
}
},
"node_modules/bullmq": {
"version": "5.70.0",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.70.0.tgz",
"integrity": "sha512-HlBSEJqG7MJ97+d/N/8rtGOcpisjGP3WD/zaXZia0hsmckJqAPTVWN6Yfw32FVfVSUVVInZQ2nUgMd2zCRghKg==",
"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/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -715,15 +615,6 @@
"consola": "^3.2.3"
}
},
"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/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
@@ -741,35 +632,6 @@
"node": "^14.18.0 || >=16.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/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
@@ -787,15 +649,6 @@
"devOptional": true,
"license": "MIT"
},
"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/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@@ -803,16 +656,6 @@
"devOptional": true,
"license": "MIT"
},
"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/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -965,28 +808,13 @@
"giget": "dist/cli.mjs"
}
},
"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==",
"node_modules/graphql": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz",
"integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==",
"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": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/jiti": {
@@ -999,70 +827,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"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-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
@@ -1070,21 +834,6 @@
"devOptional": true,
"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/nypm": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
@@ -1211,27 +960,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"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",
@@ -1242,24 +970,6 @@
"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/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@@ -1270,12 +980,6 @@
"node": ">=18"
}
},
"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",
@@ -1316,19 +1020,6 @@
"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"
}
}
}
}

View File

@@ -1,20 +1,19 @@
{
"name": "crm-omni-outbound",
"name": "crm-backend",
"private": true,
"type": "module",
"scripts": {
"db:generate": "prisma generate",
"start": "tsx src/worker.ts"
},
"dependencies": {
"@prisma/client": "^6.16.1",
"bullmq": "^5.58.2",
"ioredis": "^5.7.0"
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.13.9",
"prisma": "^6.16.1",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"dependencies": {
"@prisma/client": "^6.16.1",
"graphql": "^16.13.1"
}
}

View File

@@ -1,10 +1,10 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client"
output = "../server/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum TeamRole {
@@ -58,6 +58,12 @@ enum WorkspaceDocumentType {
Template
}
enum ClientTimelineContentType {
CALENDAR_EVENT
DOCUMENT
RECOMMENDATION
}
model Team {
id String @id @default(cuid())
name String
@@ -79,8 +85,10 @@ model Team {
feedCards FeedCard[]
contactPins ContactPin[]
documents WorkspaceDocument[]
clientTimelineEntries ClientTimelineEntry[]
contactInboxes ContactInbox[]
contactInboxPreferences ContactInboxPreference[]
contactThreadReads ContactThreadRead[]
}
model User {
@@ -96,6 +104,7 @@ model User {
aiConversations AiConversation[] @relation("ConversationCreator")
aiMessages AiMessage[] @relation("ChatAuthor")
contactInboxPreferences ContactInboxPreference[]
contactThreadReads ContactThreadRead[]
}
model TeamMember {
@@ -134,10 +143,29 @@ model Contact {
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
contactInboxes ContactInbox[]
clientTimelineEntries ClientTimelineEntry[]
contactThreadReads ContactThreadRead[]
@@index([teamId, updatedAt])
}
model ContactThreadRead {
id String @id @default(cuid())
teamId String
userId String
contactId String
readAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([userId, contactId])
@@index([teamId, userId])
}
model ContactNote {
id String @id @default(cuid())
contactId String @unique
@@ -435,3 +463,21 @@ model WorkspaceDocument {
@@index([teamId, updatedAt])
}
model ClientTimelineEntry {
id String @id @default(cuid())
teamId String
contactId String
contentType ClientTimelineContentType
contentId String
datetime 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)
@@unique([teamId, contentType, contentId])
@@index([teamId, contactId, datetime])
@@index([contactId, datetime])
}

View File

@@ -1,10 +1,10 @@
import { closeInboundQueue } from "./queue";
import { startServer } from "./server";
import { prisma } from "./utils/prisma";
const server = startServer();
async function shutdown(signal: string) {
console.log(`[omni_inbound] shutting down by ${signal}`);
console.log(`[backend] shutting down by ${signal}`);
try {
await new Promise<void>((resolve, reject) => {
@@ -21,7 +21,7 @@ async function shutdown(signal: string) {
}
try {
await closeInboundQueue();
await prisma.$disconnect();
} catch {
// ignore shutdown errors
}

232
backend/src/server.ts Normal file
View File

@@ -0,0 +1,232 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { buildSchema, graphql } from "graphql";
import {
ingestTelegramInbound,
reportTelegramOutbound,
requestTelegramOutbound,
type TelegramInboundEnvelope,
type TelegramOutboundReport,
type TelegramOutboundRequest,
} from "./service";
const PORT = Number(process.env.PORT || 8090);
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 2 * 1024 * 1024);
const GRAPHQL_SHARED_SECRET = String(process.env.BACKEND_GRAPHQL_SHARED_SECRET || "").trim();
const schema = buildSchema(`
type Query {
health: Health!
}
type Health {
ok: Boolean!
service: String!
now: String!
}
type MutationResult {
ok: Boolean!
message: String!
runId: String
omniMessageId: String
}
input TelegramInboundInput {
version: Int!
idempotencyKey: String!
provider: String!
channel: String!
direction: String!
providerEventId: String!
providerMessageId: String
eventType: String!
occurredAt: String!
receivedAt: String!
payloadRawJson: String!
payloadNormalizedJson: String!
}
input TelegramOutboundReportInput {
omniMessageId: String!
status: String!
providerMessageId: String
error: String
responseJson: String
}
input TelegramOutboundTaskInput {
omniMessageId: String!
chatId: String!
text: String!
businessConnectionId: String
}
type Mutation {
ingestTelegramInbound(input: TelegramInboundInput!): MutationResult!
reportTelegramOutbound(input: TelegramOutboundReportInput!): MutationResult!
requestTelegramOutbound(input: TelegramOutboundTaskInput!): MutationResult!
}
`);
function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
res.statusCode = statusCode;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function isGraphqlAuthorized(req: IncomingMessage) {
if (!GRAPHQL_SHARED_SECRET) return true;
const incoming = String(req.headers["x-graphql-secret"] || "").trim();
return incoming !== "" && incoming === GRAPHQL_SHARED_SECRET;
}
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 parseJsonField<T>(raw: string, fieldName: string): T {
try {
return JSON.parse(raw) as T;
} catch {
throw new Error(`${fieldName} must be valid JSON string`);
}
}
const root = {
health: () => ({
ok: true,
service: "backend",
now: new Date().toISOString(),
}),
ingestTelegramInbound: async ({ input }: { input: any }) => {
const envelope: TelegramInboundEnvelope = {
version: Number(input.version ?? 1),
idempotencyKey: String(input.idempotencyKey ?? ""),
provider: String(input.provider ?? ""),
channel: String(input.channel ?? ""),
direction: String(input.direction ?? "IN") === "OUT" ? "OUT" : "IN",
providerEventId: String(input.providerEventId ?? ""),
providerMessageId: input.providerMessageId != null ? String(input.providerMessageId) : null,
eventType: String(input.eventType ?? ""),
occurredAt: String(input.occurredAt ?? new Date().toISOString()),
receivedAt: String(input.receivedAt ?? new Date().toISOString()),
payloadRaw: parseJsonField(input.payloadRawJson, "payloadRawJson"),
payloadNormalized: parseJsonField(input.payloadNormalizedJson, "payloadNormalizedJson"),
};
const result = await ingestTelegramInbound(envelope);
return {
ok: result.ok,
message: result.message,
omniMessageId: (result as any).omniMessageId ?? null,
runId: null,
};
},
reportTelegramOutbound: async ({ input }: { input: any }) => {
const payload: TelegramOutboundReport = {
omniMessageId: String(input.omniMessageId ?? ""),
status: String(input.status ?? "FAILED"),
providerMessageId: input.providerMessageId != null ? String(input.providerMessageId) : null,
error: input.error != null ? String(input.error) : null,
responseJson: input.responseJson != null ? String(input.responseJson) : null,
};
const result = await reportTelegramOutbound(payload);
return {
ok: result.ok,
message: result.message,
runId: null,
omniMessageId: null,
};
},
requestTelegramOutbound: async ({ input }: { input: any }) => {
const payload: TelegramOutboundRequest = {
omniMessageId: String(input.omniMessageId ?? ""),
chatId: String(input.chatId ?? ""),
text: String(input.text ?? ""),
businessConnectionId: input.businessConnectionId != null ? String(input.businessConnectionId) : null,
};
const result = await requestTelegramOutbound(payload);
return {
ok: result.ok,
message: result.message,
runId: result.runId ?? null,
omniMessageId: null,
};
},
};
export function startServer() {
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: "backend",
now: new Date().toISOString(),
});
return;
}
if (req.url === "/graphql" && req.method === "POST") {
if (!isGraphqlAuthorized(req)) {
writeJson(res, 401, { errors: [{ message: "unauthorized" }] });
return;
}
try {
const body = (await readJsonBody(req)) as {
query?: string;
variables?: Record<string, unknown>;
operationName?: string;
};
const result = await graphql({
schema,
source: String(body.query || ""),
rootValue: root,
variableValues: body.variables || {},
operationName: body.operationName,
});
writeJson(res, 200, result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const statusCode = message.startsWith("payload_too_large:") ? 413 : 400;
writeJson(res, statusCode, { errors: [{ message }] });
}
return;
}
writeJson(res, 404, { ok: false, error: "not_found" });
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`[backend] listening on :${PORT}`);
});
return server;
}

512
backend/src/service.ts Normal file
View File

@@ -0,0 +1,512 @@
type MessageDirection = "IN" | "OUT";
type OmniMessageStatus = "PENDING" | "SENT" | "FAILED" | "DELIVERED" | "READ";
import { prisma } from "./utils/prisma";
export type TelegramInboundEnvelope = {
version: number;
idempotencyKey: string;
provider: string;
channel: string;
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;
[key: string]: unknown;
};
};
export type TelegramOutboundReport = {
omniMessageId: string;
status: string;
providerMessageId?: string | null;
error?: string | null;
responseJson?: string | null;
};
export type TelegramOutboundRequest = {
omniMessageId: string;
chatId: string;
text: string;
businessConnectionId?: string | null;
};
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
return v || null;
}
function parseDate(value: string) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return new Date();
return d;
}
function normalizeDirection(value: string): MessageDirection {
return value === "OUT" ? "OUT" : "IN";
}
async function resolveTeamId(envelope: TelegramInboundEnvelope) {
const n = envelope.payloadNormalized;
const bcId = asString(n.businessConnectionId);
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 = asString(n.contactExternalId) ?? asString(n.threadExternalId);
if (externalContactId) {
const linked = await prisma.telegramBusinessConnection.findFirst({
where: { businessConnectionId: `link:${externalContactId}` },
orderBy: { updatedAt: "desc" },
select: { teamId: true },
});
if (linked?.teamId) return linked.teamId;
}
const fallback = asString(process.env.DEFAULT_TEAM_ID);
if (fallback) return fallback;
const firstTeam = await prisma.team.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
});
return firstTeam?.id ?? null;
}
async function resolveContact(input: {
teamId: string;
externalContactId: string;
displayName: string;
avatarUrl: string | null;
}) {
const existing = await prisma.omniContactIdentity.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
select: { contactId: true },
});
if (existing?.contactId) {
return existing.contactId;
}
const contact = await prisma.contact.create({
data: {
teamId: input.teamId,
name: input.displayName,
avatarUrl: input.avatarUrl,
},
select: { id: true },
});
try {
await prisma.omniContactIdentity.create({
data: {
teamId: input.teamId,
contactId: contact.id,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
});
return contact.id;
} catch {
const concurrent = await prisma.omniContactIdentity.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
select: { contactId: true },
});
if (concurrent?.contactId) {
await prisma.contact.delete({ where: { id: contact.id } }).catch(() => undefined);
return concurrent.contactId;
}
throw new Error("failed to create telegram contact identity");
}
}
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 },
});
if (existing) {
await prisma.omniThread.update({
where: { id: existing.id },
data: {
contactId: input.contactId,
...(input.title ? { title: input.title } : {}),
},
select: { id: true },
});
return existing.id;
}
try {
const created = 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 },
});
return created.id;
} catch {
const concurrent = await prisma.omniThread.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
},
select: { id: true },
});
if (concurrent?.id) return concurrent.id;
throw new Error("failed to upsert telegram thread");
}
}
async function upsertContactInbox(input: {
teamId: string;
contactId: string;
sourceExternalId: string;
title: string | null;
}) {
const inbox = await prisma.contactInbox.upsert({
where: {
teamId_channel_sourceExternalId: {
teamId: input.teamId,
channel: "TELEGRAM",
sourceExternalId: input.sourceExternalId,
},
},
create: {
teamId: input.teamId,
contactId: input.contactId,
channel: "TELEGRAM",
sourceExternalId: input.sourceExternalId,
title: input.title,
},
update: {
contactId: input.contactId,
...(input.title ? { title: input.title } : {}),
},
select: { id: true },
});
return inbox.id;
}
async function markRead(teamId: string, externalChatId: string) {
const thread = await prisma.omniThread.findFirst({
where: {
teamId,
channel: "TELEGRAM",
externalChatId,
},
select: { contactId: true },
});
if (!thread) return;
const members = await prisma.teamMember.findMany({
where: { teamId },
select: { userId: true },
});
const readAt = new Date();
await Promise.all(
members.map((member: { userId: string }) =>
prisma.contactThreadRead.upsert({
where: {
userId_contactId: {
userId: member.userId,
contactId: thread.contactId,
},
},
create: {
teamId,
userId: member.userId,
contactId: thread.contactId,
readAt,
},
update: { readAt },
}),
),
);
}
export async function ingestTelegramInbound(envelope: TelegramInboundEnvelope) {
if (envelope.channel !== "TELEGRAM") {
return { ok: true, message: "skip_non_telegram" };
}
const teamId = await resolveTeamId(envelope);
if (!teamId) {
throw new Error("team_not_resolved");
}
const n = envelope.payloadNormalized;
const externalChatId = asString(n.threadExternalId) ?? asString(n.contactExternalId);
if (!externalChatId) {
throw new Error("thread_external_id_required");
}
if (envelope.eventType === "read_business_message") {
await markRead(teamId, externalChatId);
return { ok: true, message: "read_marked" };
}
const externalContactId = asString(n.contactExternalId) ?? externalChatId;
const businessConnectionId = asString(n.businessConnectionId);
const text = asString(n.text) ?? "[no text]";
const occurredAt = parseDate(envelope.occurredAt);
const direction = normalizeDirection(envelope.direction);
const contactFirstName = asString(n.contactFirstName);
const contactLastName = asString(n.contactLastName);
const contactUsername = asString(n.contactUsername);
const fallbackName = `Telegram ${externalContactId}`;
const displayName =
[contactFirstName, contactLastName].filter(Boolean).join(" ") ||
(contactUsername ? `@${contactUsername.replace(/^@/, "")}` : null) ||
fallbackName;
const contactId = await resolveContact({
teamId,
externalContactId,
displayName,
avatarUrl: asString(n.contactAvatarUrl),
});
const threadId = await upsertThread({
teamId,
contactId,
externalChatId,
businessConnectionId,
title: asString(n.chatTitle),
});
const contactInboxId = await upsertContactInbox({
teamId,
contactId,
sourceExternalId: externalChatId,
title: asString(n.chatTitle),
});
const rawEnvelope: Record<string, unknown> = {
version: envelope.version,
source: "backend.graphql.ingestTelegramInbound",
provider: envelope.provider,
channel: envelope.channel,
direction,
providerEventId: envelope.providerEventId,
receivedAt: envelope.receivedAt,
occurredAt: occurredAt.toISOString(),
payloadNormalized: n,
payloadRaw: envelope.payloadRaw ?? null,
};
let omniMessageId: string;
if (envelope.providerMessageId) {
const message = await prisma.omniMessage.upsert({
where: {
threadId_providerMessageId: {
threadId,
providerMessageId: envelope.providerMessageId,
},
},
create: {
teamId,
contactId,
threadId,
direction,
channel: "TELEGRAM",
status: "DELIVERED",
text,
providerMessageId: envelope.providerMessageId,
providerUpdateId: envelope.providerEventId,
rawJson: rawEnvelope,
occurredAt,
},
update: {
text,
providerUpdateId: envelope.providerEventId,
rawJson: rawEnvelope,
occurredAt,
},
select: { id: true },
});
omniMessageId = message.id;
} else {
const message = await prisma.omniMessage.create({
data: {
teamId,
contactId,
threadId,
direction,
channel: "TELEGRAM",
status: "DELIVERED",
text,
providerMessageId: null,
providerUpdateId: envelope.providerEventId,
rawJson: rawEnvelope,
occurredAt,
},
select: { id: true },
});
omniMessageId = message.id;
}
await prisma.contactMessage.create({
data: {
contactId,
contactInboxId,
kind: "MESSAGE",
direction,
channel: "TELEGRAM",
content: text,
occurredAt,
},
});
return { ok: true, message: "inbound_ingested", omniMessageId };
}
export async function reportTelegramOutbound(input: TelegramOutboundReport) {
const statusRaw = input.status.trim().toUpperCase();
const status: OmniMessageStatus =
statusRaw === "SENT" ||
statusRaw === "FAILED" ||
statusRaw === "DELIVERED" ||
statusRaw === "READ" ||
statusRaw === "PENDING"
? (statusRaw as OmniMessageStatus)
: "FAILED";
const existing = await prisma.omniMessage.findUnique({
where: { id: input.omniMessageId },
select: { rawJson: true },
});
const raw = (existing?.rawJson && typeof existing.rawJson === "object" && !Array.isArray(existing.rawJson)
? (existing.rawJson as Record<string, unknown>)
: {}) as Record<string, unknown>;
await prisma.omniMessage.update({
where: { id: input.omniMessageId },
data: {
status,
...(input.providerMessageId ? { providerMessageId: input.providerMessageId } : {}),
rawJson: {
...raw,
telegramWorker: {
reportedAt: new Date().toISOString(),
status,
error: input.error ?? null,
response: (() => {
if (!input.responseJson) return null;
try {
return JSON.parse(input.responseJson);
} catch {
return input.responseJson;
}
})(),
},
},
},
});
return { ok: true, message: "outbound_reported" };
}
async function callTelegramBackendGraphql<T>(query: string, variables: Record<string, unknown>) {
const url = asString(process.env.TELEGRAM_BACKEND_GRAPHQL_URL);
if (!url) {
throw new Error("TELEGRAM_BACKEND_GRAPHQL_URL is required");
}
const headers: Record<string, string> = {
"content-type": "application/json",
};
const secret = asString(process.env.TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET);
if (secret) {
headers["x-graphql-secret"] = secret;
}
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ query, variables }),
});
const payload = (await response.json()) as { data?: T; errors?: Array<{ message?: string }> };
if (!response.ok || payload.errors?.length) {
const errorMessage = payload.errors?.map((e) => e.message).filter(Boolean).join("; ") || `HTTP ${response.status}`;
throw new Error(errorMessage);
}
return payload.data as T;
}
export async function requestTelegramOutbound(input: TelegramOutboundRequest) {
type Out = {
enqueueTelegramOutbound: {
ok: boolean;
message: string;
runId?: string | null;
};
};
const query = `mutation Enqueue($input: TelegramOutboundTaskInput!) {
enqueueTelegramOutbound(input: $input) {
ok
message
runId
}
}`;
const data = await callTelegramBackendGraphql<Out>(query, { input });
const result = data.enqueueTelegramOutbound;
if (!result?.ok) {
throw new Error(result?.message || "enqueue failed");
}
return { ok: true, message: "outbound_enqueued", runId: result.runId ?? null };
}

View File

@@ -2,8 +2,7 @@ version = 1
[services]
frontend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
schedulers = { 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" }
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" }

View File

@@ -1,104 +1,93 @@
# ADR-0001: Разделение Chat Platform на 3 сервиса
# ADR-0001: Chat Platform Boundaries (GraphQL + Hatchet)
Дата: 2026-02-21
Дата: 2026-03-08
Статус: accepted
## Контекст
Сейчас delivery уже вынесен отдельно, но часть omni-интеграции остается в приложении `frontend`.
Нужна архитектура, где входящие вебхуки, доменная логика чатов и исходящая доставка развиваются независимо и не ломают друг друга.
Нужна минимальная и предсказуемая схема из 5 сервисов:
Критичные требования:
- `frontend`
- `backend`
- `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. `hatchet`
- единый оркестратор задач, ретраев и backoff-политик.
- Входящий контур должен отвечать быстро и предсказуемо.
- Исходящий контур живет с долгими retry и ограничениями провайдера.
- Сбой внешнего API не должен блокировать прием входящих сообщений.
## Потоки
### 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`.
## Границы ответственности
`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-контекста.
## Надежность
`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`.
## Последствия
Плюсы:
- независимые релизы и масштабирование по ролям;
- меньше 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`.

View File

@@ -2,19 +2,18 @@
## Single source of truth
- Canonical Prisma schema: `frontend/prisma/schema.prisma`.
- Service copies:
- `omni_chat/prisma/schema.prisma`
- `omni_outbound/prisma/schema.prisma`
- Canonical Prisma schema: `Frontend/prisma/schema.prisma`.
- Service copy:
- `backend/prisma/schema.prisma`
## Update flow
1. Edit only `frontend/prisma/schema.prisma`.
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.
- Schema rollout (`prisma db push` / migrations) is allowed only in `Frontend`.
- `backend` must use generated Prisma client only.

View File

@@ -1,60 +1,6 @@
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 },
throw createError({
statusCode: 410,
statusMessage: "Legacy delivery enqueue is disabled. Use backend GraphQL requestTelegramOutbound.",
});
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,
};
});

View File

@@ -1,11 +1,79 @@
import { readBody } from "h3";
import { getAuthContext } from "../../../utils/auth";
import { prisma } from "../../../utils/prisma";
import { enqueueTelegramSend } from "../../../queues/telegramSend";
type BackendGraphqlResponse<T> = {
data?: T;
errors?: Array<{ message?: string }>;
};
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
return v || null;
}
async function requestTelegramOutbound(input: {
omniMessageId: string;
chatId: string;
text: string;
businessConnectionId?: string | null;
}) {
type Out = {
requestTelegramOutbound: {
ok: boolean;
message: string;
runId?: string | null;
};
};
const url = asString(process.env.BACKEND_GRAPHQL_URL);
if (!url) throw new Error("BACKEND_GRAPHQL_URL is required");
const headers: Record<string, string> = {
"content-type": "application/json",
};
const secret = asString(process.env.BACKEND_GRAPHQL_SHARED_SECRET);
if (secret) {
headers["x-graphql-secret"] = secret;
}
const query = `mutation RequestTelegramOutbound($input: TelegramOutboundTaskInput!) {
requestTelegramOutbound(input: $input) {
ok
message
runId
}
}`;
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
operationName: "RequestTelegramOutbound",
query,
variables: { input },
}),
});
const payload = (await response.json()) as BackendGraphqlResponse<Out>;
if (!response.ok || payload.errors?.length) {
const message =
payload.errors?.map((error) => error.message).filter(Boolean).join("; ") || `HTTP ${response.status}`;
throw new Error(message);
}
const result = payload.data?.requestTelegramOutbound;
if (!result?.ok) {
throw new Error(result?.message || "requestTelegramOutbound failed");
}
return result;
}
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event);
const body = await readBody<{ omniMessageId?: string; attempts?: number }>(event);
const body = await readBody<{ omniMessageId?: string }>(event);
const omniMessageId = String(body?.omniMessageId ?? "").trim();
if (!omniMessageId) {
@@ -14,19 +82,26 @@ export default defineEventHandler(async (event) => {
const msg = await prisma.omniMessage.findFirst({
where: { id: omniMessageId, teamId: auth.teamId, channel: "TELEGRAM", direction: "OUT" },
select: { id: true },
select: {
id: true,
text: true,
thread: { select: { externalChatId: true, businessConnectionId: 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 });
const result = await requestTelegramOutbound({
omniMessageId: msg.id,
chatId: msg.thread.externalChatId,
text: msg.text,
businessConnectionId: msg.thread.businessConnectionId ?? null,
});
return {
ok: true,
queue: process.env.SENDER_FLOW_QUEUE_NAME || process.env.OUTBOUND_DELIVERY_QUEUE_NAME || "sender.flow",
jobId: job.id,
runId: result.runId ?? null,
omniMessageId,
};
});

View File

@@ -1,9 +1,4 @@
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();
// Legacy BullMQ worker was removed.
// Delivery orchestration now runs in hatchet/telegram_worker service.
});

View File

@@ -8,7 +8,6 @@ import { normalizePhone, verifyPassword } from "../utils/password";
import { persistAiMessage, runCrmAgentFor } from "../agent/crmAgent";
import { buildChangeSet, captureSnapshot, rollbackChangeSet, rollbackChangeSetItems } from "../utils/changeSet";
import type { ChangeSet } from "../utils/changeSet";
import { enqueueTelegramSend } from "../queues/telegramSend";
import { datasetRoot } from "../dataset/paths";
type GraphQLContext = {
@@ -16,6 +15,11 @@ type GraphQLContext = {
event: H3Event;
};
type BackendGraphqlResponse<T> = {
data?: T;
errors?: Array<{ message?: string }>;
};
function requireAuth(auth: AuthContext | null) {
if (!auth) {
throw new Error("Unauthorized");
@@ -45,6 +49,79 @@ function asObject(value: unknown): Record<string, unknown> {
return value as Record<string, unknown>;
}
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
return v || null;
}
async function requestTelegramOutboundFromBackend(input: {
omniMessageId: string;
chatId: string;
text: string;
businessConnectionId?: string | null;
}) {
type Out = {
requestTelegramOutbound: {
ok: boolean;
message: string;
runId?: string | null;
};
};
const url = asString(process.env.BACKEND_GRAPHQL_URL);
if (!url) {
throw new Error("BACKEND_GRAPHQL_URL is required");
}
const headers: Record<string, string> = {
"content-type": "application/json",
};
const secret = asString(process.env.BACKEND_GRAPHQL_SHARED_SECRET);
if (secret) {
headers["x-graphql-secret"] = secret;
}
const query = `mutation RequestTelegramOutbound($input: TelegramOutboundTaskInput!) {
requestTelegramOutbound(input: $input) {
ok
message
runId
}
}`;
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
operationName: "RequestTelegramOutbound",
query,
variables: {
input: {
omniMessageId: input.omniMessageId,
chatId: input.chatId,
text: input.text,
businessConnectionId: input.businessConnectionId ?? null,
},
},
}),
});
const payload = (await response.json()) as BackendGraphqlResponse<Out>;
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.map((error) => error.message).filter(Boolean).join("; ") || `HTTP ${response.status}`;
throw new Error(message);
}
const result = payload.data?.requestTelegramOutbound;
if (!result?.ok) {
throw new Error(result?.message || "requestTelegramOutbound failed");
}
return result;
}
function readNestedString(obj: Record<string, unknown>, path: string[]): string {
let current: unknown = obj;
for (const segment of path) {
@@ -1416,7 +1493,7 @@ async function createCommunication(auth: AuthContext | null, input: {
channel: "TELEGRAM",
},
orderBy: { updatedAt: "desc" },
select: { id: true, externalChatId: true, title: true },
select: { id: true, externalChatId: true, businessConnectionId: true, title: true },
});
if (!thread) {
throw new Error("telegram thread not found for contact");
@@ -1464,7 +1541,12 @@ async function createCommunication(auth: AuthContext | null, input: {
});
try {
await enqueueTelegramSend({ omniMessageId: omniMessage.id });
await requestTelegramOutboundFromBackend({
omniMessageId: omniMessage.id,
chatId: thread.externalChatId,
text: content,
businessConnectionId: thread.businessConnectionId ?? null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const existingOmni = await prisma.omniMessage.findUnique({
@@ -1486,7 +1568,7 @@ async function createCommunication(auth: AuthContext | null, input: {
},
},
}).catch(() => undefined);
throw new Error(`telegram enqueue failed: ${message}`);
throw new Error(`telegram outbound request failed: ${message}`);
}
} else {
const existingInbox = await prisma.contactInbox.findFirst({

View File

@@ -1,243 +0,0 @@
import { Queue, Worker, type JobsOptions, type ConnectionOptions } from "bullmq";
import { Prisma } from "../generated/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);
}
function asObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
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;
const existing = await prisma.omniMessage.findUnique({
where: { id: input.omniMessageId },
select: { rawJson: true },
});
const raw = asObject(existing?.rawJson);
const rawQueue = asObject(raw.queue);
const rawDeliveryRequest = asObject(raw.deliveryRequest);
// Keep source message in pending before actual send starts.
await prisma.omniMessage.update({
where: { id: input.omniMessageId },
data: {
status: "PENDING",
rawJson: {
...raw,
queue: {
...rawQueue,
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
enqueuedAt: new Date().toISOString(),
},
deliveryRequest: {
...rawDeliveryRequest,
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);
const raw = asObject(msg.rawJson);
const rawQueue = asObject(raw.queue);
const rawDeliveryRequest = asObject(raw.deliveryRequest);
await prisma.omniMessage.update({
where: { id: msg.id },
data: {
status: "SENT",
providerMessageId,
rawJson: {
...raw,
queue: {
...rawQueue,
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
completedAt: new Date().toISOString(),
attemptsMade: job.attemptsMade + 1,
},
deliveryRequest: {
...rawDeliveryRequest,
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) {
const raw = asObject(msg.rawJson);
const rawQueue = asObject(raw.queue);
const rawDeliveryRequest = asObject(raw.deliveryRequest);
await prisma.omniMessage.update({
where: { id: msg.id },
data: {
status: "FAILED",
rawJson: {
...raw,
queue: {
...rawQueue,
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
failedAt: new Date().toISOString(),
attemptsMade: job.attemptsMade + 1,
},
deliveryRequest: {
...rawDeliveryRequest,
endpoint,
method,
channel: job.data.channel ?? null,
provider: job.data.provider ?? null,
startedAt: requestStartedAt,
payload: requestPayload,
},
deliveryError: {
message: compactError(error),
},
},
},
});
}
throw error;
}
},
{ connection: redisConnectionFromEnv() },
);
}

View File

@@ -1,65 +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;
};
function asObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
function readNestedString(obj: Record<string, unknown>, path: string[]): string {
let current: unknown = obj;
for (const segment of path) {
if (!current || typeof current !== "object" || Array.isArray(current)) return "";
current = (current as Record<string, unknown>)[segment];
}
return typeof current === "string" ? current.trim() : "";
}
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 raw = asObject(msg.rawJson);
const text =
readNestedString(raw, ["normalized", "text"]) ||
readNestedString(raw, ["payloadNormalized", "text"]) ||
msg.text;
if (!text) {
throw new Error(`Omni message has empty text payload: ${msg.id}`);
}
const token = requireTelegramBotToken();
const endpoint = `${telegramApiBase()}/bot${token}/sendMessage`;
const payload = {
chat_id: msg.thread.externalChatId,
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();
}

View File

@@ -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");
});

View File

@@ -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;
}

34
hatchet/README.md Normal file
View 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
View 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:

View File

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

View File

@@ -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"
}
}

View File

@@ -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");
});

View File

@@ -1,719 +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 ";
const TELEGRAM_AUDIO_FILE_MARKER = "tg-file:";
const TELEGRAM_WAVE_BINS = 96;
const TELEGRAM_AVATAR_FILE_MARKER = "tg-file:";
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]";
}
type TelegramInboundMedia = {
kind: "voice" | "audio" | "video_note" | null;
fileId: string | null;
durationSec: number | null;
label: string | null;
};
function parseTelegramInboundMedia(normalized: OmniInboundEnvelopeV1["payloadNormalized"]): TelegramInboundMedia {
const kindRaw = String(normalized.mediaKind ?? "").trim().toLowerCase();
const kind: TelegramInboundMedia["kind"] =
kindRaw === "voice" || kindRaw === "audio" || kindRaw === "video_note"
? kindRaw
: null;
const fileId = asString(normalized.mediaFileId);
const durationRaw = normalized.mediaDurationSec;
const durationParsed =
typeof durationRaw === "number"
? durationRaw
: typeof durationRaw === "string"
? Number(durationRaw)
: Number.NaN;
const durationSec =
Number.isFinite(durationParsed) && durationParsed > 0
? Math.max(1, Math.round(durationParsed))
: null;
const label = asString(normalized.mediaTitle);
return { kind, fileId, durationSec, label };
}
function fallbackTextFromMedia(media: TelegramInboundMedia) {
if (!media.kind) return null;
if (media.kind === "voice") return "[voice message]";
if (media.kind === "video_note") return "[video note]";
if (media.label) return `[audio] ${media.label}`;
return "[audio]";
}
function buildFallbackWaveform(seedText: string, bins = TELEGRAM_WAVE_BINS) {
let seed = 0;
for (let i = 0; i < seedText.length; i += 1) {
seed = (seed * 33 + seedText.charCodeAt(i)) >>> 0;
}
const random = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 0xffffffff;
};
const out: number[] = [];
let smooth = 0;
for (let i = 0; i < bins; i += 1) {
const t = i / Math.max(1, bins - 1);
const burst = Math.max(0, Math.sin(t * Math.PI * (2 + (seedText.length % 5))));
const noise = (random() * 2 - 1) * 0.6;
smooth = smooth * 0.72 + noise * 0.28;
const value = Math.max(0.06, Math.min(1, 0.12 + Math.abs(smooth) * 0.42 + burst * 0.4));
out.push(Number(value.toFixed(4)));
}
return out;
}
function buildWaveformFromBytes(bytes: Uint8Array, bins = TELEGRAM_WAVE_BINS) {
if (!bytes.length) return [];
const bucketSize = Math.max(1, Math.ceil(bytes.length / bins));
const raw = new Array<number>(bins).fill(0);
for (let i = 0; i < bins; i += 1) {
const start = i * bucketSize;
const end = Math.min(bytes.length, start + bucketSize);
if (start >= end) continue;
let energy = 0;
for (let j = start; j < end; j += 1) {
energy += Math.abs(bytes[j] - 128) / 128;
}
raw[i] = energy / (end - start);
}
const smooth: number[] = [];
let prev = 0;
for (const value of raw) {
prev = prev * 0.78 + value * 0.22;
smooth.push(prev);
}
const maxValue = Math.max(...smooth, 0);
if (maxValue <= 0) return [];
return smooth.map((value) => {
const normalized = value / maxValue;
const mapped = Math.max(0.06, Math.min(1, normalized * 0.9 + 0.06));
return Number(mapped.toFixed(4));
});
}
async function fetchTelegramFileBytes(fileId: string) {
const token = String(process.env.TELEGRAM_BOT_TOKEN ?? "").trim();
if (!token) return null;
const base = String(process.env.TELEGRAM_API_BASE ?? "https://api.telegram.org").replace(/\/+$/, "");
const metaRes = await fetch(`${base}/bot${token}/getFile`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ file_id: fileId }),
});
const metaJson = (await metaRes.json().catch(() => null)) as
| { ok?: boolean; result?: { file_path?: string } }
| null;
const filePath = String(metaJson?.result?.file_path ?? "").trim();
if (!metaRes.ok || !metaJson?.ok || !filePath) return null;
const fileRes = await fetch(`${base}/file/bot${token}/${filePath}`);
if (!fileRes.ok) return null;
return new Uint8Array(await fileRes.arrayBuffer());
}
type TelegramChatPhoto = {
small_file_id?: string;
big_file_id?: string;
};
type TelegramGetChatResponse = {
ok?: boolean;
result?: {
photo?: TelegramChatPhoto;
};
};
type TelegramProfilePhotoSize = {
file_id?: string;
};
type TelegramGetUserProfilePhotosResponse = {
ok?: boolean;
result?: {
photos?: TelegramProfilePhotoSize[][];
};
};
function asTelegramAvatarUrl(fileId: string | null | undefined) {
const normalized = asString(fileId);
if (!normalized) return null;
return `${TELEGRAM_AVATAR_FILE_MARKER}${normalized}`;
}
async function fetchTelegramAvatarUrl(externalContactId: string) {
const token = String(process.env.TELEGRAM_BOT_TOKEN ?? "").trim();
if (!token) return null;
const base = String(process.env.TELEGRAM_API_BASE ?? "https://api.telegram.org").replace(/\/+$/, "");
const getChatRes = await fetch(`${base}/bot${token}/getChat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ chat_id: externalContactId }),
});
const getChatJson = (await getChatRes.json()) as TelegramGetChatResponse;
if (getChatRes.ok && getChatJson.ok) {
const fromChat = asTelegramAvatarUrl(
getChatJson.result?.photo?.small_file_id ?? getChatJson.result?.photo?.big_file_id,
);
if (fromChat) return fromChat;
}
const getPhotosRes = await fetch(`${base}/bot${token}/getUserProfilePhotos`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ user_id: externalContactId, limit: 1 }),
});
const getPhotosJson = (await getPhotosRes.json()) as TelegramGetUserProfilePhotosResponse;
if (!getPhotosRes.ok || !getPhotosJson.ok) return null;
const firstPhotoSizes = Array.isArray(getPhotosJson.result?.photos?.[0])
? getPhotosJson.result?.photos?.[0]
: [];
const candidate = firstPhotoSizes.at(-1)?.file_id ?? firstPhotoSizes[0]?.file_id;
return asTelegramAvatarUrl(candidate);
}
async function resolveInboundWaveform(media: TelegramInboundMedia, text: string) {
const fallback = buildFallbackWaveform(`${media.fileId ?? "none"}:${media.durationSec ?? "0"}:${text}`);
const fileId = media.fileId;
if (!fileId) return fallback;
try {
const bytes = await fetchTelegramFileBytes(fileId);
if (!bytes?.length) return fallback;
const fromFile = buildWaveformFromBytes(bytes);
return fromFile.length ? fromFile : fallback;
} catch {
return fallback;
}
}
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 {
// Use only normalized contact-* fields (counterparty), avoid sender/chat fallbacks
// to prevent accidental renames to the business owner name on OUT events.
const firstName = asString(normalized.contactFirstName);
const lastName = asString(normalized.contactLastName);
const username = asString(normalized.contactUsername);
const title = asString(normalized.contactTitle);
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, externalContactId: string) {
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);
const resolvedAvatarUrl = profile.avatarUrl ?? (currentAvatar ? null : await fetchTelegramAvatarUrl(externalContactId));
if (resolvedAvatarUrl && resolvedAvatarUrl !== currentAvatar) {
updates.avatarUrl = resolvedAvatarUrl;
}
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, input.externalContactId);
return existingIdentity.contactId;
}
const avatarUrl = input.profile.avatarUrl ?? (await fetchTelegramAvatarUrl(input.externalContactId));
const contact = await prisma.contact.create({
data: {
teamId: input.teamId,
name: input.profile.displayName,
avatarUrl,
},
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, input.externalContactId);
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 = {
contact: { connect: { id: 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: { contact: { connect: { id: input.contactId } } },
});
return concurrentThread;
}
}
async function upsertContactInbox(input: {
teamId: string;
contactId: string;
channel: "TELEGRAM";
sourceExternalId: string;
title: string | null;
}) {
return prisma.contactInbox.upsert({
where: {
teamId_channel_sourceExternalId: {
teamId: input.teamId,
channel: input.channel,
sourceExternalId: input.sourceExternalId,
},
},
create: {
teamId: input.teamId,
contactId: input.contactId,
channel: input.channel,
sourceExternalId: input.sourceExternalId,
title: input.title,
},
update: {
contactId: input.contactId,
...(input.title ? { title: input.title } : {}),
},
select: { id: true },
});
}
async function handleReadBusinessMessage(env: OmniInboundEnvelopeV1) {
const teamId = await resolveTeamId(env);
if (!teamId) return;
const n = env.payloadNormalized ?? ({} as OmniInboundEnvelopeV1["payloadNormalized"]);
const externalChatId = String(n.threadExternalId ?? n.contactExternalId ?? "").trim();
if (!externalChatId) return;
const thread = await prisma.omniThread.findFirst({
where: { teamId, channel: "TELEGRAM", externalChatId },
select: { contactId: true },
});
if (!thread) return;
const teamUsers = await prisma.teamMember.findMany({
where: { teamId },
select: { userId: true },
});
const now = new Date();
// ContactThreadRead is not in omni_chat's Prisma schema, use raw upsert
await Promise.all(
teamUsers.map((u) =>
prisma.$executeRaw`
INSERT INTO "ContactThreadRead" ("id", "teamId", "userId", "contactId", "readAt")
VALUES (gen_random_uuid(), ${teamId}, ${u.userId}, ${thread.contactId}, ${now})
ON CONFLICT ("userId", "contactId") DO UPDATE SET "readAt" = ${now}
`,
),
);
console.log(`[omni_chat] read_business_message: marked contact ${thread.contactId} as read for ${teamUsers.length} users`);
}
async function ingestInbound(env: OmniInboundEnvelopeV1) {
if (env.channel !== "TELEGRAM") return;
if (env.eventType === "read_business_message") {
await handleReadBusinessMessage(env);
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 media = parseTelegramInboundMedia(n);
const text = normalizeText(asString(n.text) ?? fallbackTextFromMedia(media));
const isAudioLike = Boolean(media.fileId) && (media.kind === "voice" || media.kind === "audio" || media.kind === "video_note");
const contactMessageKind: "MESSAGE" | "CALL" = isAudioLike ? "CALL" : "MESSAGE";
const contactMessageAudioUrl = isAudioLike ? `${TELEGRAM_AUDIO_FILE_MARKER}${media.fileId}` : null;
const waveformPeaks = isAudioLike ? await resolveInboundWaveform(media, text) : null;
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),
});
const inbox = await upsertContactInbox({
teamId,
contactId,
channel: "TELEGRAM",
sourceExternalId: externalChatId,
title: asString(n.chatTitle),
});
const rawEnvelope = {
version: env.version,
source: "omni_chat.receiver",
provider: env.provider,
channel: env.channel,
direction,
providerEventId: env.providerEventId,
receivedAt: env.receivedAt,
occurredAt: occurredAt.toISOString(),
normalized: {
text,
threadExternalId: externalChatId,
contactExternalId: externalContactId,
businessConnectionId,
mediaKind: media.kind,
mediaFileId: media.fileId,
mediaDurationSec: media.durationSec,
mediaLabel: media.label,
},
payloadNormalized: n,
payloadRaw: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
} as Prisma.InputJsonValue;
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: rawEnvelope,
occurredAt,
},
update: {
text,
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
rawJson: rawEnvelope,
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: rawEnvelope,
occurredAt,
},
});
}
await prisma.contactMessage.create({
data: {
contactId,
contactInboxId: inbox.id,
kind: contactMessageKind,
direction,
channel: "TELEGRAM",
content: text,
audioUrl: contactMessageAudioUrl,
durationSec: media.durationSec,
...(waveformPeaks ? { waveformJson: waveformPeaks as Prisma.InputJsonValue } : {}),
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(),
});
}

View File

@@ -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`, чтобы провайдер повторил доставку.

View File

@@ -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"
}
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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"]

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,437 +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[]
aiConversations AiConversation[]
aiMessages AiMessage[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
telegramBusinessConnections TelegramBusinessConnection[]
feedCards FeedCard[]
contactPins ContactPin[]
documents WorkspaceDocument[]
contactInboxes ContactInbox[]
contactInboxPreferences ContactInboxPreference[]
}
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[]
aiConversations AiConversation[] @relation("ConversationCreator")
aiMessages AiMessage[] @relation("ChatAuthor")
contactInboxPreferences ContactInboxPreference[]
}
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
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[]
contactInboxes ContactInbox[]
@@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
contactInboxId String?
kind ContactMessageKind @default(MESSAGE)
direction MessageDirection
channel MessageChannel
content String
audioUrl String?
durationSec Int?
waveformJson Json?
transcriptJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
contactInbox ContactInbox? @relation(fields: [contactInboxId], references: [id], onDelete: SetNull)
@@index([contactId, occurredAt])
@@index([contactInboxId, occurredAt])
}
model ContactInbox {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
sourceExternalId 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 ContactMessage[]
preferences ContactInboxPreference[]
@@unique([teamId, channel, sourceExternalId])
@@index([contactId, updatedAt])
@@index([teamId, updatedAt])
}
model ContactInboxPreference {
id String @id @default(cuid())
teamId String
userId String
contactInboxId String
isHidden Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contactInbox ContactInbox @relation(fields: [contactInboxId], references: [id], onDelete: Cascade)
@@unique([userId, contactInboxId])
@@index([teamId, userId, isHidden])
}
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?
paidAmount 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 AiConversation {
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 AiMessage[]
@@index([teamId, updatedAt])
@@index([createdByUserId])
@@map("ChatConversation")
}
model AiMessage {
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 AiConversation @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])
@@map("ChatMessage")
}
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])
}

View File

@@ -1,241 +0,0 @@
import { Queue, Worker, type Job, 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);
}
function asObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
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;
const existing = await prisma.omniMessage.findUnique({
where: { id: input.omniMessageId },
select: { rawJson: true },
});
const raw = asObject(existing?.rawJson);
const rawQueue = asObject(raw.queue);
const rawDeliveryRequest = asObject(raw.deliveryRequest);
await prisma.omniMessage.update({
where: { id: input.omniMessageId },
data: {
status: "PENDING",
rawJson: {
...raw,
queue: {
...rawQueue,
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
enqueuedAt: new Date().toISOString(),
},
deliveryRequest: {
...rawDeliveryRequest,
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: Job<OutboundDeliveryJob, unknown, "deliver">) => {
const msg = await prisma.omniMessage.findUnique({
where: { id: job.data.omniMessageId },
include: { thread: true },
});
if (!msg) return;
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);
const raw = asObject(msg.rawJson);
const rawQueue = asObject(raw.queue);
const rawDeliveryRequest = asObject(raw.deliveryRequest);
await prisma.omniMessage.update({
where: { id: msg.id },
data: {
status: "SENT",
providerMessageId,
rawJson: {
...raw,
queue: {
...rawQueue,
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
completedAt: new Date().toISOString(),
attemptsMade: job.attemptsMade + 1,
},
deliveryRequest: {
...rawDeliveryRequest,
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) {
const raw = asObject(msg.rawJson);
const rawQueue = asObject(raw.queue);
const rawDeliveryRequest = asObject(raw.deliveryRequest);
await prisma.omniMessage.update({
where: { id: msg.id },
data: {
status: "FAILED",
rawJson: {
...raw,
queue: {
...rawQueue,
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
failedAt: new Date().toISOString(),
attemptsMade: job.attemptsMade + 1,
},
deliveryRequest: {
...rawDeliveryRequest,
endpoint,
method,
channel: job.data.channel ?? null,
provider: job.data.provider ?? null,
startedAt: requestStartedAt,
payload: requestPayload,
},
deliveryError: {
message: compactError(error),
},
},
},
});
}
throw error;
}
},
{ connection: redisConnectionFromEnv() },
);
}

View File

@@ -1,16 +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;
}

View File

@@ -1,21 +0,0 @@
import Redis, { type Redis as RedisClient } from "ioredis";
declare global {
// eslint-disable-next-line no-var
var __redis: RedisClient | 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,
});
if (process.env.NODE_ENV !== "production") {
globalThis.__redis = client;
}
return client;
}

View File

@@ -1,34 +0,0 @@
import { OUTBOUND_DELIVERY_QUEUE_NAME, startOutboundDeliveryWorker } from "./queues/outboundDelivery";
import { prisma } from "./utils/prisma";
import { getRedis } from "./utils/redis";
const worker = startOutboundDeliveryWorker();
console.log(`[omni_outbound] started queue ${OUTBOUND_DELIVERY_QUEUE_NAME}`);
async function shutdown(signal: string) {
console.log(`[omni_outbound] 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");
});

View File

@@ -2,10 +2,9 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CANONICAL_SCHEMA="$ROOT_DIR/frontend/prisma/schema.prisma"
CANONICAL_SCHEMA="$ROOT_DIR/Frontend/prisma/schema.prisma"
TARGETS=(
"$ROOT_DIR/omni_chat/prisma/schema.prisma"
"$ROOT_DIR/omni_outbound/prisma/schema.prisma"
"$ROOT_DIR/backend/prisma/schema.prisma"
)
if [[ ! -f "$CANONICAL_SCHEMA" ]]; then
@@ -33,7 +32,7 @@ done
# Enforce one rollout point for schema changes:
# only frontend is allowed to run db push/migration commands.
if rg -n "prisma (db push|migrate|migrate deploy|migrate dev)" \
"$ROOT_DIR/omni_chat" "$ROOT_DIR/omni_outbound" \
"$ROOT_DIR/backend" \
--glob '!**/node_modules/**' \
--glob '!**/package-lock.json' \
--glob '!**/README.md' > /tmp/prisma_non_frontend_migrations.txt; then
@@ -41,7 +40,7 @@ if rg -n "prisma (db push|migrate|migrate deploy|migrate dev)" \
cat /tmp/prisma_non_frontend_migrations.txt >&2
status=1
else
echo "[prisma-check] OK: no migration/db push commands in omni services"
echo "[prisma-check] OK: no migration/db push commands in backend service"
fi
if [[ "$status" -ne 0 ]]; then

View File

@@ -2,10 +2,9 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CANONICAL_SCHEMA="$ROOT_DIR/frontend/prisma/schema.prisma"
CANONICAL_SCHEMA="$ROOT_DIR/Frontend/prisma/schema.prisma"
TARGETS=(
"$ROOT_DIR/omni_chat/prisma/schema.prisma"
"$ROOT_DIR/omni_outbound/prisma/schema.prisma"
"$ROOT_DIR/backend/prisma/schema.prisma"
)
if [[ ! -f "$CANONICAL_SCHEMA" ]]; then

View File

@@ -0,0 +1,58 @@
# telegram_backend
Ingress/API адаптер Telegram Business.
## Задача сервиса
- принимать webhook Telegram и нормализовать payload в envelope;
- ставить задачи в Hatchet (`process-telegram-inbound`, `process-telegram-outbound`);
- предоставлять GraphQL API:
- `enqueueTelegramOutbound` (для `backend`);
- `sendTelegramMessage` (для `telegram_worker`).
Сервис **не** содержит CRM бизнес-логику и не использует Prisma.
## API
### `GET /health`
Проверка живости сервиса.
### `POST /webhooks/telegram/business`
Прием Telegram Business webhook.
При активном `TELEGRAM_WEBHOOK_SECRET` ожидается заголовок:
- `x-telegram-bot-api-secret-token: <TELEGRAM_WEBHOOK_SECRET>`
### `POST /graphql`
Мутации:
- `enqueueTelegramOutbound(input: TelegramOutboundTaskInput!): TaskEnqueueResult!`
- `sendTelegramMessage(input: TelegramSendMessageInput!): TelegramSendResult!`
## Переменные окружения
- `PORT` (default: `8080`)
- `MAX_BODY_SIZE_BYTES` (default: `1048576`)
- `TELEGRAM_WEBHOOK_SECRET` (optional, но обязателен для production)
- `TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `TELEGRAM_BOT_TOKEN` (required для `sendTelegramMessage`)
- `TELEGRAM_API_BASE` (default: `https://api.telegram.org`)
- `HATCHET_CLIENT_TOKEN` (required)
- `HATCHET_CLIENT_TLS_STRATEGY` (optional, например `none` для self-host без TLS)
- `HATCHET_CLIENT_HOST_PORT` (optional, например `hatchet-engine:7070`)
- `HATCHET_CLIENT_API_URL` (optional)
## Запуск
```bash
npm ci
npm run start
```
## Надежность
- `200` на webhook возвращается только после успешного enqueue задачи в Hatchet.
- При ошибке enqueue сервис возвращает `503`, чтобы Telegram повторил доставку.
- Ретраи и оркестрация выполняются в Hatchet worker (`telegram_worker`).

1466
telegram_backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,18 @@
{
"name": "crm-omni-inbound",
"name": "crm-telegram-backend",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"bullmq": "^5.58.2"
},
"devDependencies": {
"@types/node": "^22.13.9",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"dependencies": {
"@hatchet-dev/typescript-sdk": "^1.15.2",
"graphql": "^16.13.1"
}
}

View File

@@ -0,0 +1,3 @@
import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1";
export const hatchet = HatchetClient.init();

View File

@@ -0,0 +1,31 @@
import { hatchet } from "./client";
import type { OmniInboundEnvelopeV1 } from "../types";
export type TelegramOutboundTaskInput = {
omniMessageId: string;
chatId: string;
text: string;
businessConnectionId?: string | null;
};
const processTelegramInbound = hatchet.task({
name: "process-telegram-inbound",
});
const processTelegramOutbound = hatchet.task({
name: "process-telegram-outbound",
});
export async function enqueueTelegramInboundTask(input: OmniInboundEnvelopeV1) {
const run = await processTelegramInbound.runNoWait(input as any);
return {
runId: await run.runId,
};
}
export async function enqueueTelegramOutboundTask(input: TelegramOutboundTaskInput) {
const run = await processTelegramOutbound.runNoWait(input as any);
return {
runId: await run.runId,
};
}

View File

@@ -0,0 +1,31 @@
import { startServer } from "./server";
const server = startServer();
async function shutdown(signal: string) {
console.log(`[telegram_backend] shutting down by ${signal}`);
try {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
} catch {
// ignore shutdown errors
}
process.exit(0);
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});

View File

@@ -0,0 +1,270 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { buildSchema, graphql } from "graphql";
import { parseTelegramBusinessUpdate } from "./telegram";
import { enqueueTelegramInboundTask, enqueueTelegramOutboundTask } from "./hatchet/tasks";
const PORT = Number(process.env.PORT || 8080);
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 1024 * 1024);
const WEBHOOK_SECRET = String(process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
const GRAPHQL_SHARED_SECRET = String(process.env.TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET || "").trim();
const schema = buildSchema(`
type Query {
health: Health!
}
type Health {
ok: Boolean!
service: String!
now: String!
}
input TelegramOutboundTaskInput {
omniMessageId: String!
chatId: String!
text: String!
businessConnectionId: String
}
input TelegramSendMessageInput {
chatId: String!
text: String!
businessConnectionId: String
}
type TaskEnqueueResult {
ok: Boolean!
message: String!
runId: String
}
type TelegramSendResult {
ok: Boolean!
message: String!
providerMessageId: String
responseJson: String
}
type Mutation {
enqueueTelegramOutbound(input: TelegramOutboundTaskInput!): TaskEnqueueResult!
sendTelegramMessage(input: TelegramSendMessageInput!): TelegramSendResult!
}
`);
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
return v || null;
}
function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
res.statusCode = statusCode;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function isWebhookAuthorized(req: IncomingMessage) {
if (!WEBHOOK_SECRET) return true;
const incoming = String(req.headers["x-telegram-bot-api-secret-token"] || "").trim();
return incoming !== "" && incoming === WEBHOOK_SECRET;
}
function isGraphqlAuthorized(req: IncomingMessage) {
if (!GRAPHQL_SHARED_SECRET) return true;
const incoming = String(req.headers["x-graphql-secret"] || "").trim();
return incoming !== "" && incoming === GRAPHQL_SHARED_SECRET;
}
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);
}
async function sendTelegramMessage(input: {
chatId: string;
text: string;
businessConnectionId?: string | null;
}) {
const token = asString(process.env.TELEGRAM_BOT_TOKEN);
if (!token) {
throw new Error("TELEGRAM_BOT_TOKEN is required");
}
const base = String(process.env.TELEGRAM_API_BASE || "https://api.telegram.org").replace(/\/+$/, "");
const endpoint = `${base}/bot${token}/sendMessage`;
const payload: Record<string, unknown> = {
chat_id: input.chatId,
text: input.text,
};
if (input.businessConnectionId) {
payload.business_connection_id = input.businessConnectionId;
}
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const text = await response.text();
const body = (() => {
try {
return JSON.parse(text) as Record<string, any>;
} catch {
return { raw: text };
}
})();
if (!response.ok || body?.ok === false) {
throw new Error(`telegram send failed: ${response.status} ${JSON.stringify(body)}`);
}
const providerMessageId =
body?.result?.message_id != null
? String(body.result.message_id)
: body?.message_id != null
? String(body.message_id)
: null;
return {
ok: true,
message: "sent",
providerMessageId,
responseJson: JSON.stringify(body),
};
}
const root = {
health: () => ({
ok: true,
service: "telegram_backend",
now: new Date().toISOString(),
}),
enqueueTelegramOutbound: async ({ input }: { input: any }) => {
const run = await enqueueTelegramOutboundTask({
omniMessageId: String(input.omniMessageId ?? ""),
chatId: String(input.chatId ?? ""),
text: String(input.text ?? ""),
businessConnectionId: input.businessConnectionId != null ? String(input.businessConnectionId) : null,
});
return {
ok: true,
message: "enqueued",
runId: run.runId,
};
},
sendTelegramMessage: async ({ input }: { input: any }) => {
const result = await sendTelegramMessage({
chatId: String(input.chatId ?? ""),
text: String(input.text ?? ""),
businessConnectionId: input.businessConnectionId != null ? String(input.businessConnectionId) : null,
});
return result;
},
};
export function startServer() {
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: "telegram_backend",
now: new Date().toISOString(),
});
return;
}
if (req.url === "/webhooks/telegram/business" && req.method === "POST") {
if (!isWebhookAuthorized(req)) {
writeJson(res, 401, { ok: false, error: "invalid_webhook_secret" });
return;
}
try {
const body = await readJsonBody(req);
const envelope = parseTelegramBusinessUpdate(body);
const run = await enqueueTelegramInboundTask(envelope);
writeJson(res, 200, {
ok: true,
queued: true,
runId: run.runId,
providerEventId: envelope.providerEventId,
idempotencyKey: envelope.idempotencyKey,
});
} catch (error) {
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;
}
if (req.url === "/graphql" && req.method === "POST") {
if (!isGraphqlAuthorized(req)) {
writeJson(res, 401, { errors: [{ message: "unauthorized" }] });
return;
}
try {
const body = (await readJsonBody(req)) as {
query?: string;
variables?: Record<string, unknown>;
operationName?: string;
};
const result = await graphql({
schema,
source: String(body.query || ""),
rootValue: root,
variableValues: body.variables || {},
operationName: body.operationName,
});
writeJson(res, 200, result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const statusCode = message.startsWith("payload_too_large:") ? 413 : 400;
writeJson(res, statusCode, { errors: [{ message }] });
}
return;
}
writeJson(res, 404, { ok: false, error: "not_found" });
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`[telegram_backend] listening on :${PORT}`);
});
return server;
}

View File

@@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
COPY tsconfig.json ./tsconfig.json
ENV NODE_ENV=production
CMD ["npm", "run", "start"]

30
telegram_worker/README.md Normal file
View File

@@ -0,0 +1,30 @@
# telegram_worker
Hatchet worker для Telegram-цепочки.
## Назначение
- выполняет `process-telegram-inbound`:
- забирает нормализованный inbound envelope;
- пишет событие в `backend` через GraphQL mutation `ingestTelegramInbound`.
- выполняет `process-telegram-outbound`:
- отправляет сообщение через `telegram_backend` mutation `sendTelegramMessage`;
- репортит статус в `backend` mutation `reportTelegramOutbound`.
- ретраи/бекофф выполняются через Hatchet.
## Переменные окружения
- `BACKEND_GRAPHQL_URL` (required)
- `BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `BACKEND_REPORT_RETRIES` (default: `6`)
- `TELEGRAM_BACKEND_GRAPHQL_URL` (required)
- `TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `HATCHET_CLIENT_TOKEN` (required)
- `HATCHET_CLIENT_TLS_STRATEGY` (для self-host без TLS: `none`)
- `HATCHET_CLIENT_HOST_PORT` (например, `hatchet-engine:7070`)
- `HATCHET_CLIENT_API_URL` (URL Hatchet API)
## Скрипты
- `npm run start` — запуск Hatchet worker.
- `npm run typecheck` — проверка TypeScript.

1456
telegram_worker/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "crm-telegram-worker",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/hatchet/worker.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hatchet-dev/typescript-sdk": "^1.15.2"
},
"devDependencies": {
"@types/node": "^22.13.9",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,3 @@
import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1";
export const hatchet = HatchetClient.init();

View File

@@ -0,0 +1,347 @@
import { NonRetryableError } from "@hatchet-dev/typescript-sdk/v1/task";
import { hatchet } from "./client";
type OmniInboundEnvelopeV1 = {
version: number;
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: Record<string, unknown>;
};
type TelegramOutboundTaskInput = {
omniMessageId: string;
chatId: string;
text: string;
businessConnectionId?: string | null;
};
type GraphqlResponse<T> = {
data?: T;
errors?: Array<{ message?: string }>;
};
type GraphqlRequest = {
query: string;
variables?: Record<string, unknown>;
operationName?: string;
};
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
return v || null;
}
function requiredEnv(name: string) {
const value = asString(process.env[name]);
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function safeJsonString(value: unknown) {
try {
return JSON.stringify(value ?? null);
} catch {
return JSON.stringify({ unserializable: true });
}
}
function compactError(error: unknown) {
if (!error) return "unknown_error";
if (typeof error === "string") return error;
const anyErr = error as { message?: unknown; stack?: unknown };
return String(anyErr.message ?? anyErr.stack ?? "unknown_error");
}
async function sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function callGraphql<T>(
url: string,
body: GraphqlRequest,
secret: string | null,
): Promise<T> {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (secret) {
headers["x-graphql-secret"] = secret;
}
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const payload = (await response.json()) as GraphqlResponse<T>;
if (!response.ok || payload.errors?.length) {
const message =
payload.errors?.map((error) => error.message).filter(Boolean).join("; ") || `HTTP ${response.status}`;
throw new Error(message);
}
if (!payload.data) {
throw new Error("graphql_data_missing");
}
return payload.data;
}
async function reportToBackend(input: {
omniMessageId: string;
status: "SENT" | "FAILED";
providerMessageId?: string | null;
error?: string | null;
responseJson?: string | null;
}) {
type ReportResult = {
reportTelegramOutbound: {
ok: boolean;
message: string;
};
};
const url = requiredEnv("BACKEND_GRAPHQL_URL");
const secret = asString(process.env.BACKEND_GRAPHQL_SHARED_SECRET);
const query = `mutation ReportTelegramOutbound($input: TelegramOutboundReportInput!) {
reportTelegramOutbound(input: $input) {
ok
message
}
}`;
const data = await callGraphql<ReportResult>(
url,
{
operationName: "ReportTelegramOutbound",
query,
variables: {
input: {
omniMessageId: input.omniMessageId,
status: input.status,
providerMessageId: input.providerMessageId ?? null,
error: input.error ?? null,
responseJson: input.responseJson ?? null,
},
},
},
secret,
);
if (!data.reportTelegramOutbound.ok) {
throw new Error(data.reportTelegramOutbound.message || "backend_report_failed");
}
}
async function reportToBackendWithRetry(
input: Parameters<typeof reportToBackend>[0],
attempts: number,
) {
let lastError: unknown = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await reportToBackend(input);
return;
} catch (error) {
lastError = error;
if (attempt < attempts) {
const delayMs = Math.min(30000, 250 * 2 ** attempt);
await sleep(delayMs);
}
}
}
throw lastError instanceof Error ? lastError : new Error(compactError(lastError));
}
async function ingestInboundToBackend(input: OmniInboundEnvelopeV1) {
type IngestResult = {
ingestTelegramInbound: {
ok: boolean;
message: string;
omniMessageId?: string | null;
};
};
const url = requiredEnv("BACKEND_GRAPHQL_URL");
const secret = asString(process.env.BACKEND_GRAPHQL_SHARED_SECRET);
const query = `mutation IngestTelegramInbound($input: TelegramInboundInput!) {
ingestTelegramInbound(input: $input) {
ok
message
omniMessageId
}
}`;
const data = await callGraphql<IngestResult>(
url,
{
operationName: "IngestTelegramInbound",
query,
variables: {
input: {
version: input.version,
idempotencyKey: input.idempotencyKey,
provider: input.provider,
channel: input.channel,
direction: input.direction,
providerEventId: input.providerEventId,
providerMessageId: input.providerMessageId,
eventType: input.eventType,
occurredAt: input.occurredAt,
receivedAt: input.receivedAt,
payloadRawJson: safeJsonString(input.payloadRaw),
payloadNormalizedJson: safeJsonString(input.payloadNormalized),
},
},
},
secret,
);
if (!data.ingestTelegramInbound.ok) {
throw new Error(data.ingestTelegramInbound.message || "backend_ingest_failed");
}
}
async function sendViaTelegramBackend(input: TelegramOutboundTaskInput) {
type SendResult = {
sendTelegramMessage: {
ok: boolean;
message: string;
providerMessageId?: string | null;
responseJson?: string | null;
};
};
const url = requiredEnv("TELEGRAM_BACKEND_GRAPHQL_URL");
const secret = asString(process.env.TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET);
const query = `mutation SendTelegramMessage($input: TelegramSendMessageInput!) {
sendTelegramMessage(input: $input) {
ok
message
providerMessageId
responseJson
}
}`;
const data = await callGraphql<SendResult>(
url,
{
operationName: "SendTelegramMessage",
query,
variables: {
input: {
chatId: input.chatId,
text: input.text,
businessConnectionId: input.businessConnectionId ?? null,
},
},
},
secret,
);
if (!data.sendTelegramMessage.ok) {
throw new Error(data.sendTelegramMessage.message || "telegram_send_failed");
}
return {
providerMessageId: data.sendTelegramMessage.providerMessageId ?? null,
responseJson: data.sendTelegramMessage.responseJson ?? null,
};
}
export const processTelegramInbound = hatchet.task({
name: "process-telegram-inbound",
retries: 12,
backoff: { factor: 2, maxSeconds: 60 },
fn: async (input: any, ctx) => {
const envelope = input as OmniInboundEnvelopeV1;
await ingestInboundToBackend(envelope);
await ctx.logger.info("telegram inbound processed", {
providerEventId: envelope.providerEventId,
idempotencyKey: envelope.idempotencyKey,
eventType: envelope.eventType,
});
return { ok: true };
},
});
export const processTelegramOutbound = hatchet.task({
name: "process-telegram-outbound",
retries: 12,
backoff: { factor: 2, maxSeconds: 60 },
fn: async (input: any, ctx) => {
const payload = input as TelegramOutboundTaskInput;
try {
const sent = await sendViaTelegramBackend(payload);
const reportAttempts = Math.max(1, Number(process.env.BACKEND_REPORT_RETRIES || 6));
try {
await reportToBackendWithRetry(
{
omniMessageId: payload.omniMessageId,
status: "SENT",
providerMessageId: sent.providerMessageId,
responseJson: sent.responseJson,
},
reportAttempts,
);
} catch (reportError) {
await ctx.logger.error("telegram sent but backend status report failed", {
error: reportError instanceof Error ? reportError : new Error(compactError(reportError)),
extra: {
omniMessageId: payload.omniMessageId,
providerMessageId: sent.providerMessageId,
},
});
throw new NonRetryableError(
`telegram sent but backend report failed: ${compactError(reportError)}`,
);
}
await ctx.logger.info("telegram outbound processed", {
omniMessageId: payload.omniMessageId,
providerMessageId: sent.providerMessageId,
});
return {
ok: true,
providerMessageId: sent.providerMessageId,
};
} catch (error) {
const errorMessage = compactError(error);
try {
await reportToBackend({
omniMessageId: payload.omniMessageId,
status: "FAILED",
error: errorMessage,
});
} catch {
// Ignore report failure here: the task itself will retry.
}
throw error;
}
},
});

View File

@@ -0,0 +1,22 @@
import { hatchet } from "./client";
import { processTelegramInbound, processTelegramOutbound } from "./tasks";
import path from "node:path";
import { fileURLToPath } from "node:url";
async function main() {
const worker = await hatchet.worker("telegram-worker", {
workflows: [processTelegramInbound, processTelegramOutbound],
});
await worker.start();
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main().catch((error) => {
const message = error instanceof Error ? error.stack || error.message : String(error);
console.error(`[telegram_worker/hatchet] worker failed: ${message}`);
process.exitCode = 1;
});
}