diff --git a/.vscode/launch.json b/.vscode/launch.json index a0d4b2742b..2e594596b0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -389,6 +389,29 @@ "showAsyncStacks": true, "outputCapture": "std", "cwd": "${workspaceRoot}/services/analytics-collector/pod-analytics-collector" + }, + { + "name": "Debug AI bot", + "type": "node", + "request": "launch", + "args": ["src/index.ts"], + "env": { + "ACCOUNTS_URL": "http://localhost:3000", + "MONGO_URL": "mongodb://localhost:27017", + "PORT": "4008", + "SERVER_SECRET": "secret", + "SUPPORT_WORKSPACE": "support", + "FIRST_NAME": "Jolie", + "LAST_NAME": "AI", + "PASSWORD": "password", + "AVATAR_PATH": "./assets/avatar.png", + "AVATAR_CONTENT_TYPE": ".png" + }, + "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], + "sourceMaps": true, + "cwd": "${workspaceRoot}/services/ai-bot/pod-ai-bot", + "protocol": "inspector", + "outputCapture": "std" } ] } diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 0b212fc33b..22f3cf22f5 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -71,6 +71,9 @@ dependencies: '@rush-temp/activity-resources': specifier: file:./projects/activity-resources.tgz version: file:projects/activity-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2) + '@rush-temp/ai-bot': + specifier: file:./projects/ai-bot.tgz + version: file:projects/ai-bot.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) '@rush-temp/analytics': specifier: file:./projects/analytics.tgz version: file:projects/analytics.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) @@ -407,6 +410,9 @@ dependencies: '@rush-temp/model-server-activity': specifier: file:./projects/model-server-activity.tgz version: file:projects/model-server-activity.tgz + '@rush-temp/model-server-ai-bot': + specifier: file:./projects/model-server-ai-bot.tgz + version: file:projects/model-server-ai-bot.tgz '@rush-temp/model-server-attachment': specifier: file:./projects/model-server-attachment.tgz version: file:projects/model-server-attachment.tgz @@ -572,6 +578,9 @@ dependencies: '@rush-temp/pod-account': specifier: file:./projects/pod-account.tgz version: file:projects/pod-account.tgz + '@rush-temp/pod-ai-bot': + specifier: file:./projects/pod-ai-bot.tgz + version: file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4) '@rush-temp/pod-analytics-collector': specifier: file:./projects/pod-analytics-collector.tgz version: file:projects/pod-analytics-collector.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4) @@ -701,6 +710,12 @@ dependencies: '@rush-temp/server-activity-resources': specifier: file:./projects/server-activity-resources.tgz version: file:projects/server-activity-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) + '@rush-temp/server-ai-bot': + specifier: file:./projects/server-ai-bot.tgz + version: file:projects/server-ai-bot.tgz(esbuild@0.20.1)(ts-node@10.9.2) + '@rush-temp/server-ai-bot-resources': + specifier: file:./projects/server-ai-bot-resources.tgz + version: file:projects/server-ai-bot-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) '@rush-temp/server-analytics-collector': specifier: file:./projects/server-analytics-collector.tgz version: file:projects/server-analytics-collector.tgz(esbuild@0.20.1)(ts-node@10.9.2) @@ -23971,6 +23986,37 @@ packages: - ts-node dev: false + file:projects/ai-bot.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): + resolution: {integrity: sha512-TH/kDh1LKw+pJRxLZZIjR7da6BQslR9S9Uy9HHXDtvtu5es55OmjRZEWPUnkBjKRmY6hTPBmaO4veq7RiaNz7g==, tarball: file:projects/ai-bot.tgz} + id: file:projects/ai-bot.tgz + name: '@rush-temp/ai-bot' + version: 0.0.0 + dependencies: + '@types/jest': 29.5.12 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + eslint: 8.56.0 + eslint-config-prettier: 8.10.0(eslint@8.56.0) + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + prettier: 3.2.5 + ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - '@types/node' + - babel-jest + - babel-plugin-macros + - esbuild + - node-notifier + - supports-color + - ts-node + dev: false + file:projects/analytics-collector-assets.tgz(esbuild@0.20.1)(svelte@4.2.12)(ts-node@10.9.2): resolution: {integrity: sha512-ThEhCV2dJchrcM0mfUjNFdD0/qOzl3qC7glianWUl4Qsj03IrYyjbxvdsV7Fy/49UhQ+YUt46X39kgIWVBnWiw==, tarball: file:projects/analytics-collector-assets.tgz} id: file:projects/analytics-collector-assets.tgz @@ -26613,7 +26659,7 @@ packages: dev: false file:projects/lead-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2): - resolution: {integrity: sha512-YFe9zCBoR5pPRxsoG3Oc9qVVHEWGDlmQrw27HY+3P0SMBXz8xWVjgM6SrL2458y83t/Xsj30krK1zEZkWORfpQ==, tarball: file:projects/lead-resources.tgz} + resolution: {integrity: sha512-DIL0rCz2r3nKYgE2fOrEdVSwLGq1Zz2MjsU1HvDxnIs4IE1Q4s8oNMBtvtunERIQ2fNnJHcE0XXijawtYilrjQ==, tarball: file:projects/lead-resources.tgz} id: file:projects/lead-resources.tgz name: '@rush-temp/lead-resources' version: 0.0.0 @@ -27562,6 +27608,24 @@ packages: - supports-color dev: false + file:projects/model-server-ai-bot.tgz: + resolution: {integrity: sha512-eo56EIdT6JLS0upi95IDRPBEXyASWLEpUdH9cfK2lLtpslJYlJkRPSqImEA5xk1fUUwexRPqVPW+bYFoIMsUxQ==, tarball: file:projects/model-server-ai-bot.tgz} + name: '@rush-temp/model-server-ai-bot' + version: 0.0.0 + dependencies: + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + prettier: 3.2.5 + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + dev: false + file:projects/model-server-attachment.tgz: resolution: {integrity: sha512-F45oxBvR4Gh7Y70CqozTOyXQ/lWOkP/EDURRVxxETMWbtXej37/QCtWXlLJ2YNWmoG6ildVMOZlK/IaMSdU9mA==, tarball: file:projects/model-server-attachment.tgz} name: '@rush-temp/model-server-attachment' @@ -28861,6 +28925,62 @@ packages: - supports-color dev: false + file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4): + resolution: {integrity: sha512-5Upjw52FvxxuZY9eSN8N5FUKoNMhqME7DwEAp4TTQEKR/Rs+Fnxi7S5ZQ6G2G40RmZ0F0mWfXfWTcyhFlyio5Q==, tarball: file:projects/pod-ai-bot.tgz} + id: file:projects/pod-ai-bot.tgz + name: '@rush-temp/pod-ai-bot' + version: 0.0.0 + dependencies: + '@tsconfig/node16': 1.0.4 + '@types/cors': 2.8.17 + '@types/express': 4.17.21 + '@types/jest': 29.5.12 + '@types/node': 20.11.19 + '@types/node-fetch': 2.6.11 + '@types/ws': 8.5.11 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + cors: 2.8.5 + dotenv: 16.0.3 + esbuild: 0.20.1 + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-node: 11.1.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + express: 4.19.2 + fast-equals: 5.0.1 + form-data: 4.0.0 + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + mongodb: 6.8.0 + node-fetch: 2.7.0 + prettier: 3.2.5 + ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) + ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.3.3) + typescript: 5.3.3 + ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@babel/core' + - '@jest/types' + - '@mongodb-js/zstd' + - '@swc/core' + - '@swc/wasm' + - babel-jest + - babel-plugin-macros + - bufferutil + - encoding + - gcp-metadata + - kerberos + - mongodb-client-encryption + - node-notifier + - snappy + - socks + - supports-color + - utf-8-validate + dev: false + file:projects/pod-analytics-collector.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4): resolution: {integrity: sha512-Sx9mTOCsgDTcoC8h9VXeemVHerKLMDzdW4aJ08fdsF9UA35tLWmbDXw+fn3gPUNj2l+QT5ExEF9rV1Iez1FIbw==, tarball: file:projects/pod-analytics-collector.tgz} id: file:projects/pod-analytics-collector.tgz @@ -30590,6 +30710,74 @@ packages: - ts-node dev: false + file:projects/server-ai-bot-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): + resolution: {integrity: sha512-YOa1XOLVz87AjouR9Qn4Wkiib/9nXkIiMIW7XVzCl+lVqfdjXDRn4kjxXj363Wh8bw5Ato7kpuj0M9C1Vsh+zw==, tarball: file:projects/server-ai-bot-resources.tgz} + id: file:projects/server-ai-bot-resources.tgz + name: '@rush-temp/server-ai-bot-resources' + version: 0.0.0 + dependencies: + '@types/jest': 29.5.12 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + mongodb: 6.8.0 + prettier: 3.2.5 + ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@babel/core' + - '@jest/types' + - '@mongodb-js/zstd' + - '@types/node' + - babel-jest + - babel-plugin-macros + - esbuild + - gcp-metadata + - kerberos + - mongodb-client-encryption + - node-notifier + - snappy + - socks + - supports-color + - ts-node + dev: false + + file:projects/server-ai-bot.tgz(esbuild@0.20.1)(ts-node@10.9.2): + resolution: {integrity: sha512-9rB0TFsDFJ2KgmJwTCJRQG/rh+NORXysidGZq8v77VEN7Sh/xQlB33k4XgHutF3U1Bd0KUdRC/pg9grlgezIdA==, tarball: file:projects/server-ai-bot.tgz} + id: file:projects/server-ai-bot.tgz + name: '@rush-temp/server-ai-bot' + version: 0.0.0 + dependencies: + '@types/jest': 29.5.12 + '@types/node': 20.11.19 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + prettier: 3.2.5 + ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - babel-jest + - babel-plugin-macros + - esbuild + - node-notifier + - supports-color + - ts-node + dev: false + file:projects/server-analytics-collector-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): resolution: {integrity: sha512-cLqoyrE7bZmjUeQSenTvg0S2XdEUUulUTMYvYephOUR5orzG71xy3Z+HgV92YapcZMzi9RVdaataHwYhxTRDPw==, tarball: file:projects/server-analytics-collector-resources.tgz} id: file:projects/server-analytics-collector-resources.tgz diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 23377590e8..a7c7dd8abc 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -213,7 +213,24 @@ services: deploy: resources: limits: - memory: 300M + memory: 300M + aiBot: + image: hardcoreeng/ai-bot + restart: unless-stopped + environment: + - SERVER_SECRET=secret + - MONGO_URL=mongodb://mongodb:27017 + - ACCOUNTS_URL=http://account:3000 + - SUPPORT_WORKSPACE=support + - FIRST_NAME=Jolie + - LAST_NAME=AI + - PASSWORD=password + - AVATAR_PATH=./avatar.png + - AVATAR_CONTENT_TYPE=.png + deploy: + resources: + limits: + memory: 300M volumes: db: files: diff --git a/dev/tool/package.json b/dev/tool/package.json index e30aecb505..4b701d3138 100644 --- a/dev/tool/package.json +++ b/dev/tool/package.json @@ -133,6 +133,8 @@ "@hcengineering/server-guest-resources": "^0.6.0", "@hcengineering/server-time": "^0.6.0", "@hcengineering/server-time-resources": "^0.6.0", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-ai-bot-resources": "^0.6.0", "@hcengineering/setting": "^0.6.17", "@hcengineering/tags": "^0.6.16", "@hcengineering/task": "^0.6.20", diff --git a/dev/tool/src/__start.ts b/dev/tool/src/__start.ts index c1c0d9ff7b..5ba341d8ed 100644 --- a/dev/tool/src/__start.ts +++ b/dev/tool/src/__start.ts @@ -44,6 +44,7 @@ import { serverTelegramId } from '@hcengineering/server-telegram' import { serverTimeId } from '@hcengineering/server-time' import { serverTrackerId } from '@hcengineering/server-tracker' import { serverViewId } from '@hcengineering/server-view' +import { serverAiBotId } from '@hcengineering/server-ai-bot' addLocation(serverActivityId, () => import('@hcengineering/server-activity-resources')) addLocation(serverAttachmentId, () => import('@hcengineering/server-attachment-resources')) @@ -68,6 +69,7 @@ addLocation(serverDocumentId, () => import('@hcengineering/server-document-resou addLocation(serverTimeId, () => import('@hcengineering/server-time-resources')) addLocation(serverGuestId, () => import('@hcengineering/server-guest-resources')) addLocation(serverDriveId, () => import('@hcengineering/server-drive-resources')) +addLocation(serverAiBotId, () => import('@hcengineering/server-ai-bot-resources')) function prepareTools (): { mongodbUri: string diff --git a/models/all/package.json b/models/all/package.json index 43d48c6c54..42350fa276 100644 --- a/models/all/package.json +++ b/models/all/package.json @@ -105,6 +105,7 @@ "@hcengineering/model-server-products": "^0.1.0", "@hcengineering/model-server-training": "^0.1.0", "@hcengineering/model-server-controlled-documents": "^0.1.0", - "@hcengineering/model-analytics-collector": "^0.6.0" + "@hcengineering/model-analytics-collector": "^0.6.0", + "@hcengineering/model-server-ai-bot": "^0.6.0" } } diff --git a/models/all/src/index.ts b/models/all/src/index.ts index 13decd8afc..37322b9d51 100644 --- a/models/all/src/index.ts +++ b/models/all/src/index.ts @@ -60,6 +60,7 @@ import { serverTelegramId, createModel as serverTelegramModel } from '@hcenginee import { serverTemplatesId, createModel as serverTemplatesModel } from '@hcengineering/model-server-templates' import { serverTrackerId, createModel as serverTrackerModel } from '@hcengineering/model-server-tracker' import { serverViewId, createModel as serverViewModel } from '@hcengineering/model-server-view' +import { serverAiBotId, createModel as serverAiBotModel } from '@hcengineering/model-server-ai-bot' import setting, { settingId, createModel as settingModel } from '@hcengineering/model-setting' import { driveId, createModel as driveModel } from '@hcengineering/model-drive' import { supportId, createModel as supportModel } from '@hcengineering/model-support' @@ -424,7 +425,8 @@ export default function buildModel (enabled: string[] = ['*'], disabled: string[ [serverDriveModel, serverDriveId], [serverProductsModel, serverProductsId], [serverTrainingModel, serverTrainingId], - [serverDocumentsModel, serverDocumentsId] + [serverDocumentsModel, serverDocumentsId], + [serverAiBotModel, serverAiBotId] ] for (const [b, id, config] of builders) { diff --git a/models/server-ai-bot/.eslintrc.js b/models/server-ai-bot/.eslintrc.js new file mode 100644 index 0000000000..c1cf82cba0 --- /dev/null +++ b/models/server-ai-bot/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/model/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/models/server-ai-bot/.npmignore b/models/server-ai-bot/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/models/server-ai-bot/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/models/server-ai-bot/config/rig.json b/models/server-ai-bot/config/rig.json new file mode 100644 index 0000000000..2f6be36605 --- /dev/null +++ b/models/server-ai-bot/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "model" +} diff --git a/models/server-ai-bot/package.json b/models/server-ai-bot/package.json new file mode 100644 index 0000000000..3f83963a1e --- /dev/null +++ b/models/server-ai-bot/package.json @@ -0,0 +1,49 @@ +{ + "name": "@hcengineering/model-server-ai-bot", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Hardcore Engineering Inc.", + "template": "@hcengineering/model-package", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "prettier": "^3.1.0", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/activity": "^0.6.0", + "@hcengineering/ai-bot": "^0.6.0", + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/model": "^0.6.11", + "@hcengineering/model-activity": "^0.6.0", + "@hcengineering/model-chunter": "^0.6.0", + "@hcengineering/model-contact": "^0.6.1", + "@hcengineering/model-core": "^0.6.0", + "@hcengineering/model-notification": "^0.6.0", + "@hcengineering/model-presentation": "^0.6.0", + "@hcengineering/model-view": "^0.6.0", + "@hcengineering/notification": "^0.6.23", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/view": "^0.6.13" + } +} diff --git a/models/server-ai-bot/src/index.ts b/models/server-ai-bot/src/index.ts new file mode 100644 index 0000000000..25bf6d947f --- /dev/null +++ b/models/server-ai-bot/src/index.ts @@ -0,0 +1,111 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Builder, Mixin, Model, Prop, TypeRef, TypeString } from '@hcengineering/model' +import core, { type Account, type Class, type Doc, type Domain, type Ref, type Space } from '@hcengineering/core' +import serverCore from '@hcengineering/server-core' +import serverAiBot from '@hcengineering/server-ai-bot' +import { TDoc } from '@hcengineering/model-core' +import { getEmbeddedLabel } from '@hcengineering/platform' +import aiBot, { + type AIBotEvent, + type AIBotTransferEvent, + type AIBotResponseEvent, + type TransferredMessage +} from '@hcengineering/ai-bot' +import chunter, { type ChatMessage } from '@hcengineering/chunter' +import notification from '@hcengineering/notification' +import { TChatMessage } from '@hcengineering/model-chunter' + +export { serverAiBotId } from '@hcengineering/server-ai-bot' + +export const DOMAIN_AI_BOT = 'ai_bot' as Domain + +@Model(aiBot.class.AIBotEvent, core.class.Doc, DOMAIN_AI_BOT) +export class TAIBotEvent extends TDoc implements AIBotEvent { + @Prop(TypeRef(chunter.class.ChatMessage), core.string.Class) + messageClass!: Ref> + + @Prop(TypeString(), getEmbeddedLabel('Collection')) + collection!: string + + @Prop(TypeString(), getEmbeddedLabel('Message')) + message!: string +} + +@Model(aiBot.class.AIBotResponseEvent, core.class.Doc) +export class TAIBotResponseEvent extends TAIBotEvent implements AIBotResponseEvent { + @Prop(TypeRef(core.class.Doc), core.string.Object) + objectId!: Ref + + @Prop(TypeRef(core.class.Class), core.string.Class) + objectClass!: Ref> + + @Prop(TypeRef(core.class.Space), core.string.Space) + objectSpace!: Ref + + @Prop(TypeRef(core.class.Account), core.string.Account) + user!: Ref +} + +@Model(aiBot.class.AIBotTransferEvent, aiBot.class.AIBotEvent) +export class TAIBotTransferEvent extends TAIBotEvent implements AIBotTransferEvent { + toEmail!: string + toWorkspace!: string + fromWorkspace!: string + messageId!: Ref + parentMessageId?: Ref +} + +@Mixin(aiBot.mixin.TransferredMessage, chunter.class.ChatMessage) +export class TTransferredMessage extends TChatMessage implements TransferredMessage { + messageId!: Ref + parentMessageId?: Ref +} + +export function createModel (builder: Builder): void { + builder.createModel(TAIBotEvent, TAIBotTransferEvent, TAIBotResponseEvent, TTransferredMessage) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAiBot.trigger.OnMessageSend, + isAsync: true + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAiBot.trigger.OnMention, + txMatch: { + _class: core.class.TxCreateDoc, + objectClass: notification.class.MentionInboxNotification + }, + isAsync: true + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAiBot.trigger.OnMessageNotified, + txMatch: { + _class: core.class.TxCreateDoc, + objectClass: notification.class.ActivityInboxNotification + }, + isAsync: true + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAiBot.trigger.OnUserStatus, + txMatch: { + objectClass: core.class.UserStatus + }, + isAsync: true + }) +} diff --git a/models/server-ai-bot/tsconfig.json b/models/server-ai-bot/tsconfig.json new file mode 100644 index 0000000000..9765b6086d --- /dev/null +++ b/models/server-ai-bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/model/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/plugins/ai-bot/.eslintrc.js b/plugins/ai-bot/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/plugins/ai-bot/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/plugins/ai-bot/.npmignore b/plugins/ai-bot/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/plugins/ai-bot/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/plugins/ai-bot/config/rig.json b/plugins/ai-bot/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/plugins/ai-bot/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/plugins/ai-bot/jest.config.js b/plugins/ai-bot/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/plugins/ai-bot/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/plugins/ai-bot/package.json b/plugins/ai-bot/package.json new file mode 100644 index 0000000000..55b8b14e40 --- /dev/null +++ b/plugins/ai-bot/package.json @@ -0,0 +1,47 @@ +{ + "name": "@hcengineering/ai-bot", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.54.0", + "eslint-config-prettier": "^8.8.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/view": "^0.6.13" + } +} diff --git a/plugins/ai-bot/src/index.ts b/plugins/ai-bot/src/index.ts new file mode 100644 index 0000000000..4a58934ee8 --- /dev/null +++ b/plugins/ai-bot/src/index.ts @@ -0,0 +1,65 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Account, Class, Doc, type Mixin, Ref, Space } from '@hcengineering/core' +import type { Plugin } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' +import { ChatMessage } from '@hcengineering/chunter' + +export const aiBotId = 'ai-bot' as Plugin + +export const aiBotAccountEmail = 'huly.ai.bot@hc.engineering' + +export interface AIBotEvent extends Doc { + collection: string + messageClass: Ref> + message: string +} + +export interface AIBotResponseEvent extends AIBotEvent { + objectId: Ref + objectClass: Ref> + objectSpace: Ref + user: Ref +} + +export interface AIBotTransferEvent extends AIBotEvent { + toEmail: string + toWorkspace: string + fromWorkspace: string + messageId: Ref + parentMessageId?: Ref +} + +export interface TransferredMessage extends ChatMessage { + messageId: Ref + parentMessageId?: Ref +} + +const aiBot = plugin(aiBotId, { + class: { + AIBotEvent: '' as Ref>, + AIBotTransferEvent: '' as Ref>, + AIBotResponseEvent: '' as Ref> + }, + mixin: { + TransferredMessage: '' as Ref> + }, + account: { + AIBot: '' as Ref + } +}) + +export default aiBot diff --git a/plugins/ai-bot/tsconfig.json b/plugins/ai-bot/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/plugins/ai-bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/pods/server/package.json b/pods/server/package.json index 72020dedef..9f70800e66 100644 --- a/pods/server/package.json +++ b/pods/server/package.json @@ -71,6 +71,8 @@ "@hcengineering/analytics-service": "^0.6.0", "@hcengineering/contact": "^0.6.24", "@hcengineering/notification": "^0.6.23", - "@hcengineering/server-notification": "^0.6.1" + "@hcengineering/server-notification": "^0.6.1", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-ai-bot-resources": "^0.6.0" } } diff --git a/pods/server/src/__start.ts b/pods/server/src/__start.ts index 8ea676b062..026305cf34 100644 --- a/pods/server/src/__start.ts +++ b/pods/server/src/__start.ts @@ -15,6 +15,7 @@ import serverNotification from '@hcengineering/server-notification' import serverToken from '@hcengineering/server-token' import { serverFactories } from '@hcengineering/server-ws/src/factories' import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service' +import serverAiBot from '@hcengineering/server-ai-bot' import { join } from 'path' import { start } from '.' const serverFactory = serverFactories[(process.env.SERVER_PROVIDER as string) ?? 'ws'] ?? serverFactories.ws @@ -50,6 +51,7 @@ setMetadata(notification.metadata.PushPublicKey, config.pushPublicKey) setMetadata(serverNotification.metadata.PushPrivateKey, config.pushPrivateKey) setMetadata(serverNotification.metadata.PushSubject, config.pushSubject) setMetadata(serverCore.metadata.ElasticIndexName, config.elasticIndexName) +setMetadata(serverAiBot.metadata.SupportWorkspaceId, process.env.SUPPORT_WORKSPACE) const shutdown = start(config.url, { fullTextUrl: config.elasticUrl, diff --git a/pods/server/src/server.ts b/pods/server/src/server.ts index 8cac008e5f..870d96f0eb 100644 --- a/pods/server/src/server.ts +++ b/pods/server/src/server.ts @@ -22,6 +22,8 @@ import { type Token } from '@hcengineering/server-token' import { ClientSession, start as startJsonRpc, type ServerFactory, type Session } from '@hcengineering/server-ws' import { createServerPipeline, registerServerPlugins, registerStringLoaders } from '@hcengineering/server-pipeline' +import { serverAiBotId } from '@hcengineering/server-ai-bot' +import { createAIBotAdapter } from '@hcengineering/server-ai-bot-resources' registerStringLoaders() @@ -53,7 +55,20 @@ export function start ( const externalStorage = buildStorageFromConfig(opt.storageConfig, dbUrl) - const pipelineFactory = createServerPipeline(metrics, dbUrl, { ...opt, externalStorage }) + const pipelineFactory = createServerPipeline( + metrics, + dbUrl, + { ...opt, externalStorage }, + { + serviceAdapters: { + [serverAiBotId]: { + factory: createAIBotAdapter, + db: '%ai-bot', + url: dbUrl + } + } + } + ) const sessionFactory = (token: Token, pipeline: Pipeline): Session => { if (token.extra?.mode === 'backup') { return new BackupClientSession(token, pipeline) diff --git a/rush.json b/rush.json index 792cd39999..7c72bc0043 100644 --- a/rush.json +++ b/rush.json @@ -2091,6 +2091,31 @@ "packageName": "@hcengineering/server-analytics-collector-resources", "projectFolder": "server-plugins/analytics-collector-resources", "shouldPublish": false + }, + { + "packageName": "@hcengineering/pod-ai-bot", + "projectFolder": "services/ai-bot/pod-ai-bot", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/ai-bot", + "projectFolder": "plugins/ai-bot", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/model-server-ai-bot", + "projectFolder": "models/server-ai-bot", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/server-ai-bot", + "projectFolder": "server-plugins/ai-bot", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/server-ai-bot-resources", + "projectFolder": "server-plugins/ai-bot-resources", + "shouldPublish": false } ] } diff --git a/server-plugins/ai-bot-resources/.eslintrc.js b/server-plugins/ai-bot-resources/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/server-plugins/ai-bot-resources/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/server-plugins/ai-bot-resources/.npmignore b/server-plugins/ai-bot-resources/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/server-plugins/ai-bot-resources/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/server-plugins/ai-bot-resources/.prettierignore b/server-plugins/ai-bot-resources/.prettierignore new file mode 100644 index 0000000000..21c8f7d149 --- /dev/null +++ b/server-plugins/ai-bot-resources/.prettierignore @@ -0,0 +1 @@ +tsdoc-metadata.json \ No newline at end of file diff --git a/server-plugins/ai-bot-resources/config/rig.json b/server-plugins/ai-bot-resources/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/server-plugins/ai-bot-resources/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/server-plugins/ai-bot-resources/jest.config.js b/server-plugins/ai-bot-resources/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/server-plugins/ai-bot-resources/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/server-plugins/ai-bot-resources/package.json b/server-plugins/ai-bot-resources/package.json new file mode 100644 index 0000000000..4c28e91d80 --- /dev/null +++ b/server-plugins/ai-bot-resources/package.json @@ -0,0 +1,55 @@ +{ + "name": "@hcengineering/server-ai-bot-resources", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/activity": "^0.6.0", + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/mongo": "^0.6.1", + "@hcengineering/notification": "^0.6.23", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/server-activity-resources": "^0.6.0", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/server-templates": "^0.6.0", + "@hcengineering/templates": "^0.6.11", + "@hcengineering/ai-bot": "^0.6.0", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/analytics-collector": "^0.6.0", + "mongodb": "^6.8.0" + } +} diff --git a/server-plugins/ai-bot-resources/src/adapter.ts b/server-plugins/ai-bot-resources/src/adapter.ts new file mode 100644 index 0000000000..0ad6f9f5fb --- /dev/null +++ b/server-plugins/ai-bot-resources/src/adapter.ts @@ -0,0 +1,75 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { MeasureContext, WorkspaceId } from '@hcengineering/core' +import { getMongoClient, MongoClientReference } from '@hcengineering/mongo' +import { AIBotServiceAdapter, WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' +import { Collection, Db, MongoClient } from 'mongodb' + +class AIBotAdapter implements AIBotServiceAdapter { + private readonly workspacesInfoCollection: Collection + private readonly db: Db + closed = false + + constructor ( + private readonly _client: MongoClientReference, + private readonly client: MongoClient, + private readonly _metrics: MeasureContext, + private readonly dbName: string + ) { + this.db = client.db(dbName) + this.workspacesInfoCollection = this.db.collection('workspacesInfo') + } + + async processWorkspace (workspace: WorkspaceId): Promise { + if (this.closed) { + return + } + const existsRecord = await this.workspacesInfoCollection.findOne({ + workspace: workspace.name, + productId: workspace.productId + }) + + if (existsRecord != null && !existsRecord.active) { + await this.workspacesInfoCollection.updateOne( + { workspace: workspace.name, productId: workspace.productId }, + { $set: { active: true } } + ) + } else if (existsRecord == null) { + const record: WorkspaceInfoRecord = { + workspace: workspace.name, + productId: workspace.productId, + active: true + } + + await this.workspacesInfoCollection.insertOne(record) + } + } + + async close (): Promise { + this.closed = true + this._client.close() + } + + metrics (): MeasureContext { + return this._metrics + } +} + +export async function createAIBotAdapter (url: string, db: string, metrics: MeasureContext): Promise { + const _client = getMongoClient(url) + + return new AIBotAdapter(_client, await _client.getClient(), metrics, db) +} diff --git a/server-plugins/ai-bot-resources/src/index.ts b/server-plugins/ai-bot-resources/src/index.ts new file mode 100644 index 0000000000..8e89b65ee7 --- /dev/null +++ b/server-plugins/ai-bot-resources/src/index.ts @@ -0,0 +1,454 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core, { + AccountRole, + AttachedDoc, + Data, + Doc, + Ref, + toWorkspaceString, + Tx, + TxCollectionCUD, + TxCreateDoc, + TxCUD, + TxProcessor, + TxUpdateDoc, + UserStatus +} from '@hcengineering/core' +import { TriggerControl } from '@hcengineering/server-core' +import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' +import aiBot, { aiBotAccountEmail, AIBotResponseEvent } from '@hcengineering/ai-bot' +import serverAIBot, { AIBotServiceAdapter, serverAiBotId } from '@hcengineering/server-ai-bot' +import contact, { PersonAccount } from '@hcengineering/contact' +import { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification' +import { getMetadata } from '@hcengineering/platform' +import analytics from '@hcengineering/analytics-collector' + +async function processWorkspace (control: TriggerControl): Promise { + const adapter = control.serviceAdaptersManager.getAdapter(serverAiBotId) as AIBotServiceAdapter | undefined + + if (adapter !== undefined) { + await adapter.processWorkspace(control.workspace) + } else { + console.error('Cannot find server adapter: ', serverAiBotId) + } +} + +async function isDirectAvailable (direct: DirectMessage, control: TriggerControl): Promise { + const { members } = direct + + if (!members.includes(aiBot.account.AIBot)) { + return false + } + + const personAccounts = await control.modelDb.findAll(contact.class.PersonAccount, { + _id: { $in: members as Ref[] } + }) + const persons = new Set(personAccounts.map((account) => account.person)) + + return persons.size === 2 +} + +async function isAvailableDoc (doc: Doc, control: TriggerControl): Promise { + if (doc._class === chunter.class.DirectMessage) { + return await isDirectAvailable(doc as DirectMessage, control) + } + + return true +} + +async function getMessageDoc (message: ChatMessage, control: TriggerControl): Promise { + if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + const thread = message as ThreadMessage + const _id = thread.objectId + const _class = thread.objectClass + + return (await control.findAll(_class, { _id }))[0] + } else { + const _id = message.attachedTo + const _class = message.attachedToClass + + return (await control.findAll(_class, { _id }))[0] + } +} + +async function getMessageData ( + doc: Doc, + message: ChatMessage, + control: TriggerControl +): Promise | undefined> { + if (!(await isAvailableDoc(doc, control))) { + return + } + + return { + objectId: message.attachedTo, + objectClass: message.attachedToClass, + objectSpace: doc.space, + collection: message.collection, + messageClass: message._class, + message: message.message, + user: message.createdBy ?? message.modifiedBy + } +} + +async function getThreadMessageData ( + doc: Doc, + message: ThreadMessage, + control: TriggerControl +): Promise | undefined> { + if (!(await isAvailableDoc(doc, control))) { + return + } + + return { + objectId: message.attachedTo, + objectClass: message.attachedToClass, + objectSpace: message.space, + collection: message.collection, + messageClass: message._class, + message: message.message, + user: message.createdBy ?? message.modifiedBy + } +} + +// Note: temporally commented until open ai will be added +// async function createAIBotEvent (message: ChatMessage, control: TriggerControl): Promise { +// const doc = await getMessageDoc(message, control) +// +// if (doc === undefined) { +// return [] +// } +// +// let data: Data | undefined +// +// if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { +// data = await getThreadMessageData(doc, message as ThreadMessage, control) +// } else { +// data = await getMessageData(doc, message, control) +// } +// +// if (data === undefined) { +// return [] +// } +// +// const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotResponseEvent, message.space, data) +// +// await processWorkspace(control) +// +// return [eventTx] +// return [] +// } + +async function getThreadParent (control: TriggerControl, message: ChatMessage): Promise | undefined> { + if (!control.hierarchy.isDerived(message.attachedToClass, chunter.class.ChatMessage)) { + return undefined + } + + const parentInfo = ( + await control.findAll(message.attachedToClass, { + _id: message.attachedTo as Ref, + [aiBot.mixin.TransferredMessage]: { $exists: true } + }) + )[0] + + if (parentInfo !== undefined) { + return control.hierarchy.as(parentInfo, aiBot.mixin.TransferredMessage).messageId + } + + return message.attachedTo as Ref +} + +function getSupportWorkspaceId (): string | undefined { + const supportWorkspaceId = getMetadata(serverAIBot.metadata.SupportWorkspaceId) + + if (supportWorkspaceId === '') { + return undefined + } + + return supportWorkspaceId +} + +async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise { + const supportWorkspaceId = getSupportWorkspaceId() + + if (supportWorkspaceId === undefined) { + return [] + } + + const direct = (await getMessageDoc(message, control)) as DirectMessage + + if (direct === undefined) { + return [] + } + + const isAvailable = await isDirectAvailable(direct, control) + + if (!isAvailable) { + return [] + } + + const account = control.modelDb.findAllSync(contact.class.PersonAccount, { + _id: (message.createdBy ?? message.modifiedBy) as Ref + })[0] + + if (account === undefined || account.role !== AccountRole.Owner) { + return [] + } + + let data: Data | undefined + + if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + data = await getThreadMessageData(direct, message as ThreadMessage, control) + } else { + data = await getMessageData(direct, message, control) + } + + if (data === undefined) { + return [] + } + + const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { + messageClass: data.messageClass, + message: message.message, + collection: data.collection, + toWorkspace: supportWorkspaceId, + toEmail: account.email, + fromWorkspace: toWorkspaceString(control.workspace), + messageId: message._id, + parentMessageId: await getThreadParent(control, message) + }) + + await processWorkspace(control) + + return [eventTx] +} + +async function onSupportWorkspaceMessage (control: TriggerControl, message: ChatMessage): Promise { + const supportWorkspaceId = getSupportWorkspaceId() + + if (supportWorkspaceId === undefined) { + return [] + } + + if (toWorkspaceString(control.workspace) !== supportWorkspaceId) { + return [] + } + + const channel = await getMessageDoc(message, control) + + if (channel === undefined) { + return [] + } + + if (!control.hierarchy.hasMixin(channel, analytics.mixin.AnalyticsChannel)) { + return [] + } + + const mixin = control.hierarchy.as(channel, analytics.mixin.AnalyticsChannel) + const { workspace, email } = mixin + + let data: Data | undefined + + if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + data = await getThreadMessageData(channel, message as ThreadMessage, control) + } else { + data = await getMessageData(channel, message, control) + } + + if (data === undefined) { + return [] + } + + await processWorkspace(control) + + return [ + control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { + messageClass: data.messageClass, + message: message.message, + collection: data.collection, + toEmail: email, + toWorkspace: workspace, + fromWorkspace: toWorkspaceString(control.workspace), + messageId: message._id, + parentMessageId: await getThreadParent(control, message) + }) + ] +} + +export async function OnMessageSend ( + originTx: TxCollectionCUD, + control: TriggerControl +): Promise { + const { hierarchy } = control + const tx = TxProcessor.extractTx(originTx) as TxCreateDoc + if (tx._class !== core.class.TxCreateDoc || !hierarchy.isDerived(tx.objectClass, chunter.class.ChatMessage)) { + return [] + } + + if (tx.modifiedBy === aiBot.account.AIBot || tx.modifiedBy === core.account.System) { + return [] + } + + const isThread = hierarchy.isDerived(tx.objectClass, chunter.class.ThreadMessage) + const message = TxProcessor.createDoc2Doc(tx) + + const docClass = isThread ? (message as ThreadMessage).objectClass : message.attachedToClass + + if (!hierarchy.isDerived(docClass, chunter.class.ChunterSpace)) { + return [] + } + + const res: Tx[] = [] + + if (docClass === chunter.class.DirectMessage) { + const txes = await onBotDirectMessageSend(control, message) + res.push(...txes) + } + + if (docClass === chunter.class.Channel) { + const txes = await onSupportWorkspaceMessage(control, message) + res.push(...txes) + } + + return res +} + +export async function OnMention (tx: TxCreateDoc, control: TriggerControl): Promise { + // Note: temporally commented until open ai will be added + // if (tx.objectClass !== notification.class.MentionInboxNotification || tx._class !== core.class.TxCreateDoc) { + // return [] + // } + // + // const mention = TxProcessor.createDoc2Doc(tx) + // + // if (mention.user !== aiBot.account.AIBot) { + // return [] + // } + // + // if (!control.hierarchy.isDerived(mention.mentionedInClass, chunter.class.ChatMessage)) { + // return [] + // } + // + // const message = ( + // await control.findAll(mention.mentionedInClass, { _id: mention.mentionedIn as Ref }) + // )[0] + // + // if (message === undefined) { + // return [] + // } + // + // await createAIBotEvent(message, control) + + return [] +} + +export async function OnMessageNotified ( + tx: TxCreateDoc, + control: TriggerControl +): Promise { + // Note: temporally commented until open ai will be added + // if (tx.objectClass !== notification.class.ActivityInboxNotification || tx._class !== core.class.TxCreateDoc) { + // return [] + // } + // + // const doc = TxProcessor.createDoc2Doc(tx) + // + // if (doc.user !== aiBot.account.AIBot) { + // return [] + // } + // + // if (!control.hierarchy.isDerived(doc.attachedToClass, chunter.class.ChatMessage)) { + // return [] + // } + // + // const personAccount = await control.modelDb.findOne(contact.class.PersonAccount, { email: aiBotAccountEmail }) + // + // if (personAccount === undefined) { + // return [] + // } + // + // const message = ( + // await control.findAll(doc.attachedToClass, { _id: doc.attachedTo as Ref }) + // )[0] + // + // if (message === undefined) { + // return [] + // } + // + // if (isDocMentioned(personAccount.person, message.message)) { + // return await createAIBotEvent(message, control) + // } + // + // if (!control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + // return [] + // } + // + // const thread = message as ThreadMessage + // TODO: do we really need to find parent??? + // const parent = (await control.findAll(thread.attachedToClass, { _id: thread.attachedTo }))[0] + // + // if (parent === undefined) { + // return [] + // } + // + // if (parent.createdBy === aiBot.account.AIBot) { + // return await createAIBotEvent(message, control) + // } + + return [] +} + +export async function OnUserStatus (originTx: Tx, control: TriggerControl): Promise { + const tx = TxProcessor.extractTx(originTx) as TxCUD + + if ( + tx.objectClass !== core.class.UserStatus || + ![core.class.TxCreateDoc, core.class.TxUpdateDoc].includes(tx._class) + ) { + return [] + } + + if (tx._class === core.class.TxUpdateDoc) { + const updateTx = tx as TxUpdateDoc + const val = updateTx.operations.online + if (val !== true) { + return [] + } + } + + const account = control.modelDb.findAllSync(contact.class.PersonAccount, { email: aiBotAccountEmail })[0] + + if (account !== undefined) { + return [] + } + + await processWorkspace(control) + + return [] +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export default async () => ({ + trigger: { + OnMessageSend, + OnMention, + OnMessageNotified, + OnUserStatus + } +}) + +export * from './adapter' diff --git a/server-plugins/ai-bot-resources/tsconfig.json b/server-plugins/ai-bot-resources/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/server-plugins/ai-bot-resources/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/server-plugins/ai-bot/.eslintrc.js b/server-plugins/ai-bot/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/server-plugins/ai-bot/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/server-plugins/ai-bot/.npmignore b/server-plugins/ai-bot/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/server-plugins/ai-bot/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/server-plugins/ai-bot/config/rig.json b/server-plugins/ai-bot/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/server-plugins/ai-bot/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/server-plugins/ai-bot/jest.config.js b/server-plugins/ai-bot/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/server-plugins/ai-bot/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/server-plugins/ai-bot/package.json b/server-plugins/ai-bot/package.json new file mode 100644 index 0000000000..ceee584341 --- /dev/null +++ b/server-plugins/ai-bot/package.json @@ -0,0 +1,45 @@ +{ + "name": "@hcengineering/server-ai-bot", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@types/jest": "^29.5.5", + "@types/node": "~20.11.16", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/ai-bot": "^0.6.0", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/core": "^0.6.32", + "@hcengineering/server-core": "^0.6.1" + } +} diff --git a/server-plugins/ai-bot/src/index.ts b/server-plugins/ai-bot/src/index.ts new file mode 100644 index 0000000000..29380a1f13 --- /dev/null +++ b/server-plugins/ai-bot/src/index.ts @@ -0,0 +1,33 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Metadata, Plugin, Resource } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' +import { TriggerFunc } from '@hcengineering/server-core' + +export * from './types' +export const serverAiBotId = 'server-ai-bot' as Plugin + +export default plugin(serverAiBotId, { + metadata: { + SupportWorkspaceId: '' as Metadata + }, + trigger: { + OnMessageSend: '' as Resource, + OnMention: '' as Resource, + OnUserStatus: '' as Resource, + OnMessageNotified: '' as Resource + } +}) diff --git a/server-plugins/ai-bot/src/types.ts b/server-plugins/ai-bot/src/types.ts new file mode 100644 index 0000000000..89d45b440a --- /dev/null +++ b/server-plugins/ai-bot/src/types.ts @@ -0,0 +1,29 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { ServiceAdapter } from '@hcengineering/server-core' +import { WorkspaceId } from '@hcengineering/core' + +export interface AIBotServiceAdapter extends ServiceAdapter { + processWorkspace: (workspace: WorkspaceId) => Promise +} + +export interface WorkspaceInfoRecord { + workspace: string + productId: string + active: boolean + avatarPath?: string + avatarLastModified?: number +} diff --git a/server-plugins/ai-bot/tsconfig.json b/server-plugins/ai-bot/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/server-plugins/ai-bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/server/server-pipeline/package.json b/server/server-pipeline/package.json index a35d5dc2be..1d4f8164e9 100644 --- a/server/server-pipeline/package.json +++ b/server/server-pipeline/package.json @@ -172,6 +172,8 @@ "@hcengineering/products": "^0.1.0", "@hcengineering/training": "^0.1.0", "@hcengineering/github": "^0.6.0", - "@hcengineering/github-assets": "^0.6.0" + "@hcengineering/github-assets": "^0.6.0", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-ai-bot-resources": "^0.6.0" } } diff --git a/server/server-pipeline/src/serverPlugins.ts b/server/server-pipeline/src/serverPlugins.ts index 9108f3c0f3..cf647657fe 100644 --- a/server/server-pipeline/src/serverPlugins.ts +++ b/server/server-pipeline/src/serverPlugins.ts @@ -27,6 +27,7 @@ import { serverTimeId } from '@hcengineering/server-time' import { serverTrackerId } from '@hcengineering/server-tracker' import { serverTrainingId } from '@hcengineering/server-training' import { serverViewId } from '@hcengineering/server-view' +import { serverAiBotId } from '@hcengineering/server-ai-bot' export function registerServerPlugins (): void { addLocation(serverActivityId, () => import('@hcengineering/server-activity-resources')) @@ -57,4 +58,5 @@ export function registerServerPlugins (): void { addLocation(serverDocumentsId, () => import('@hcengineering/server-controlled-documents-resources')) addLocation(serverTrainingId, () => import('@hcengineering/server-training-resources')) addLocation(serverGithubId, () => import('@hcengineering/server-github-resources')) + addLocation(serverAiBotId, () => import('@hcengineering/server-ai-bot-resources')) } diff --git a/services/ai-bot/pod-ai-bot/.eslintrc.js b/services/ai-bot/pod-ai-bot/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/services/ai-bot/pod-ai-bot/.npmignore b/services/ai-bot/pod-ai-bot/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/services/ai-bot/pod-ai-bot/Dockerfile b/services/ai-bot/pod-ai-bot/Dockerfile new file mode 100644 index 0000000000..356a47b67d --- /dev/null +++ b/services/ai-bot/pod-ai-bot/Dockerfile @@ -0,0 +1,9 @@ +FROM node:20-alpine + +WORKDIR /usr/src/app + +COPY bundle/bundle.js ./ +COPY assets/avatar.png ./ + +EXPOSE 4008 +CMD [ "node", "bundle.js" ] diff --git a/services/ai-bot/pod-ai-bot/assets/avatar.png b/services/ai-bot/pod-ai-bot/assets/avatar.png new file mode 100644 index 0000000000..fe75e47ce5 Binary files /dev/null and b/services/ai-bot/pod-ai-bot/assets/avatar.png differ diff --git a/services/ai-bot/pod-ai-bot/config/rig.json b/services/ai-bot/pod-ai-bot/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/services/ai-bot/pod-ai-bot/jest.config.js b/services/ai-bot/pod-ai-bot/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/services/ai-bot/pod-ai-bot/package.json b/services/ai-bot/pod-ai-bot/package.json new file mode 100644 index 0000000000..28e7cb06a1 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/package.json @@ -0,0 +1,84 @@ +{ + "name": "@hcengineering/pod-ai-bot", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent", + "_phase:bundle": "rushx bundle", + "_phase:docker-build": "rushx docker:build", + "_phase:docker-staging": "rushx docker:staging", + "bundle": "mkdir -p bundle && esbuild src/index.ts --bundle --platform=node > bundle/bundle.js", + "docker:build": "../../../common/scripts/docker_build.sh hardcoreeng/ai-bot", + "docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/ai-bot staging", + "docker:push": "../../../common/scripts/docker_tag.sh hardcoreeng/ai-bot", + "docker:tbuild": "rush bundle --to @hcengineering/pod-ai-bot && docker build -t hardcoreeng/ai-bot . --platform=linux/amd64 && ../../../common/scripts/docker_tag_push.sh hardcoreeng/ai-bot", + "run-local": "cross-env APP_ID=$(cat ../../../../uberflow_private/appid) PRIVATE_KEY=\"$(cat ../../../../uberflow_private/private-key.pem)\" CLIENT_ID=$(cat ../../../../uberflow_private/client-id) CLIENT_SECRET=$(cat ../../../../uberflow_private/client-secret) SERVER_SECRET=secret ACCOUNTS_URL=http://localhost:3000/ COLLABORATOR_URL=http://localhost:3078 COLLABORATOR_API_URL=http://localhost:3078 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_ENDPOINT=localhost ts-node src/index.ts", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@tsconfig/node16": "^1.0.4", + "@types/cors": "^2.8.12", + "@types/express": "^4.17.13", + "@types/jest": "^29.5.5", + "@types/node": "~20.11.16", + "@types/node-fetch": "~2.6.2", + "@types/ws": "^8.5.11", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "esbuild": "^0.20.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.8.0", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/account": "^0.6.0", + "@hcengineering/ai-bot": "^0.6.0", + "@hcengineering/analytics-collector": "^0.6.0", + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/client": "^0.6.18", + "@hcengineering/client-resources": "^0.6.27", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/mongo": "^0.6.1", + "@hcengineering/notification": "^0.6.23", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-analytics-collector-resources": "^0.6.0", + "@hcengineering/server-client": "^0.6.0", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/server-token": "^0.6.11", + "@hcengineering/server-ws": "^0.6.11", + "@hcengineering/setting": "^0.6.17", + "cors": "^2.8.5", + "dotenv": "~16.0.0", + "express": "^4.19.2", + "fast-equals": "^5.0.1", + "form-data": "^4.0.0", + "mongodb": "^6.8.0", + "node-fetch": "^2.6.6", + "ws": "^8.18.0" + } +} diff --git a/services/ai-bot/pod-ai-bot/src/account.ts b/services/ai-bot/pod-ai-bot/src/account.ts new file mode 100644 index 0000000000..186a0b905e --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/account.ts @@ -0,0 +1,101 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { LoginInfo, Workspace, WorkspaceLoginInfo } from '@hcengineering/account' +import aiBot, { aiBotAccountEmail } from '@hcengineering/ai-bot' +import { AccountRole, WorkspaceId } from '@hcengineering/core' + +import config from './config' + +export async function assignBotToWorkspace (workspaceId: WorkspaceId): Promise { + const accountsUrl = config.AccountsURL + const workspace = await ( + await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'assignWorkspace', + params: [ + aiBotAccountEmail, + workspaceId.name, + AccountRole.User, + undefined, + false, + undefined, + aiBot.account.AIBot + ] + }) + }) + ).json() + + return workspace.result as Workspace +} + +export async function createBotAccount (): Promise { + const accountsUrl = config.AccountsURL + const workspace = await ( + await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'createAccount', + params: [aiBotAccountEmail, config.Password, config.FirstName, config.LastName] + }) + }) + ).json() + + return workspace.result as Workspace +} + +export async function loginBot (): Promise { + const accountsUrl = config.AccountsURL + const workspace = await ( + await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'login', + params: [aiBotAccountEmail, config.Password] + }) + }) + ).json() + + return workspace.result as LoginInfo | undefined +} + +export async function getWorkspaceInfo (token: string): Promise { + const accountsUrl = config.AccountsURL + const workspaceInfo = await ( + await fetch(accountsUrl, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'getWorkspaceInfo', + params: [] + }) + }) + ).json() + + return workspaceInfo.result as WorkspaceLoginInfo +} diff --git a/services/ai-bot/pod-ai-bot/src/config.ts b/services/ai-bot/pod-ai-bot/src/config.ts new file mode 100644 index 0000000000..784a2ac1ab --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/config.ts @@ -0,0 +1,76 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +interface Config { + AccountsURL: string + ConfigurationDB: string + MongoURL: string + ProductID: string + ServerSecret: string + ServiceID: string + SupportWorkspace: string + FirstName: string + LastName: string + AvatarPath: string + AvatarName: string + AvatarContentType: string + Password: string +} + +const envMap: { [key in keyof Config]: string } = { + AccountsURL: 'ACCOUNTS_URL', + ConfigurationDB: 'CONFIGURATION_DB', + MongoURL: 'MONGO_URL', + ProductID: 'PRODUCT_ID', + ServerSecret: 'SERVER_SECRET', + ServiceID: 'SERVICE_ID', + SupportWorkspace: 'SUPPORT_WORKSPACE', + FirstName: 'FIRST_NAME', + LastName: 'LAST_NAME', + AvatarPath: 'AVATAR_PATH', + AvatarName: 'AVATAR_NAME', + AvatarContentType: 'AVATAR_CONTENT_TYPE', + Password: 'PASSWORD' +} + +const config: Config = (() => { + const params: Partial = { + AccountsURL: process.env[envMap.AccountsURL], + ConfigurationDB: process.env[envMap.ConfigurationDB] ?? '%ai-bot', + MongoURL: process.env[envMap.MongoURL], + ProductID: process.env[envMap.ProductID] ?? '', + ServerSecret: process.env[envMap.ServerSecret], + ServiceID: process.env[envMap.ServiceID] ?? 'ai-bot-service', + SupportWorkspace: process.env[envMap.SupportWorkspace], + FirstName: process.env[envMap.FirstName], + LastName: process.env[envMap.LastName], + AvatarPath: process.env[envMap.AvatarPath] ?? './assets/avatar.png', + AvatarName: process.env[envMap.AvatarName] ?? 'huly_ai_bot_avatar', + AvatarContentType: process.env[envMap.AvatarContentType] ?? '.png', + Password: process.env[envMap.Password] ?? 'password' + } + + const missingEnv = (Object.keys(params) as Array) + .filter((key) => params[key] === undefined) + .map((key) => envMap[key]) + + if (missingEnv.length > 0) { + throw Error(`Missing env variables: ${missingEnv.join(', ')}`) + } + + return params as Config +})() + +export default config diff --git a/services/ai-bot/pod-ai-bot/src/controller.ts b/services/ai-bot/pod-ai-bot/src/controller.ts new file mode 100644 index 0000000000..0df29539ef --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/controller.ts @@ -0,0 +1,216 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Db, Collection } from 'mongodb' +import { getWorkspaceId, MeasureContext, systemAccountEmail, toWorkspaceString, WorkspaceId } from '@hcengineering/core' +import { aiBotAccountEmail, AIBotTransferEvent } from '@hcengineering/ai-bot' +import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' +import { getTransactorEndpoint } from '@hcengineering/server-client' +import { generateToken } from '@hcengineering/server-token' +import { WorkspaceLoginInfo } from '@hcengineering/account' + +import { WorkspaceClient } from './workspaceClient' +import { assignBotToWorkspace, getWorkspaceInfo } from './account' + +const POLLING_INTERVAL_MS = 5 * 1000 // 5 seconds +const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes +const ASSIGN_WORKSPACE_DELAY_MS = 5 * 1000 // 5 secs +const MAX_ASSIGN_ATTEMPTS = 5 + +export class AIBotController { + private readonly workspaces: Map = new Map() + private readonly closeWorkspaceTimeouts: Map = new Map() + + private readonly db: Db + private readonly ctx: MeasureContext + private readonly workspacesInfoCollection: Collection + + private readonly intervalId: NodeJS.Timeout + + assignTimeout: NodeJS.Timeout | undefined + assignAttempts = 0 + + constructor (mongoDb: Db, ctx: MeasureContext) { + this.db = mongoDb + this.ctx = ctx + this.workspacesInfoCollection = this.db.collection('workspacesInfo') + + this.intervalId = setInterval(() => { + void this.updateWorkspaceClients() + }, POLLING_INTERVAL_MS) + } + + async updateWorkspaceClients (): Promise { + const activeRecords = await this.workspacesInfoCollection.find({ active: true }).toArray() + + for (const record of activeRecords) { + const id: WorkspaceId = { name: record.workspace, productId: record.productId } + + if (this.workspaces.has(toWorkspaceString(id))) { + continue + } + + await this.initWorkspaceClient(id, record) + } + } + + async closeWorkspaceClient (workspaceId: WorkspaceId): Promise { + const workspace = toWorkspaceString(workspaceId) + + this.ctx.info('Closing workspace client: ', { workspace }) + + const timeoutId = this.closeWorkspaceTimeouts.get(workspace) + + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + this.closeWorkspaceTimeouts.delete(workspace) + } + + await this.workspacesInfoCollection.updateOne( + { workspace: workspaceId.name, productId: workspaceId.productId }, + { $set: { active: false } } + ) + + const client = this.workspaces.get(workspace) + + if (client !== undefined) { + await client.close() + this.workspaces.delete(workspace) + } + } + + private async getWorkspaceInfo (ws: WorkspaceId): Promise { + const systemToken = generateToken(systemAccountEmail, ws) + for (let i = 0; i < 5; i++) { + try { + const info = await getWorkspaceInfo(systemToken) + + if (info == null) { + this.ctx.warn('Cannot find workspace info', ws) + await wait(ASSIGN_WORKSPACE_DELAY_MS) + continue + } + + return info + } catch (e) { + this.ctx.error('Error during get workspace info:', { e }) + await wait(ASSIGN_WORKSPACE_DELAY_MS) + } + } + } + + private async assignToWorkspace (ws: WorkspaceId): Promise { + clearTimeout(this.assignTimeout) + try { + const info = await this.getWorkspaceInfo(ws) + + if (info === undefined) { + void this.closeWorkspaceClient(ws) + return + } + + if (info.creating === true) { + this.ctx.info('Workspace is creating -> waiting...', ws) + this.assignTimeout = setTimeout(() => { + void this.assignToWorkspace(ws) + }, ASSIGN_WORKSPACE_DELAY_MS) + return + } + + const result = await assignBotToWorkspace(ws) + this.ctx.info('Assign to workspace result: ', { result, workspace: ws.name }) + } catch (e) { + this.ctx.error('Error during assign workspace:', { e }) + if (this.assignAttempts < MAX_ASSIGN_ATTEMPTS) { + this.assignAttempts++ + this.assignTimeout = setTimeout(() => { + void this.assignToWorkspace(ws) + }, ASSIGN_WORKSPACE_DELAY_MS) + } else { + void this.closeWorkspaceClient(ws) + } + } + } + + async initWorkspaceClient (workspaceId: WorkspaceId, info: WorkspaceInfoRecord): Promise { + const workspace = toWorkspaceString(workspaceId) + + if (!this.workspaces.has(workspace)) { + this.ctx.info('Listen workspace: ', { workspace }) + await this.assignToWorkspace(workspaceId) + const token = generateToken(aiBotAccountEmail, workspaceId) + const endpoint = await getTransactorEndpoint(token) + this.workspaces.set( + workspace, + new WorkspaceClient(endpoint, token, workspaceId, this, this.ctx.newChild(workspace, {}), info) + ) + } + + const timeoutId = this.closeWorkspaceTimeouts.get(workspace) + + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + + const newTimeoutId = setTimeout(() => { + void this.closeWorkspaceClient(workspaceId) + }, CLOSE_INTERVAL_MS) + + this.closeWorkspaceTimeouts.set(workspace, newTimeoutId) + } + + async transfer (event: AIBotTransferEvent): Promise { + const workspaceId = getWorkspaceId(event.toWorkspace) + const info = await this.workspacesInfoCollection + .find({ workspace: workspaceId.name, productId: workspaceId.productId }) + .toArray() + + await this.initWorkspaceClient(workspaceId, info[0]) + + const wsClient = this.workspaces.get(event.toWorkspace) + + if (wsClient === undefined) { + return + } + + await wsClient.transfer(event) + } + + async close (): Promise { + clearInterval(this.intervalId) + for (const workspace of this.workspaces.values()) { + await workspace.close() + } + for (const timeoutId of this.closeWorkspaceTimeouts.values()) { + clearTimeout(timeoutId) + } + this.workspaces.clear() + } + + async updateAvatarInfo (workspace: WorkspaceId, path: string, lastModified: number): Promise { + await this.workspacesInfoCollection.updateOne( + { workspace: workspace.name, productId: workspace.productId }, + { $set: { avatarPath: path, avatarLastModified: lastModified } } + ) + } +} + +async function wait (delay: number): Promise { + await new Promise((resolve) => { + setTimeout(() => { + resolve() + }, delay) + }) +} diff --git a/services/ai-bot/pod-ai-bot/src/index.ts b/services/ai-bot/pod-ai-bot/src/index.ts new file mode 100644 index 0000000000..d3533f53b3 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/index.ts @@ -0,0 +1,20 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { config } from 'dotenv' + +import { start } from './start' + +config() +void start() diff --git a/services/ai-bot/pod-ai-bot/src/platform.ts b/services/ai-bot/pod-ai-bot/src/platform.ts new file mode 100644 index 0000000000..8ba2283aea --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/platform.ts @@ -0,0 +1,7 @@ +import { Client } from '@hcengineering/core' +import { createClient, getTransactorEndpoint } from '@hcengineering/server-client' + +export async function connectPlatform (token: string): Promise { + const endpoint = await getTransactorEndpoint(token) + return await createClient(endpoint, token) +} diff --git a/services/ai-bot/pod-ai-bot/src/start.ts b/services/ai-bot/pod-ai-bot/src/start.ts new file mode 100644 index 0000000000..b006e26448 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/start.ts @@ -0,0 +1,63 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { setMetadata } from '@hcengineering/platform' +import serverToken from '@hcengineering/server-token' +import serverAiBot from '@hcengineering/server-ai-bot' +import { MeasureMetricsContext } from '@hcengineering/core' +import serverClient from '@hcengineering/server-client' + +import config from './config' +import { closeDB, getDB } from './storage' +import { AIBotController } from './controller' +import { createBotAccount } from './account' + +export const start = async (): Promise => { + setMetadata(serverToken.metadata.Secret, config.ServerSecret) + setMetadata(serverAiBot.metadata.SupportWorkspaceId, config.SupportWorkspace) + setMetadata(serverClient.metadata.UserAgent, config.ServiceID) + setMetadata(serverClient.metadata.Endpoint, config.AccountsURL) + + const ctx = new MeasureMetricsContext('ai-bot-service', {}) + + ctx.info('AI Bot Service started', { firstName: config.FirstName, lastName: config.LastName }) + + const db = await getDB(config.MongoURL, config.ConfigurationDB) + for (let i = 0; i < 5; i++) { + ctx.info('Creating bot account', { attempt: i }) + try { + await createBotAccount() + break + } catch (e) { + ctx.error('Error during account creation', { error: e }) + } + await new Promise((resolve) => setTimeout(resolve, 3000)) + } + const aiController = new AIBotController(db, ctx) + + const onClose = (): void => { + void aiController.close() + void closeDB() + } + + process.on('SIGINT', onClose) + process.on('SIGTERM', onClose) + process.on('uncaughtException', (e: Error) => { + console.error(e) + }) + process.on('unhandledRejection', (e: Error) => { + console.error(e) + }) +} diff --git a/services/ai-bot/pod-ai-bot/src/storage.ts b/services/ai-bot/pod-ai-bot/src/storage.ts new file mode 100644 index 0000000000..012646bbbd --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/storage.ts @@ -0,0 +1,31 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Db, MongoClient } from 'mongodb' + +let client: MongoClient | undefined + +export const getDB = async (mongoUrl: string, db: string): Promise => { + client = new MongoClient(mongoUrl) + await client.connect() + + return client.db(db) +} + +export const closeDB: () => Promise = async () => { + if (client !== undefined) { + await client.close() + } +} diff --git a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts new file mode 100644 index 0000000000..2655456748 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts @@ -0,0 +1,444 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core, { + Account, + Class, + Client, + Doc, + MeasureContext, + Ref, + Space, + Tx, + TxCreateDoc, + TxOperations, + TxProcessor, + WorkspaceId, + Blob +} from '@hcengineering/core' +import aiBot, { AIBotEvent, aiBotAccountEmail, AIBotResponseEvent, AIBotTransferEvent } from '@hcengineering/ai-bot' +import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' +import contact, { AvatarType, combineName, getFirstName, getLastName, PersonAccount } from '@hcengineering/contact' +import { generateToken } from '@hcengineering/server-token' +import notification from '@hcengineering/notification' +import { getOrCreateAnalyticsChannel } from '@hcengineering/server-analytics-collector-resources' +import { deepEqual } from 'fast-equals' +import { BlobClient } from '@hcengineering/server-client' +import fs from 'fs' +import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' + +import config from './config' +import { loginBot } from './account' +import { AIBotController } from './controller' +import { connectPlatform } from './platform' + +const MAX_LOGIN_DELAY_MS = 15 * 1000 // 15 ses + +export class WorkspaceClient { + client: Client | undefined + opClient: TxOperations | undefined + + blobClient: BlobClient + + loginTimeout: NodeJS.Timeout | undefined + loginDelayMs = 2 * 1000 + + initializePromise: Promise | undefined = undefined + + aiAccount: PersonAccount | undefined + + constructor ( + readonly transactorUrl: string, + readonly token: string, + readonly workspace: WorkspaceId, + readonly controller: AIBotController, + readonly ctx: MeasureContext, + readonly info: WorkspaceInfoRecord | undefined + ) { + this.blobClient = new BlobClient(transactorUrl, token, this.workspace) + this.initializePromise = this.initClient().then(() => { + this.initializePromise = undefined + }) + } + + private async login (): Promise { + this.ctx.info('Logging in: ', this.workspace) + const token = (await loginBot())?.token + + if (token !== undefined) { + return token + } else { + return (await loginBot())?.token + } + } + + private async uploadAvatarFile (client: TxOperations): Promise { + this.ctx.info('Upload avatar file', { workspace: this.workspace.name }) + + try { + await this.checkPersonData(client) + + const stat = fs.statSync(config.AvatarPath) + const lastModified = stat.mtime.getTime() + + if ( + this.info !== undefined && + this.info.avatarPath === config.AvatarPath && + this.info.avatarLastModified === lastModified + ) { + this.ctx.info('Avatar file already uploaded', { workspace: this.workspace.name, path: config.AvatarPath }) + return + } + const data = fs.readFileSync(config.AvatarPath) + + await this.blobClient.upload(this.ctx, config.AvatarName, data.length, config.AvatarContentType, data) + await this.controller.updateAvatarInfo(this.workspace, config.AvatarPath, lastModified) + this.ctx.info('Uploaded avatar file', { workspace: this.workspace.name, path: config.AvatarPath }) + } catch (e) { + this.ctx.error('Failed to upload avatar file', { e }) + } + } + + private async tryLogin (): Promise { + const token = await this.login() + + clearTimeout(this.loginTimeout) + + if (token === undefined) { + this.loginTimeout = setTimeout(() => { + if (this.loginDelayMs < MAX_LOGIN_DELAY_MS) { + this.loginDelayMs += 1000 + } + void this.tryLogin() + + this.ctx.info(`login delay ${this.loginDelayMs} millisecond`) + }, this.loginDelayMs) + } + } + + private async checkPersonData (client: TxOperations): Promise { + const account = await client.findOne(contact.class.PersonAccount, { email: aiBotAccountEmail }) + if (account === undefined) { + this.ctx.error('Cannot find AI PersonAccount', { email: aiBotAccountEmail }) + return + } + this.aiAccount = account + const person = await client.findOne(contact.class.Person, { _id: account.person }) + + if (person === undefined) { + this.ctx.error('Cannot find AI Person ', { _id: account.person }) + return + } + + const firstName = getFirstName(person.name) + const lastName = getLastName(person.name) + + if (lastName !== config.LastName || firstName !== config.FirstName) { + await client.update(person, { + name: combineName(config.FirstName, config.LastName) + }) + } + + if (person.avatar === config.AvatarName) { + return + } + + const exist = await this.blobClient.checkFile(this.ctx, config.AvatarName) + + if (!exist) { + this.ctx.error('Cannot find file', { file: config.AvatarName, workspace: this.workspace.name }) + return + } + + await client.diffUpdate(person, { avatar: config.AvatarName as Ref, avatarType: AvatarType.IMAGE }) + } + + private async initClient (): Promise { + await this.tryLogin() + + const token = generateToken(aiBotAccountEmail, this.workspace) + this.client = await connectPlatform(token) + + if (this.client === undefined) { + this.ctx.error('Cannot connect to platform', this.workspace) + return + } + this.opClient = new TxOperations(this.client, aiBot.account.AIBot) + void this.opClient.findAll(aiBot.class.AIBotEvent, {}).then((res) => { + void this.processEvents(res) + }) + + this.client.notify = (...txes: Tx[]) => { + void this.txHandler(txes) + } + + await this.uploadAvatarFile(this.opClient) + this.ctx.info('Initialized workspace', this.workspace) + } + + async getThreadParent ( + client: TxOperations, + event: AIBotTransferEvent, + _id: Ref, + _class: Ref> + ): Promise { + const parent = await client.findOne(chunter.class.ChatMessage, { + attachedTo: _id, + attachedToClass: _class, + [aiBot.mixin.TransferredMessage]: { + messageId: event.parentMessageId, + parentMessageId: undefined + } + }) + + if (parent !== undefined) { + return parent + } + + return await client.findOne(chunter.class.ChatMessage, { + _id: event.parentMessageId + }) + } + + async createTransferMessage ( + client: TxOperations, + event: AIBotTransferEvent, + _id: Ref, + _class: Ref>, + space: Ref, + message: string + ): Promise { + if (event.messageClass === chunter.class.ChatMessage) { + const ref = await client.addCollection( + chunter.class.ChatMessage, + space, + _id, + _class, + event.collection, + { message } + ) + await client.createMixin(ref, chunter.class.ChatMessage, space, aiBot.mixin.TransferredMessage, { + messageId: event.messageId, + parentMessageId: event.parentMessageId + }) + } else if (event.messageClass === chunter.class.ThreadMessage && event.parentMessageId !== undefined) { + const parent = await this.getThreadParent(client, event, _id, _class) + + if (parent !== undefined) { + const ref = await client.addCollection( + chunter.class.ThreadMessage, + parent.space, + parent._id, + parent._class, + event.collection, + { message, objectId: parent.attachedTo, objectClass: parent.attachedToClass } + ) + await client.createMixin( + ref, + chunter.class.ThreadMessage as Ref>, + space, + aiBot.mixin.TransferredMessage, + { + messageId: event.messageId, + parentMessageId: event.parentMessageId + } + ) + } + } + } + + async processResponseEvent (event: AIBotResponseEvent): Promise { + if (this.opClient === undefined) { + return + } + + if (event.messageClass === chunter.class.ChatMessage) { + await this.opClient.addCollection( + chunter.class.ChatMessage, + event.objectSpace, + event.objectId, + event.objectClass, + event.collection, + { message: 'You said: ' + event.message } + ) + } else if (event.messageClass === chunter.class.ThreadMessage) { + const parent = await this.opClient.findOne(chunter.class.ChatMessage, { + _id: event.objectId as Ref + }) + + if (parent !== undefined) { + await this.opClient.addCollection( + chunter.class.ThreadMessage, + event.objectSpace, + event.objectId, + event.objectClass, + event.collection, + { message: 'You said: ' + event.message, objectId: parent.attachedTo, objectClass: parent.attachedToClass } + ) + } + } + + await this.opClient.remove(event) + } + + async processTransferEvent (event: AIBotTransferEvent): Promise { + if (this.opClient === undefined) { + return + } + + await this.controller.transfer(event) + await this.opClient.remove(event) + } + + async getAccount (email: string): Promise { + if (this.opClient === undefined) { + return + } + + return await this.opClient.findOne(contact.class.PersonAccount, { email }) + } + + async getDirect (_id: Ref): Promise | undefined> { + if (this.opClient === undefined) { + return + } + + const personAccount = await this.opClient.findOne(contact.class.PersonAccount, { _id: _id as Ref }) + + if (personAccount === undefined) { + return + } + + const allAccounts = await this.opClient.findAll(contact.class.PersonAccount, { person: personAccount.person }) + const accIds: Ref[] = [aiBot.account.AIBot, ...allAccounts.map(({ _id }) => _id)].sort() + const existingDms = await this.opClient.findAll(chunter.class.DirectMessage, {}) + + for (const dm of existingDms) { + if (deepEqual(dm.members.sort(), accIds)) { + return dm._id + } + } + + const id = await this.opClient.createDoc(chunter.class.DirectMessage, core.space.Space, { + name: '', + description: '', + private: true, + archived: false, + members: accIds + }) + + if (this.aiAccount === undefined) return id + const space = await this.opClient.findOne(contact.class.PersonSpace, { person: this.aiAccount.person }) + if (space === undefined) return id + await this.opClient.createDoc(notification.class.DocNotifyContext, space._id, { + user: aiBot.account.AIBot, + objectId: id, + objectClass: chunter.class.DirectMessage, + objectSpace: core.space.Space, + isPinned: false + }) + + return id + } + + async transferToSupport (event: AIBotTransferEvent): Promise { + if (this.opClient === undefined) { + return + } + + const channel = await getOrCreateAnalyticsChannel(this.opClient, event.toEmail, event.fromWorkspace) + + if (channel === undefined) { + return + } + + await this.createTransferMessage(this.opClient, event, channel, chunter.class.Channel, channel, event.message) + } + + async transferToUserDirect (event: AIBotTransferEvent): Promise { + if (this.opClient === undefined) { + return + } + + const account = await this.getAccount(event.toEmail) + + if (account === undefined) { + return + } + + const direct = await this.getDirect(account._id) + + if (direct === undefined) { + return + } + + await this.createTransferMessage(this.opClient, event, direct, chunter.class.DirectMessage, direct, event.message) + } + + async transfer (event: AIBotTransferEvent): Promise { + if (this.initializePromise instanceof Promise) { + await this.initializePromise + } + + if (event.toWorkspace === config.SupportWorkspace) { + await this.transferToSupport(event) + } else { + await this.transferToUserDirect(event) + } + } + + async processEvents (events: AIBotEvent[]): Promise { + if (events.length === 0 || this.opClient === undefined) { + return + } + + for (const event of events) { + try { + if (event._class === aiBot.class.AIBotResponseEvent) { + void this.processResponseEvent(event as AIBotResponseEvent) + } else if (event._class === aiBot.class.AIBotTransferEvent) { + void this.processTransferEvent(event as AIBotTransferEvent) + } + } catch (e) { + this.ctx.error('Error processing event: ', { e }) + } + } + } + + async close (): Promise { + clearTimeout(this.loginTimeout) + await this.client?.close() + await this.opClient?.close() + + this.ctx.info('Closed workspace client: ', this.workspace) + } + + private async txHandler (txes: Tx[]): Promise { + if (this.opClient === undefined) { + return + } + + const hierarchy = this.opClient.getHierarchy() + + const resultTxes = txes + .map((a) => TxProcessor.extractTx(a) as TxCreateDoc) + .filter( + (tx) => tx._class === core.class.TxCreateDoc && hierarchy.isDerived(tx.objectClass, aiBot.class.AIBotEvent) + ) + .map((tx) => TxProcessor.createDoc2Doc(tx)) + + await this.processEvents(resultTxes) + } +} diff --git a/services/ai-bot/pod-ai-bot/tsconfig.json b/services/ai-bot/pod-ai-bot/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file