refactor chat delivery to graphql + hatchet services
This commit is contained in:
35
backend/README.md
Normal file
35
backend/README.md
Normal 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`.
|
||||
325
omni_chat/package-lock.json → backend/package-lock.json
generated
325
omni_chat/package-lock.json → backend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
232
backend/src/server.ts
Normal 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
512
backend/src/service.ts
Normal 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 };
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() },
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { OUTBOUND_DELIVERY_QUEUE_NAME, startOutboundDeliveryWorker } from "./outboundDelivery";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { getRedis } from "../utils/redis";
|
||||
|
||||
const worker = startOutboundDeliveryWorker();
|
||||
console.log(`[omni_outbound(legacy-in-frontend)] started queue ${OUTBOUND_DELIVERY_QUEUE_NAME}`);
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[omni_outbound(legacy-in-frontend)] shutting down by ${signal}`);
|
||||
try {
|
||||
await worker.close();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
try {
|
||||
const redis = getRedis();
|
||||
await redis.quit();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
@@ -1,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
34
hatchet/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Hatchet (Dokploy)
|
||||
|
||||
Compose-стек для self-hosted Hatchet, перенесенный из соседнего проекта `gl` и адаптированный под ENV.
|
||||
|
||||
## Файлы
|
||||
|
||||
- `docker-compose.yml` — сервисы Hatchet (Postgres, RabbitMQ, migration, setup-config, engine, dashboard).
|
||||
|
||||
## Обязательные ENV (Dokploy UI)
|
||||
|
||||
- `HATCHET_POSTGRES_USER` (default: `hatchet`)
|
||||
- `HATCHET_POSTGRES_PASSWORD` (default: `hatchet`)
|
||||
- `HATCHET_POSTGRES_DB` (default: `hatchet`)
|
||||
- `HATCHET_DATABASE_URL` (default: `postgres://hatchet:hatchet@postgres:5432/hatchet`)
|
||||
- `HATCHET_RABBITMQ_USER` (default: `user`)
|
||||
- `HATCHET_RABBITMQ_PASSWORD` (default: `password`)
|
||||
- `HATCHET_RABBITMQ_URL` (default: `amqp://user:password@rabbitmq:5672/`)
|
||||
- `HATCHET_SERVER_AUTH_COOKIE_DOMAIN` (например, `hatchet.<ваш-домен>`)
|
||||
- `HATCHET_SERVER_AUTH_COOKIE_INSECURE` (`t`/`f`)
|
||||
- `HATCHET_SERVER_GRPC_INSECURE` (`t`/`f`)
|
||||
- `HATCHET_SERVER_GRPC_BROADCAST_ADDRESS` (например, `hatchet-engine:7070` внутри сети)
|
||||
|
||||
## ENV для приложений-воркеров (Node SDK)
|
||||
|
||||
- `HATCHET_CLIENT_TOKEN` — токен клиента из Hatchet.
|
||||
- `HATCHET_CLIENT_TLS_STRATEGY` — для self-host без TLS: `none`.
|
||||
- `HATCHET_CLIENT_HOST_PORT` — gRPC адрес (например, `hatchet-engine:7070` в одной Docker-сети).
|
||||
- `HATCHET_CLIENT_API_URL` — URL API Hatchet dashboard/api.
|
||||
|
||||
## Развертывание
|
||||
|
||||
Сервис описан в `deploy-map.toml` как:
|
||||
|
||||
`hatchet = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "hatchet/docker-compose.yml" }`
|
||||
121
hatchet/docker-compose.yml
Normal file
121
hatchet/docker-compose.yml
Normal file
@@ -0,0 +1,121 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15.6
|
||||
command: postgres -c "max_connections=1000"
|
||||
restart: always
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${HATCHET_POSTGRES_USER:-hatchet}
|
||||
POSTGRES_PASSWORD: ${HATCHET_POSTGRES_PASSWORD:-hatchet}
|
||||
POSTGRES_DB: ${HATCHET_POSTGRES_DB:-hatchet}
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- hatchet_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d ${HATCHET_POSTGRES_DB:-hatchet} -U ${HATCHET_POSTGRES_USER:-hatchet}"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3-management
|
||||
hostname: rabbitmq
|
||||
expose:
|
||||
- "5672"
|
||||
- "15672"
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: ${HATCHET_RABBITMQ_USER:-user}
|
||||
RABBITMQ_DEFAULT_PASS: ${HATCHET_RABBITMQ_PASSWORD:-password}
|
||||
volumes:
|
||||
- hatchet_rabbitmq_data:/var/lib/rabbitmq
|
||||
- hatchet_rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmqctl", "status"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
migration:
|
||||
image: ghcr.io/hatchet-dev/hatchet/hatchet-migrate:latest
|
||||
command: /hatchet/hatchet-migrate
|
||||
environment:
|
||||
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
setup-config:
|
||||
image: ghcr.io/hatchet-dev/hatchet/hatchet-admin:latest
|
||||
command: /hatchet/hatchet-admin quickstart --skip certs --generated-config-dir /hatchet/config --overwrite=false
|
||||
environment:
|
||||
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||
SERVER_MSGQUEUE_RABBITMQ_URL: ${HATCHET_RABBITMQ_URL:-amqp://user:password@rabbitmq:5672/}
|
||||
SERVER_AUTH_COOKIE_DOMAIN: ${HATCHET_SERVER_AUTH_COOKIE_DOMAIN:-hatchet.local}
|
||||
SERVER_AUTH_COOKIE_INSECURE: ${HATCHET_SERVER_AUTH_COOKIE_INSECURE:-t}
|
||||
SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
|
||||
SERVER_GRPC_INSECURE: ${HATCHET_SERVER_GRPC_INSECURE:-t}
|
||||
SERVER_GRPC_BROADCAST_ADDRESS: ${HATCHET_SERVER_GRPC_BROADCAST_ADDRESS:-hatchet-engine:7070}
|
||||
SERVER_DEFAULT_ENGINE_VERSION: V1
|
||||
SERVER_INTERNAL_CLIENT_INTERNAL_GRPC_BROADCAST_ADDRESS: hatchet-engine:7070
|
||||
volumes:
|
||||
- hatchet_certs:/hatchet/certs
|
||||
- hatchet_config:/hatchet/config
|
||||
depends_on:
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
hatchet-engine:
|
||||
image: ghcr.io/hatchet-dev/hatchet/hatchet-engine:latest
|
||||
command: /hatchet/hatchet-engine --config /hatchet/config
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
setup-config:
|
||||
condition: service_completed_successfully
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
expose:
|
||||
- "7070"
|
||||
environment:
|
||||
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||
SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
|
||||
SERVER_GRPC_INSECURE: ${HATCHET_SERVER_GRPC_INSECURE:-t}
|
||||
volumes:
|
||||
- hatchet_certs:/hatchet/certs
|
||||
- hatchet_config:/hatchet/config
|
||||
networks:
|
||||
- default
|
||||
- dokploy-network
|
||||
|
||||
hatchet-dashboard:
|
||||
image: ghcr.io/hatchet-dev/hatchet/hatchet-dashboard:latest
|
||||
command: sh ./entrypoint.sh --config /hatchet/config
|
||||
expose:
|
||||
- "80"
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
setup-config:
|
||||
condition: service_completed_successfully
|
||||
migration:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||
volumes:
|
||||
- hatchet_certs:/hatchet/certs
|
||||
- hatchet_config:/hatchet/config
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
hatchet_postgres_data:
|
||||
hatchet_rabbitmq_data:
|
||||
hatchet_rabbitmq.conf:
|
||||
hatchet_config:
|
||||
hatchet_certs:
|
||||
@@ -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`.
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "crm-omni-chat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"postinstall": "node ./node_modules/prisma/build/index.js generate --schema ./prisma/schema.prisma"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.9",
|
||||
"prisma": "^6.16.1",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.1",
|
||||
"bullmq": "^5.70.0"
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { closeReceiverWorker, RECEIVER_FLOW_QUEUE_NAME, receiverQueue, startReceiverWorker } from "./worker";
|
||||
|
||||
const port = Number(process.env.PORT || 8090);
|
||||
const service = "omni_chat";
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
const q = receiverQueue();
|
||||
const counts = await q.getJobCounts("wait", "active", "failed", "completed", "delayed");
|
||||
await q.close();
|
||||
const payload = JSON.stringify({
|
||||
ok: true,
|
||||
service,
|
||||
receiverFlow: RECEIVER_FLOW_QUEUE_NAME,
|
||||
senderFlow: process.env.SENDER_FLOW_QUEUE_NAME || "sender.flow",
|
||||
queue: counts,
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: false, error: "not_found" }));
|
||||
});
|
||||
|
||||
startReceiverWorker();
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`[omni_chat] listening on :${port}`);
|
||||
console.log(`[omni_chat] receiver worker started for queue ${RECEIVER_FLOW_QUEUE_NAME}`);
|
||||
});
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[omni_chat] shutting down by ${signal}`);
|
||||
try {
|
||||
await closeReceiverWorker();
|
||||
} finally {
|
||||
server.close(() => process.exit(0));
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
@@ -1,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(),
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
# omni_inbound
|
||||
|
||||
Отдельный сервис приема входящих webhook-событий каналов (первый канал: Telegram Business).
|
||||
|
||||
## Задача сервиса
|
||||
|
||||
- принимать webhook;
|
||||
- валидировать секрет;
|
||||
- нормализовать событие в универсальный envelope;
|
||||
- делать durable enqueue в BullMQ (`receiver.flow`);
|
||||
- возвращать `200` только после успешного enqueue.
|
||||
|
||||
Сервис **не** содержит бизнес-логику CRM и не вызывает provider API для исходящих сообщений.
|
||||
|
||||
## API
|
||||
|
||||
### `GET /health`
|
||||
Проверка живости сервиса.
|
||||
|
||||
### `POST /webhooks/telegram/business`
|
||||
Прием Telegram Business webhook.
|
||||
|
||||
При активном `TELEGRAM_WEBHOOK_SECRET` ожидается заголовок:
|
||||
|
||||
- `x-telegram-bot-api-secret-token: <TELEGRAM_WEBHOOK_SECRET>`
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
- `PORT` (default: `8080`)
|
||||
- `REDIS_URL` (default: `redis://localhost:6379`)
|
||||
- `RECEIVER_FLOW_QUEUE_NAME` (default: `receiver.flow`)
|
||||
- `INBOUND_QUEUE_NAME` (legacy alias, optional)
|
||||
- `TELEGRAM_WEBHOOK_SECRET` (optional, но обязателен для production)
|
||||
- `TELEGRAM_CONNECT_WEBHOOK_FORWARD_URL` (optional; URL CRM endpoint для линковки Telegram Business)
|
||||
- `MAX_BODY_SIZE_BYTES` (default: `1048576`)
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Надежность
|
||||
|
||||
- Идемпотентность: `jobId` строится из `idempotencyKey` (SHA-256).
|
||||
- Дубликаты webhook безопасны и не приводят к повторной постановке события.
|
||||
- При ошибке enqueue сервис возвращает `503`, чтобы провайдер повторил доставку.
|
||||
908
omni_inbound/package-lock.json
generated
908
omni_inbound/package-lock.json
generated
@@ -1,908 +0,0 @@
|
||||
{
|
||||
"name": "crm-omni-inbound",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "crm-omni-inbound",
|
||||
"dependencies": {
|
||||
"bullmq": "^5.58.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.9",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bullmq": {
|
||||
"version": "5.69.4",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.69.4.tgz",
|
||||
"integrity": "sha512-Lp7ymp875I/rtjMm6oxzQ3PrvDDHkgge0oaAznmZsKtGyglfdrg9zbidPSszTXgWFkS2rCgMcTRNJfM3uUMOjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "4.9.0",
|
||||
"ioredis": "5.9.2",
|
||||
"msgpackr": "1.11.5",
|
||||
"node-abort-controller": "3.1.1",
|
||||
"semver": "7.7.4",
|
||||
"tslib": "2.8.1",
|
||||
"uuid": "11.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
|
||||
"integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.5.0",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.5",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
|
||||
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abort-controller": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Queue, type ConnectionOptions } from "bullmq";
|
||||
import type { OmniInboundEnvelopeV1 } from "./types";
|
||||
|
||||
export const RECEIVER_FLOW_QUEUE_NAME = (
|
||||
process.env.RECEIVER_FLOW_QUEUE_NAME ||
|
||||
process.env.INBOUND_QUEUE_NAME ||
|
||||
"receiver.flow"
|
||||
).trim();
|
||||
|
||||
let queueInstance: Queue<OmniInboundEnvelopeV1, unknown, "ingest"> | null = null;
|
||||
|
||||
function redisConnectionFromEnv(): ConnectionOptions {
|
||||
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
|
||||
const parsed = new URL(raw);
|
||||
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : 6379,
|
||||
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
||||
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
||||
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toJobId(idempotencyKey: string) {
|
||||
const hash = createHash("sha256").update(idempotencyKey).digest("hex");
|
||||
return `inbound-${hash}`;
|
||||
}
|
||||
|
||||
export function inboundQueue() {
|
||||
if (queueInstance) return queueInstance;
|
||||
|
||||
queueInstance = new Queue<OmniInboundEnvelopeV1, unknown, "ingest">(RECEIVER_FLOW_QUEUE_NAME, {
|
||||
connection: redisConnectionFromEnv(),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: { count: 10000 },
|
||||
removeOnFail: { count: 20000 },
|
||||
attempts: 8,
|
||||
backoff: { type: "exponential", delay: 1000 },
|
||||
},
|
||||
});
|
||||
|
||||
return queueInstance;
|
||||
}
|
||||
|
||||
export async function enqueueInboundEvent(envelope: OmniInboundEnvelopeV1) {
|
||||
const q = inboundQueue();
|
||||
const jobId = toJobId(envelope.idempotencyKey);
|
||||
|
||||
return q.add("ingest", envelope, {
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
|
||||
export function isDuplicateJobError(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const message = String((error as { message?: string }).message || "").toLowerCase();
|
||||
return message.includes("job") && message.includes("exists");
|
||||
}
|
||||
|
||||
export async function closeInboundQueue() {
|
||||
if (!queueInstance) return;
|
||||
await queueInstance.close();
|
||||
queueInstance = null;
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { RECEIVER_FLOW_QUEUE_NAME, enqueueInboundEvent, isDuplicateJobError } from "./queue";
|
||||
import { parseTelegramBusinessUpdate } from "./telegram";
|
||||
|
||||
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 1024 * 1024);
|
||||
|
||||
function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
|
||||
const payload = JSON.stringify(body);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buf.length;
|
||||
if (total > MAX_BODY_SIZE_BYTES) {
|
||||
throw new Error(`payload_too_large:${MAX_BODY_SIZE_BYTES}`);
|
||||
}
|
||||
chunks.push(buf);
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function validateTelegramSecret(req: IncomingMessage): boolean {
|
||||
const expected = (process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
|
||||
if (!expected) return true;
|
||||
|
||||
const incoming = String(req.headers["x-telegram-bot-api-secret-token"] || "").trim();
|
||||
return incoming !== "" && incoming === expected;
|
||||
}
|
||||
|
||||
async function forwardTelegramConnectWebhook(rawBody: unknown) {
|
||||
const url = (process.env.TELEGRAM_CONNECT_WEBHOOK_FORWARD_URL || "").trim();
|
||||
if (!url) return;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
const secret = (process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
|
||||
if (secret) {
|
||||
headers["x-telegram-bot-api-secret-token"] = secret;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(rawBody ?? {}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.warn(`[omni_inbound] telegram connect forward failed: ${res.status} ${text.slice(0, 300)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[omni_inbound] telegram connect forward error: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startServer() {
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (!req.url || !req.method) {
|
||||
writeJson(res, 404, { ok: false, error: "not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/health" && req.method === "GET") {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: "omni_inbound",
|
||||
queue: RECEIVER_FLOW_QUEUE_NAME,
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/webhooks/telegram/business" && req.method === "POST") {
|
||||
if (!validateTelegramSecret(req)) {
|
||||
writeJson(res, 401, { ok: false, error: "invalid_webhook_secret" });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
let envelope: ReturnType<typeof parseTelegramBusinessUpdate> | null = null;
|
||||
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
envelope = parseTelegramBusinessUpdate(body);
|
||||
|
||||
await enqueueInboundEvent(envelope);
|
||||
|
||||
void forwardTelegramConnectWebhook(body);
|
||||
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
queued: true,
|
||||
duplicate: false,
|
||||
providerEventId: envelope.providerEventId,
|
||||
idempotencyKey: envelope.idempotencyKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDuplicateJobError(error)) {
|
||||
void forwardTelegramConnectWebhook(body);
|
||||
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
queued: false,
|
||||
duplicate: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const statusCode = message.startsWith("payload_too_large:") ? 413 : 503;
|
||||
writeJson(res, statusCode, {
|
||||
ok: false,
|
||||
error: "receiver_enqueue_failed",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, { ok: false, error: "not_found" });
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`[omni_inbound] listening on :${port}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app/delivery
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY src ./src
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -1,22 +0,0 @@
|
||||
# omni_outbound
|
||||
|
||||
Изолированный сервис исходящей доставки.
|
||||
|
||||
## Назначение
|
||||
|
||||
- потребляет задачи из `sender.flow`;
|
||||
- выполняет отправку в провайдеров;
|
||||
- применяет retry/backoff и финальный fail-status.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
- `REDIS_URL`
|
||||
- `DATABASE_URL`
|
||||
- `SENDER_FLOW_QUEUE_NAME` (default: `sender.flow`)
|
||||
- `OUTBOUND_DELIVERY_QUEUE_NAME` (legacy alias, optional)
|
||||
|
||||
## Prisma policy
|
||||
|
||||
- Источник схемы: `frontend/prisma/schema.prisma`.
|
||||
- Локальная копия в `omni_outbound/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
|
||||
- Миграции/`db push` выполняются только в `frontend`.
|
||||
1358
omni_outbound/package-lock.json
generated
1358
omni_outbound/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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])
|
||||
}
|
||||
@@ -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() },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
58
telegram_backend/README.md
Normal file
58
telegram_backend/README.md
Normal 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
1466
telegram_backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
3
telegram_backend/src/hatchet/client.ts
Normal file
3
telegram_backend/src/hatchet/client.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1";
|
||||
|
||||
export const hatchet = HatchetClient.init();
|
||||
31
telegram_backend/src/hatchet/tasks.ts
Normal file
31
telegram_backend/src/hatchet/tasks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
telegram_backend/src/index.ts
Normal file
31
telegram_backend/src/index.ts
Normal 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");
|
||||
});
|
||||
270
telegram_backend/src/server.ts
Normal file
270
telegram_backend/src/server.ts
Normal 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;
|
||||
}
|
||||
13
telegram_worker/Dockerfile
Normal file
13
telegram_worker/Dockerfile
Normal 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
30
telegram_worker/README.md
Normal 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
1456
telegram_worker/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
telegram_worker/package.json
Normal file
17
telegram_worker/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
telegram_worker/src/hatchet/client.ts
Normal file
3
telegram_worker/src/hatchet/client.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1";
|
||||
|
||||
export const hatchet = HatchetClient.init();
|
||||
347
telegram_worker/src/hatchet/tasks.ts
Normal file
347
telegram_worker/src/hatchet/tasks.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
22
telegram_worker/src/hatchet/worker.ts
Normal file
22
telegram_worker/src/hatchet/worker.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user