diff --git a/.vscode/launch.json b/.vscode/launch.json index 2e594596b0..025c155b01 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -244,7 +244,7 @@ "MINIO_ACCESS_KEY": "minioadmin", "MINIO_SECRET_KEY": "minioadmin", "FRONT_URL": "http://localhost:8080", - "PORT": "3500", + "PORT": "3500" }, "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "sourceMaps": true, @@ -299,7 +299,7 @@ "MONGO_URL": "mongodb://localhost:27017", "MINIO_ENDPOINT": "localhost", "MINIO_ACCESS_KEY": "minioadmin", - "MINIO_SECRET_KEY": "minioadmin", + "MINIO_SECRET_KEY": "minioadmin" }, "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "runtimeVersion": "20", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 22f3cf22f5..02e86721f6 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -620,6 +620,9 @@ dependencies: '@rush-temp/pod-telegram': specifier: file:./projects/pod-telegram.tgz version: file:projects/pod-telegram.tgz(bufferutil@4.0.8)(ts-node@10.9.2)(utf-8-validate@6.0.4) + '@rush-temp/pod-telegram-bot': + specifier: file:./projects/pod-telegram-bot.tgz + version: file:projects/pod-telegram-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4) '@rush-temp/preference': specifier: file:./projects/preference.tgz version: file:projects/preference.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) @@ -1100,6 +1103,9 @@ dependencies: '@storybook/testing-library': specifier: ^0.0.14-next.2 version: 0.0.14-next.2 + '@telegraf/entity': + specifier: ^0.5.0 + version: 0.5.0 '@tiptap/core': specifier: ^2.2.4 version: 2.2.4(@tiptap/pm@2.2.4) @@ -1307,9 +1313,6 @@ dependencies: '@types/web-push': specifier: ~3.6.3 version: 3.6.3 - '@types/ws': - specifier: ^8.5.11 - version: 8.5.11 '@typescript-eslint/eslint-plugin': specifier: ^6.11.0 version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) @@ -1508,9 +1511,6 @@ dependencies: eslint-plugin-svelte: specifier: ^2.35.1 version: 2.35.1(eslint@8.56.0)(svelte@4.2.12)(ts-node@10.9.2) - express: - specifier: ^4.19.2 - version: 4.19.2 express-fileupload: specifier: ^1.5.1 version: 1.5.1 @@ -1778,6 +1778,9 @@ dependencies: tar-stream: specifier: ^2.2.0 version: 2.2.0 + telegraf: + specifier: ^4.16.3 + version: 4.16.3 telegram: specifier: 2.22.2 version: 2.22.2 @@ -1835,9 +1838,6 @@ dependencies: winston-daily-rotate-file: specifier: ^5.0.0 version: 5.0.0(winston@3.13.1) - ws: - specifier: ^8.18.0 - version: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) y-prosemirror: specifier: ^1.2.1 version: 1.2.2(prosemirror-model@1.19.4)(y-protocols@1.0.6)(yjs@13.6.12) @@ -2688,7 +2688,7 @@ packages: '@babel/core': 7.23.9 '@babel/helper-compilation-targets': 7.23.6 '@babel/helper-plugin-utils': 7.22.5 - debug: 4.3.4 + debug: 4.3.5 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -8283,6 +8283,16 @@ packages: defer-to-connect: 2.0.1 dev: false + /@telegraf/entity@0.5.0: + resolution: {integrity: sha512-4oHOoXcrNaK44zPq4GuTgMmUvCSQxJRVAuPFzVtSeiKzCBJnLeYblsMqWotokhrZSDnNpunC1sxhqI3iVYa/sg==} + dependencies: + '@telegraf/types': 7.1.0 + dev: false + + /@telegraf/types@7.1.0: + resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==} + dev: false + /@testing-library/dom@8.20.1: resolution: {integrity: sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==} engines: {node: '>=12'} @@ -10175,6 +10185,13 @@ packages: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: false + /abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: false + /accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -11216,6 +11233,17 @@ packages: resolution: {integrity: sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==} dev: false + /buffer-alloc-unsafe@1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + dev: false + + /buffer-alloc@1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + dev: false + /buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} dev: false @@ -11239,6 +11267,10 @@ packages: engines: {node: '>=0.4'} dev: false + /buffer-fill@1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + dev: false + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: false @@ -14126,6 +14158,11 @@ packages: es5-ext: 0.10.64 dev: false + /event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: false + /eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} dev: false @@ -18870,6 +18907,11 @@ packages: retry: 0.13.1 dev: false + /p-timeout@4.1.0: + resolution: {integrity: sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==} + engines: {node: '>=10'} + dev: false + /p-timeout@5.1.0: resolution: {integrity: sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==} engines: {node: '>=12'} @@ -20513,6 +20555,12 @@ packages: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: false + /safe-compare@1.1.4: + resolution: {integrity: sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==} + dependencies: + buffer-alloc: 1.2.0 + dev: false + /safe-regex-test@1.0.3: resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} @@ -20540,6 +20588,11 @@ packages: rimraf: 2.7.1 dev: false + /sandwich-stream@2.0.2: + resolution: {integrity: sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==} + engines: {node: '>= 0.10'} + dev: false + /sanitize-filename@1.6.3: resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} dependencies: @@ -21848,6 +21901,24 @@ packages: yallist: 4.0.0 dev: false + /telegraf@4.16.3: + resolution: {integrity: sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==} + engines: {node: ^12.20.0 || >=14.13.1} + hasBin: true + dependencies: + '@telegraf/types': 7.1.0 + abort-controller: 3.0.0 + debug: 4.3.5 + mri: 1.2.0 + node-fetch: 2.7.0 + p-timeout: 4.1.0 + safe-compare: 1.1.4 + sandwich-stream: 2.0.2 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /telegram@2.22.2: resolution: {integrity: sha512-9payizc801Aqqu4eTGPc0huxKnIwFe0hn18mrpbgAKKiMLurX/UIQW/fjPhI5ONzockDFsaXDFqTreXBoG1u3A==} dependencies: @@ -27423,7 +27494,7 @@ packages: dev: false file:projects/model-notification.tgz: - resolution: {integrity: sha512-cz+OQrGb1FXp0P2DrgPLtwpwFx3gamnrO71yB3KC429Juu+HrO5oakAI34vsXZyLKC5vddp0WigK4VObJFPv7A==, tarball: file:projects/model-notification.tgz} + resolution: {integrity: sha512-k7valecajKQwOhAeROASPm+RO2/D3xZSkan8Z09kmy+9dNLYBxQT/mr7k+HBdlzU4JD3fDQRP6EStyJBkLFZsQ==, tarball: file:projects/model-notification.tgz} name: '@rush-temp/model-notification' version: 0.0.0 dependencies: @@ -28602,7 +28673,7 @@ packages: dev: false file:projects/notification.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-fdBECYYan/iSrhZ5bICAU35WUBHFdiCD+D5xJTddijqu6PSNj0Q4hqkphWnmg0MXNKfqDg/RaLA0rPZBdklekA==, tarball: file:projects/notification.tgz} + resolution: {integrity: sha512-wk161j1J5wPe+ZzgCc7y5X+0fyWi+NpQJ/nrGgDMd6hOW4K7patIv3iwCF/sY28WD5ygVe1ezi4x0bbv4bQJzw==, tarball: file:projects/notification.tgz} id: file:projects/notification.tgz name: '@rush-temp/notification' version: 0.0.0 @@ -29445,7 +29516,7 @@ packages: dev: false file:projects/pod-server.tgz: - resolution: {integrity: sha512-XTI4qfulSUmxQELlvipQdb6F4i2MG1uBldX7HHP19oBB7fTn85S/qXQols9NOggFWtgIw5ApT2VdQqiJLqmOGQ==, tarball: file:projects/pod-server.tgz} + resolution: {integrity: sha512-91Ac7EN5mpCKBG8sIWUljU+qSb6mtEJNVt9TynP8U0++Bhlmyvcryd4k2rMC5S+M/GvmlikUpAZ+MdIBsqUM+g==, tarball: file:projects/pod-server.tgz} name: '@rush-temp/pod-server' version: 0.0.0 dependencies: @@ -29579,6 +29650,65 @@ packages: - utf-8-validate dev: false + file:projects/pod-telegram-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4): + resolution: {integrity: sha512-T7EdAT4nMEnEDROxVBBAwOD7ssjiSl6eOr96YFwfZvHlbaR6wJm/XYNzwOM61oc0+rjfnmkmM+FzOwTSifePEA==, tarball: file:projects/pod-telegram-bot.tgz} + id: file:projects/pod-telegram-bot.tgz + name: '@rush-temp/pod-telegram-bot' + version: 0.0.0 + dependencies: + '@telegraf/entity': 0.5.0 + '@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/otp-generator': 4.0.2 + '@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 + htmlparser2: 9.1.0 + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + mongodb: 6.8.0 + node-fetch: 2.7.0 + otp-generator: 4.0.1 + prettier: 3.2.5 + telegraf: 4.16.3 + 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-telegram.tgz(bufferutil@4.0.8)(ts-node@10.9.2)(utf-8-validate@6.0.4): resolution: {integrity: sha512-MF+eEeVhFR4XQj2YaAP6gjvm1tijtnRUMDrQt48vZBD8mwQA8B0drOVHCkOQ+NJOYqi1c3pRG/+kPKWqswbplQ==, tarball: file:projects/pod-telegram.tgz} id: file:projects/pod-telegram.tgz @@ -32367,7 +32497,7 @@ packages: dev: false file:projects/server-telegram-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-4Pob/P+5sdtU7LGgb1jJbqVms72MXOpAvOZ+GivgI3j9JvqvbOfHn87O1jVhrWa865xZrdGlV9+T9grap3W3xg==, tarball: file:projects/server-telegram-resources.tgz} + resolution: {integrity: sha512-VsEVxoPM/hyelQa9/rQFzo+vbS8ixxU4RvVWdM0ae9HJuaEyszX4y9GheG/SDMa0NzCtyOOLf9KqL3ohGpkWRg==, tarball: file:projects/server-telegram-resources.tgz} id: file:projects/server-telegram-resources.tgz name: '@rush-temp/server-telegram-resources' version: 0.0.0 @@ -32387,20 +32517,13 @@ packages: 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 diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index a9a8405a4b..384e9ec65f 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -221,6 +221,7 @@ export async function configurePlatform (): Promise { setMetadata(presentation.metadata.FrontVersion, config.VERSION) } setMetadata(telegram.metadata.TelegramURL, config.TELEGRAM_URL ?? 'http://localhost:8086') + setMetadata(telegram.metadata.BotUrl, config.TELEGRAM_BOT_URL) setMetadata(gmail.metadata.GmailURL, config.GMAIL_URL ?? 'http://localhost:8087') setMetadata(calendar.metadata.CalendarServiceURL, config.CALENDAR_URL ?? 'http://localhost:8095') setMetadata(notification.metadata.PushPublicKey, config.PUSH_PUBLIC_KEY) diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index 36abe5d687..9ea6c2cc86 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -30,6 +30,7 @@ export interface Config { PREVIEW_CONFIG: string DESKTOP_UPDATES_URL?: string DESKTOP_UPDATES_CHANNEL?: string + TELEGRAM_BOT_URL?: string } export interface Branding { diff --git a/plugins/telegram-resources/src/components/NotificationProviderPresenter.svelte b/plugins/telegram-resources/src/components/NotificationProviderPresenter.svelte index 3fea750286..0aed0a5045 100644 --- a/plugins/telegram-resources/src/components/NotificationProviderPresenter.svelte +++ b/plugins/telegram-resources/src/components/NotificationProviderPresenter.svelte @@ -28,7 +28,7 @@ let isConnectionEstablished = false let connectionError: Error | undefined - let info: { name: string, username: string } | undefined = undefined + let info: { name: string, username: string, photoUrl: string } | undefined = undefined let isLoading = false const url = getMetadata(telegram.metadata.BotUrl) ?? '' @@ -122,8 +122,11 @@ {:else if info}
- - + {#if info.photoUrl !== ''} + + {:else} + + {/if} {info.name} (@{info.username}) diff --git a/pods/server/package.json b/pods/server/package.json index 9f70800e66..6c6c3dfbfb 100644 --- a/pods/server/package.json +++ b/pods/server/package.json @@ -72,6 +72,8 @@ "@hcengineering/contact": "^0.6.24", "@hcengineering/notification": "^0.6.23", "@hcengineering/server-notification": "^0.6.1", + "@hcengineering/server-telegram": "^0.6.0", + "@hcengineering/pod-telegram-bot": "^0.6.0", "@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 026305cf34..bcae5bcc7f 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 serverTelegram from '@hcengineering/server-telegram' import serverAiBot from '@hcengineering/server-ai-bot' import { join } from 'path' import { start } from '.' @@ -51,6 +52,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(serverTelegram.metadata.BotUrl, process.env.TELEGRAM_BOT_URL) setMetadata(serverAiBot.metadata.SupportWorkspaceId, process.env.SUPPORT_WORKSPACE) const shutdown = start(config.url, { diff --git a/rush.json b/rush.json index 7c72bc0043..9567a97a47 100644 --- a/rush.json +++ b/rush.json @@ -2116,6 +2116,11 @@ "packageName": "@hcengineering/server-ai-bot-resources", "projectFolder": "server-plugins/ai-bot-resources", "shouldPublish": false + }, + { + "packageName": "@hcengineering/pod-telegram-bot", + "projectFolder": "services/telegram-bot/pod-telegram-bot", + "shouldPublish": false } ] } diff --git a/services/telegram-bot/pod-telegram-bot/.eslintrc.js b/services/telegram-bot/pod-telegram-bot/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/services/telegram-bot/pod-telegram-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/telegram-bot/pod-telegram-bot/.npmignore b/services/telegram-bot/pod-telegram-bot/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/services/telegram-bot/pod-telegram-bot/Dockerfile b/services/telegram-bot/pod-telegram-bot/Dockerfile new file mode 100644 index 0000000000..cc07270704 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine + +WORKDIR /usr/src/app + +COPY bundle/bundle.js ./ + +EXPOSE 4020 +CMD [ "node", "bundle.js" ] diff --git a/services/telegram-bot/pod-telegram-bot/config/rig.json b/services/telegram-bot/pod-telegram-bot/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/services/telegram-bot/pod-telegram-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/telegram-bot/pod-telegram-bot/jest.config.js b/services/telegram-bot/pod-telegram-bot/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/services/telegram-bot/pod-telegram-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/telegram-bot/pod-telegram-bot/package.json b/services/telegram-bot/pod-telegram-bot/package.json new file mode 100644 index 0000000000..2f3858f397 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/package.json @@ -0,0 +1,84 @@ +{ + "name": "@hcengineering/pod-telegram-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/telegram-bot", + "docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/telegram-bot staging", + "docker:push": "../../../common/scripts/docker_tag.sh hardcoreeng/telegram-bot", + "run-local": "cross-env 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/otp-generator": "^4.0.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/activity": "^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-client": "^0.6.0", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/server-token": "^0.6.11", + "@hcengineering/setting": "^0.6.17", + "@hcengineering/telegram": "^0.6.21", + "@hcengineering/telegram-assets": "^0.6.0", + "@hcengineering/text": "^0.6.5", + "@telegraf/entity": "^0.5.0", + "cors": "^2.8.5", + "dotenv": "~16.0.0", + "express": "^4.19.2", + "htmlparser2": "^9.0.0", + "mongodb": "^6.8.0", + "node-fetch": "^2.6.6", + "otp-generator": "^4.0.1", + "telegraf": "^4.16.3", + "ws": "^8.18.0" + } +} diff --git a/services/telegram-bot/pod-telegram-bot/src/bot.ts b/services/telegram-bot/pod-telegram-bot/src/bot.ts new file mode 100644 index 0000000000..34daefadd1 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/bot.ts @@ -0,0 +1,148 @@ +// +// 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 { Context, Telegraf, NarrowedContext } from 'telegraf' +import { Update, Message } from 'telegraf/typings/core/types/typegram' +import { translate } from '@hcengineering/platform' +import telegram from '@hcengineering/telegram' +import { htmlToMarkup } from '@hcengineering/text' +import { message } from 'telegraf/filters' +import { toHTML } from '@telegraf/entity' + +import config from './config' +import { PlatformWorker } from './worker' +import { getBotCommands, getCommandsHelp } from './utils' + +async function onStart (ctx: Context, worker: PlatformWorker): Promise { + const id = ctx.from?.id + const lang = ctx.from?.language_code ?? 'en' + const record = id !== undefined ? await worker.getUserRecord(id) : undefined + + const commandsHelp = await getCommandsHelp(lang) + const welcomeMessage = await translate(telegram.string.WelcomeMessage, { app: config.App }, lang) + + if (record !== undefined) { + const connectedMessage = await translate( + telegram.string.ConnectedDescriptionHtml, + { email: record.email, app: config.App }, + lang + ) + const message = welcomeMessage + '\n\n' + commandsHelp + '\n\n' + connectedMessage + + await ctx.replyWithHTML(message) + } else { + const connectMessage = await translate(telegram.string.ConnectMessage, { app: config.App }, lang) + const message = welcomeMessage + '\n\n' + commandsHelp + '\n\n' + connectMessage + + await ctx.reply(message) + } +} + +async function onHelp (ctx: Context): Promise { + const lang = ctx.from?.language_code ?? 'en' + const commandsHelp = await getCommandsHelp(lang) + + await ctx.reply(commandsHelp) +} + +async function onStop (ctx: Context, worker: PlatformWorker): Promise { + if (ctx.from?.id !== undefined) { + await worker.removeUserByTelegramId(ctx.from?.id) + } + const lang = ctx.from?.language_code ?? 'en' + const message = await translate(telegram.string.StopMessage, { app: config.App }, lang) + + await ctx.reply(message) +} + +async function onConnect (ctx: Context, worker: PlatformWorker): Promise { + const id = ctx.from?.id + const lang = ctx.from?.language_code ?? 'en' + + if (id === undefined) { + return + } + + const account = await worker.getUserRecord(id) + + if (account !== undefined) { + const reply = await translate( + telegram.string.AccountAlreadyConnectedHtml, + { email: account.email, app: config.App }, + lang + ) + await ctx.replyWithHTML(reply) + return + } + + const code = await worker.generateCode(id) + await ctx.reply(`*${code}*`, { parse_mode: 'MarkdownV2' }) +} + +type TextMessage = Record<'text', any> & Message.TextMessage + +async function onReply ( + ctx: NarrowedContext, Update.MessageUpdate>, + worker: PlatformWorker +): Promise { + const id = ctx.chat?.id + const message = ctx.message + + if (id === undefined || message.reply_to_message === undefined) { + return + } + + const replyTo = message.reply_to_message + const userRecord = await worker.getUserRecord(id) + + if (userRecord === undefined) { + return + } + + const notification = await worker.getNotificationRecord(replyTo.message_id, userRecord.email) + + if (notification === undefined) { + return + } + + const isReplied = await worker.reply(notification, htmlToMarkup(toHTML(message))) + if (isReplied) { + await ctx.react('👍') + } +} + +export async function setUpBot (worker: PlatformWorker): Promise { + const bot = new Telegraf(config.BotToken) + + await bot.telegram.setMyCommands(await getBotCommands()) + + bot.start((ctx) => onStart(ctx, worker)) + bot.help(onHelp) + + bot.command('stop', (ctx) => onStop(ctx, worker)) + bot.command('connect', (ctx) => onConnect(ctx, worker)) + + bot.on(message('text'), async (ctx) => { + await onReply(ctx, worker) + }) + + const description = await translate(telegram.string.BotDescription, { app: config.App }) + const shortDescription = await translate(telegram.string.BotShortDescription, { app: config.App }) + + await bot.telegram.setMyDescription(description, 'en') + await bot.telegram.setMyShortDescription(shortDescription, 'en') + + return bot +} diff --git a/services/telegram-bot/pod-telegram-bot/src/config.ts b/services/telegram-bot/pod-telegram-bot/src/config.ts new file mode 100644 index 0000000000..412c9d8231 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/config.ts @@ -0,0 +1,61 @@ +// +// 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. +// + +export interface Config { + Port: number + BotToken: string + FrontUrl: string + MongoURL: string + MongoDB: string + ServiceId: string + Secret: string + Domain: string + BotPort: number + App: string + OtpTimeToLiveSec: number + OtpRetryDelaySec: number + AccountsUrl: string +} + +const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) + +const config: Config = (() => { + const params: Partial = { + Port: parseNumber(process.env.PORT) ?? 4020, + BotToken: process.env.BOT_TOKEN, + FrontUrl: process.env.FRONT_URL, + MongoURL: process.env.MONGO_URL, + MongoDB: process.env.MONGO_DB, + AccountsUrl: process.env.ACCOUNTS_URL, + ServiceId: process.env.SERVICE_ID, + Secret: process.env.SECRET, + Domain: process.env.DOMAIN, + BotPort: parseNumber(process.env.BOT_PORT) ?? 8443, + // TODO: later we should get this title from branding map + App: process.env.APP ?? 'Huly', + OtpTimeToLiveSec: parseNumber(process.env.OTP_TIME_TO_LIVE_SEC) ?? 60, + OtpRetryDelaySec: parseNumber(process.env.OTP_RETRY_DELAY_SEC) ?? 60 + } + + const missingEnv = (Object.keys(params) as Array).filter((key) => params[key] === undefined) + + if (missingEnv.length > 0) { + throw Error(`Missing config for attributes: ${missingEnv.join(', ')}`) + } + + return params as Config +})() + +export default config diff --git a/services/telegram-bot/pod-telegram-bot/src/error.ts b/services/telegram-bot/pod-telegram-bot/src/error.ts new file mode 100644 index 0000000000..74f470ab06 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/error.ts @@ -0,0 +1,39 @@ +// +// 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. +// + +const generateMessage = (code: number): string => { + if (code === 401) { + return 'Unauthorized' + } + + if (code === 404) { + return 'Not Found' + } + + if (code === 400) { + return 'Bad Request' + } + + return 'Error' +} + +export class ApiError extends Error { + readonly code: number + + constructor (code: number, message?: string) { + super(message ?? generateMessage(code)) + this.code = code + } +} diff --git a/services/telegram-bot/pod-telegram-bot/src/index.ts b/services/telegram-bot/pod-telegram-bot/src/index.ts new file mode 100644 index 0000000000..223a40a7f5 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/index.ts @@ -0,0 +1,21 @@ +// +// 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/telegram-bot/pod-telegram-bot/src/limiter.ts b/services/telegram-bot/pod-telegram-bot/src/limiter.ts new file mode 100644 index 0000000000..ad48077e52 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/limiter.ts @@ -0,0 +1,89 @@ +// +// 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 { Timestamp } from '@hcengineering/core' + +export class Limiter { + // Bot should not send more than 30 messages per second, but we will limit it to 25 just to be safe + maxMsgPerTime = 25 + timeLimit = 1000 + + sentMsg = 0 + clearTimeLimitOn: Timestamp = Date.now() + + // Bot should not send more than 20 messages per minute to the same chat + chatTimeLimit = 60 * 1000 + maxMsgPerChat = 20 + + clearChatLimitOn: Timestamp = Date.now() + sentMsgByChat = new Map() + + constructor () { + setInterval(() => { + this.sentMsg = 0 + this.clearTimeLimitOn = Date.now() + this.timeLimit + }, this.timeLimit) + + setInterval(() => { + this.sentMsgByChat.clear() + this.clearChatLimitOn = Date.now() + this.chatTimeLimit + }, this.chatTimeLimit) + } + + async exec(op: () => Promise): Promise { + while (this.sentMsg >= this.maxMsgPerTime) { + await new Promise((resolve) => setTimeout(resolve, Math.max(Date.now() - this.clearTimeLimitOn), 10)) + } + + this.sentMsg++ + + try { + await op() + } catch (e) { + console.error(e) + } + } + + async add(telegramId: number, op: () => Promise): Promise { + await this.updateChatLimits(telegramId) + + if (this.sentMsg >= this.maxMsgPerTime) { + await new Promise((resolve) => setTimeout(resolve, this.maxMsgPerTime)) + } + + void this.exec(op) + } + + async updateChatLimits (telegramId: number): Promise { + let counts = this.sentMsgByChat.get(telegramId) ?? 0 + + while (counts >= this.maxMsgPerChat) { + if (counts >= this.maxMsgPerChat) { + await this.waitChatLimits() + } + + counts = this.sentMsgByChat.get(telegramId) ?? 0 + } + + this.sentMsgByChat.set(telegramId, counts + 1) + } + + async waitChatLimits (): Promise { + const now = Date.now() + const diff = Math.max(this.clearChatLimitOn - now, 0) + + await new Promise((resolve) => setTimeout(resolve, diff)) + } +} diff --git a/services/telegram-bot/pod-telegram-bot/src/loaders.ts b/services/telegram-bot/pod-telegram-bot/src/loaders.ts new file mode 100644 index 0000000000..368e76bc11 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/loaders.ts @@ -0,0 +1,27 @@ +// +// 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 { telegramId } from '@hcengineering/telegram' +import { addStringsLoader, platformId } from '@hcengineering/platform' +import { coreId } from '@hcengineering/core' +import coreEng from '@hcengineering/core/lang/en.json' +import platformEng from '@hcengineering/platform/lang/en.json' +import telegramEng from '@hcengineering/telegram-assets/lang/en.json' + +export function registerLoaders (): void { + addStringsLoader(coreId, async (lang: string) => coreEng) + addStringsLoader(platformId, async (lang: string) => platformEng) + addStringsLoader(telegramId, async (lang: string) => telegramEng) +} diff --git a/services/telegram-bot/pod-telegram-bot/src/server.ts b/services/telegram-bot/pod-telegram-bot/src/server.ts new file mode 100644 index 0000000000..66fe435471 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/server.ts @@ -0,0 +1,224 @@ +// +// 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 { Token, decodeToken } from '@hcengineering/server-token' +import cors from 'cors' +import express, { type Express, type NextFunction, type Request, type Response } from 'express' +import { IncomingHttpHeaders, type Server } from 'http' +import { MeasureContext } from '@hcengineering/core' +import { Telegraf } from 'telegraf' +import telegram, { TelegramNotificationRecord } from '@hcengineering/telegram' +import { translate } from '@hcengineering/platform' + +import { ApiError } from './error' +import { PlatformWorker } from './worker' +import { Limiter } from './limiter' +import config from './config' +import { toTelegramHtml } from './utils' + +const extractCookieToken = (cookie?: string): Token | null => { + if (cookie === undefined || cookie === null) { + return null + } + + const cookies = cookie.split(';') + const tokenCookie = cookies.find((cookie) => cookie.toLocaleLowerCase().includes('token')) + if (tokenCookie === undefined) { + return null + } + + const encodedToken = tokenCookie.split('=')[1] + if (encodedToken === undefined) { + return null + } + + return decodeToken(encodedToken) +} + +const extractAuthorizationToken = (authorization?: string): Token | null => { + if (authorization === undefined || authorization === null) { + return null + } + const encodedToken = authorization.split(' ')[1] + + if (encodedToken === undefined) { + return null + } + + return decodeToken(encodedToken) +} + +const extractToken = (headers: IncomingHttpHeaders): Token => { + try { + const token = extractCookieToken(headers.cookie) ?? extractAuthorizationToken(headers.authorization) + + if (token === null) { + throw new ApiError(401) + } + + return token + } catch { + throw new ApiError(401) + } +} + +type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise + +const handleRequest = async ( + fn: AsyncRequestHandler, + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const token = extractToken(req.headers) + await fn(req, res, token, next) + } catch (err: unknown) { + next(err) + } +} + +const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, next: NextFunction) => { + void handleRequest(fn, req, res, next) +} + +export function createServer (bot: Telegraf, worker: PlatformWorker): Express { + const limiter = new Limiter() + const app = express() + + app.use(cors()) + app.use(express.json()) + + app.post( + '/test', + wrapRequest(async (_, res, token) => { + const record = await worker.getUserRecordByEmail(token.email) + if (record === undefined) { + throw new ApiError(404) + } + + await limiter.add(record.telegramId, async () => { + const testMessage = await translate(telegram.string.TestMessage, { app: config.App }) + await bot.telegram.sendMessage(record.telegramId, testMessage) + }) + + res.status(200) + res.json({}) + }) + ) + + app.post( + '/auth', + wrapRequest(async (req, res, token) => { + if (req.body == null || typeof req.body !== 'object') { + throw new ApiError(400) + } + + const { code } = req.body + + if (code == null || code === '' || typeof code !== 'string') { + throw new ApiError(400) + } + + const record = await worker.getUserRecordByEmail(token.email) + + if (record !== undefined) { + throw new ApiError(409, 'User already authorized') + } + + const newRecord = await worker.authorizeUser(code, token.email) + + if (newRecord === undefined) { + throw new ApiError(500) + } + + void limiter.add(newRecord.telegramId, async () => { + const message = await translate(telegram.string.AccountConnectedHtml, { app: config.App, email: token.email }) + await bot.telegram.sendMessage(newRecord.telegramId, message, { parse_mode: 'HTML' }) + }) + + res.status(200) + res.json({}) + }) + ) + + app.get( + '/info', + wrapRequest(async (_, res, token) => { + const me = await bot.telegram.getMe() + const profilePhotos = await bot.telegram.getUserProfilePhotos(me.id) + const photoId = profilePhotos.photos[0]?.[0]?.file_id + + let photoUrl = '' + + if (photoId !== undefined) { + photoUrl = (await bot.telegram.getFileLink(photoId)).toString() + } + res.status(200) + res.json({ username: me.username, name: me.first_name, photoUrl }) + }) + ) + + app.post( + '/notify', + wrapRequest(async (req, res, token) => { + if (req.body == null || !Array.isArray(req.body)) { + throw new ApiError(400) + } + const notificationRecords = req.body as TelegramNotificationRecord[] + const usersRecords = await worker.getUsersRecords() + + for (const notificationRecord of notificationRecords) { + const userRecord = usersRecords.find((record) => record.email === token.email) + if (userRecord !== undefined) { + void limiter.add(userRecord.telegramId, async () => { + const formattedMessage = toTelegramHtml(notificationRecord) + const message = await bot.telegram.sendMessage(userRecord.telegramId, formattedMessage, { + parse_mode: 'HTML' + }) + await worker.addNotificationRecord({ + notificationId: notificationRecord.notificationId, + email: userRecord.email, + workspace: notificationRecord.workspace, + telegramId: message.message_id + }) + }) + } + } + + res.status(200) + res.json({}) + }) + ) + + app.use((err: any, _req: any, res: any, _next: any) => { + if (err instanceof ApiError) { + res.status(err.code).send({ code: err.code, message: err.message }) + return + } + + res.status(500).send(err.message?.length > 0 ? { message: err.message } : err) + }) + + return app +} + +export function listen (e: Express, ctx: MeasureContext, port: number, host?: string): Server { + const cb = (): void => { + ctx.info(`Telegram bot service has been started at ${host ?? '*'}:${port}`) + } + + return host !== undefined ? e.listen(port, host, cb) : e.listen(port, cb) +} diff --git a/services/telegram-bot/pod-telegram-bot/src/start.ts b/services/telegram-bot/pod-telegram-bot/src/start.ts new file mode 100644 index 0000000000..5c06d70a2e --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/start.ts @@ -0,0 +1,72 @@ +// +// 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 { MeasureMetricsContext } from '@hcengineering/core' +import { setMetadata } from '@hcengineering/platform' +import serverToken from '@hcengineering/server-token' +import serverClient from '@hcengineering/server-client' + +import config from './config' +import { createServer, listen } from './server' +import { setUpBot } from './bot' +import { PlatformWorker } from './worker' +import { registerLoaders } from './loaders' + +export const start = async (): Promise => { + setMetadata(serverToken.metadata.Secret, config.Secret) + setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl) + setMetadata(serverClient.metadata.UserAgent, config.ServiceId) + registerLoaders() + + const ctx = new MeasureMetricsContext('telegram-bot', {}) + + const worker = await PlatformWorker.create() + const bot = await setUpBot(worker) + const app = createServer(bot, worker) + + void bot.launch({ webhook: { domain: config.Domain, port: config.BotPort } }).then(() => { + void bot.telegram.getWebhookInfo().then((info) => { + ctx.info('Webhook info', info) + }) + }) + app.get(`/telegraf/${bot.secretPathComponent()}`, (req, res) => { + res.status(200).send() + }) + app.post(`/telegraf/${bot.secretPathComponent()}`, (req, res) => { + void bot.handleUpdate(req.body, res) + res.status(200).send() + }) + + const server = listen(app, ctx, config.Port) + + const onClose = (): void => { + server.close(() => process.exit()) + } + + process.once('SIGINT', () => { + bot.stop('SIGINT') + onClose() + }) + process.once('SIGTERM', () => { + bot.stop('SIGTERM') + onClose() + }) + process.on('uncaughtException', (e: Error) => { + console.error(e) + }) + process.on('unhandledRejection', (e: Error) => { + console.error(e) + }) +} diff --git a/services/telegram-bot/pod-telegram-bot/src/storage.ts b/services/telegram-bot/pod-telegram-bot/src/storage.ts new file mode 100644 index 0000000000..f724b79820 --- /dev/null +++ b/services/telegram-bot/pod-telegram-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 { MongoClient } from 'mongodb' + +import config from './config' + +export const getDB = (() => { + let client: MongoClient | undefined + + return async () => { + if (client === undefined) { + client = new MongoClient(config.MongoURL) + await client.connect() + } + + return client.db(config.MongoDB) + } +})() diff --git a/services/telegram-bot/pod-telegram-bot/src/types.ts b/services/telegram-bot/pod-telegram-bot/src/types.ts new file mode 100644 index 0000000000..c50f5e42c6 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/types.ts @@ -0,0 +1,36 @@ +// +// 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 { Ref, Timestamp } from '@hcengineering/core' +import { InboxNotification } from '@hcengineering/notification' + +export interface UserRecord { + telegramId: number + email: string +} + +export interface NotificationRecord { + notificationId: Ref + workspace: string + email: string + telegramId: number +} + +export interface OtpRecord { + telegramId: number + code: string + expires: Timestamp + createdOn: Timestamp +} diff --git a/services/telegram-bot/pod-telegram-bot/src/utils.ts b/services/telegram-bot/pod-telegram-bot/src/utils.ts new file mode 100644 index 0000000000..83945e992d --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/utils.ts @@ -0,0 +1,179 @@ +// +// 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 { Collection } from 'mongodb' +import otpGenerator from 'otp-generator' +import { BotCommand } from 'telegraf/typings/core/types/typegram' +import { translate } from '@hcengineering/platform' +import telegram, { TelegramNotificationRecord } from '@hcengineering/telegram' +import { Parser } from 'htmlparser2' + +import { OtpRecord } from './types' +import config from './config' + +export async function getNewOtp (otpCollection: Collection): Promise { + let otp = otpGenerator.generate(6, { + upperCaseAlphabets: false, + lowerCaseAlphabets: false, + specialChars: false + }) + + let exist = await otpCollection.findOne({ otp }) + + while (exist != null) { + otp = otpGenerator.generate(6, { + lowerCaseAlphabets: false + }) + exist = await otpCollection.findOne({ otp }) + } + + return otp +} + +export async function getBotCommands (lang: string = 'en'): Promise { + return [ + { + command: 'start', + description: await translate(telegram.string.StartBot, { app: config.App }, lang) + }, + { + command: 'connect', + description: await translate(telegram.string.ConnectAccount, { app: config.App }, lang) + }, + { + command: 'help', + description: await translate(telegram.string.ShowCommandsDetails, { app: config.App }, lang) + }, + { + command: 'stop', + description: await translate(telegram.string.TurnNotificationsOff, { app: config.App }, lang) + } + ] +} + +export async function getCommandsHelp (lang: string): Promise { + const myCommands = await getBotCommands(lang) + return myCommands.map(({ command, description }) => `/${command} - ${description}`).join('\n') +} + +const maxTitleLength = 300 +const maxQuoteLength = 500 +const maxBodyLength = 2000 +const maxSenderLength = 100 + +export function toTelegramHtml (record: TelegramNotificationRecord): string { + const title = record.title !== '' ? `${platformToTelegram(record.title, maxTitleLength)}` + '\n' : '' + const quote = + record.quote !== undefined && record.quote !== '' + ? `
${platformToTelegram(record.quote, maxQuoteLength)}
` + '\n' + : '' + const body = platformToTelegram(record.body, maxBodyLength) + const sender = `— ${record.sender.slice(0, maxSenderLength)}` + + return title + quote + body + '\n' + sender +} + +const supportedTags = ['strong', 'em', 's', 'blockquote', 'code', 'a'] + +export function platformToTelegram (message: string, limit: number): string { + let textLength = 0 + let newMessage = '' + const openedTags = new Map< + string, + { + count: number + } + >() + + const parser = new Parser({ + onopentag: (tag, attrs) => { + if (tag === 'br' || tag === 'p') { + return + } + + if (textLength >= limit) { + return + } + + // Just skip unsupported tag + if (!supportedTags.includes(tag)) { + return + } + + const existingTag = openedTags.get(tag) + if (existingTag !== undefined) { + existingTag.count += 1 + return + } + + openedTags.set(tag, { + count: 1 + }) + newMessage += `<${tag}>` + }, + ontext: (text) => { + if (textLength >= limit) { + return + } + + textLength += unescape(text).length + newMessage += unescape(text) + + if (textLength > limit) { + const extra = textLength - limit + newMessage = newMessage.slice(0, -extra) + } + }, + onclosetag: (tag) => { + const isLimit = textLength >= limit + if (tag === 'br' && !isLimit) { + newMessage += '\n' + textLength += 1 + return + } + + if (tag === 'p' && !isLimit) { + newMessage += '\n\n' + textLength += 2 + return + } + + // Just skip unsupported tag + if (!supportedTags.includes(tag)) { + return + } + + const existingTag = openedTags.get(tag) + + // We have unknown tag + if (existingTag === undefined) { + return + } + + existingTag.count -= 1 + + if (existingTag.count <= 0) { + openedTags.delete(tag) + } + + newMessage += `` + } + }) + + parser.write(message) + parser.end() + + return newMessage.trim() +} diff --git a/services/telegram-bot/pod-telegram-bot/src/worker.ts b/services/telegram-bot/pod-telegram-bot/src/worker.ts new file mode 100644 index 0000000000..3fd5709605 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/worker.ts @@ -0,0 +1,192 @@ +// +// 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 { Collection } from 'mongodb' +import { Account, Ref, SortingOrder } from '@hcengineering/core' + +import { UserRecord, NotificationRecord, OtpRecord } from './types' +import { getDB } from './storage' +import { WorkspaceClient } from './workspace' +import { getNewOtp } from './utils' +import config from './config' + +const closeWorkspaceTimeout = 10 * 60 * 1000 // 10 minutes + +export class PlatformWorker { + private readonly workspacesClients = new Map() + private readonly closeWorkspaceTimeouts: Map = new Map() + private readonly intervalId: NodeJS.Timeout | undefined + + private constructor ( + private readonly usersStorage: Collection, + private readonly notificationsStorage: Collection, + private readonly otpStorage: Collection + ) { + this.intervalId = setInterval( + () => { + void otpStorage.deleteMany({ expires: { $lte: Date.now() } }) + }, + 3 * 60 * 1000 + ) + } + + async close (): Promise { + if (this.intervalId !== undefined) { + clearInterval(this.intervalId) + } + } + + async closeWorkspaceClient (workspace: string): Promise { + const timeoutId = this.closeWorkspaceTimeouts.get(workspace) + + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + this.closeWorkspaceTimeouts.delete(workspace) + } + + const client = this.workspacesClients.get(workspace) + + if (client !== undefined) { + await client.close() + this.workspacesClients.delete(workspace) + } + } + + async getUsersRecords (): Promise { + return await this.usersStorage.find().toArray() + } + + async addUser (id: number, email: string): Promise { + const emailRes = await this.usersStorage.findOne({ email }) + + if (emailRes !== null) { + console.log('Account is already registered', { id, email }) + return + } + + const tRes = await this.usersStorage.findOne({ telegramId: id }) + + if (tRes !== null) { + console.log('Account is already registered', { id, email }) + return + } + + const insertResult = await this.usersStorage.insertOne({ telegramId: id, email }) + + return (await this.usersStorage.findOne({ _id: insertResult.insertedId })) ?? undefined + } + + async addNotificationRecord (record: NotificationRecord): Promise { + await this.notificationsStorage.insertOne(record) + } + + async removeUserByTelegramId (id: number): Promise { + await this.usersStorage.deleteOne({ telegramId: id }) + } + + async removeUserByAccount (_id: Ref): Promise { + await this.usersStorage.deleteOne({ account: _id }) + } + + async getNotificationRecord (id: number, email: string): Promise { + return (await this.notificationsStorage.findOne({ telegramId: id, email })) ?? undefined + } + + async getUserRecord (id: number): Promise { + return (await this.usersStorage.findOne({ telegramId: id })) ?? undefined + } + + async getUserRecordByEmail (email: string): Promise { + return (await this.usersStorage.findOne({ email })) ?? undefined + } + + async getWorkspaceClient (workspace: string): Promise { + const wsClient = this.workspacesClients.get(workspace) ?? (await WorkspaceClient.create(workspace)) + + if (!this.workspacesClients.has(workspace)) { + this.workspacesClients.set(workspace, wsClient) + } + + const timeoutId = this.closeWorkspaceTimeouts.get(workspace) + + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + + const newTimeoutId = setTimeout(() => { + void this.closeWorkspaceClient(workspace) + }, closeWorkspaceTimeout) + + this.closeWorkspaceTimeouts.set(workspace, newTimeoutId) + + return wsClient + } + + async reply (notification: NotificationRecord, text: string): Promise { + const client = await this.getWorkspaceClient(notification.workspace) + return await client.reply(notification, text) + } + + async authorizeUser (code: string, email: string): Promise { + const otpData = (await this.otpStorage.findOne({ code })) ?? undefined + const isExpired = otpData !== undefined && otpData.expires < Date.now() + const isValid = otpData !== undefined && !isExpired && code === otpData.code + + if (!isValid) { + throw new Error('Invalid OTP') + } + + return await this.addUser(otpData.telegramId, email) + } + + async generateCode (telegramId: number): Promise { + const now = Date.now() + const otpData = ( + await this.otpStorage.find({ telegramId }).sort({ createdOn: SortingOrder.Descending }).limit(1).toArray() + )[0] + const retryDelay = config.OtpRetryDelaySec * 1000 + const isValid = otpData !== undefined && otpData.expires > now + const canRetry = otpData !== undefined && otpData.createdOn + retryDelay < now + + if (isValid && !canRetry) { + return otpData.code + } + + const newCode = await getNewOtp(this.otpStorage) + const timeToLive = config.OtpTimeToLiveSec * 1000 + const expires = now + timeToLive + + await this.otpStorage.insertOne({ telegramId, code: newCode, expires, createdOn: now }) + + return newCode + } + + static async createStorages (): Promise< + [Collection, Collection, Collection] + > { + const db = await getDB() + const userStorage = db.collection('users') + const notificationsStorage = db.collection('notifications') + const otpStorage = db.collection('otp') + + return [userStorage, notificationsStorage, otpStorage] + } + + static async create (): Promise { + const [userStorage, notificationsStorage, otpStorage] = await PlatformWorker.createStorages() + + return new PlatformWorker(userStorage, notificationsStorage, otpStorage) + } +} diff --git a/services/telegram-bot/pod-telegram-bot/src/workspace.ts b/services/telegram-bot/pod-telegram-bot/src/workspace.ts new file mode 100644 index 0000000000..1d02ec4e04 --- /dev/null +++ b/services/telegram-bot/pod-telegram-bot/src/workspace.ts @@ -0,0 +1,145 @@ +// +// 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 { Client, getWorkspaceId, systemAccountEmail, TxFactory, WorkspaceId } from '@hcengineering/core' +import { generateToken } from '@hcengineering/server-token' +import notification, { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification' +import chunter, { ThreadMessage } from '@hcengineering/chunter' +import contact, { PersonAccount } from '@hcengineering/contact' +import { createClient, getTransactorEndpoint } from '@hcengineering/server-client' +import activity, { ActivityMessage } from '@hcengineering/activity' + +import { NotificationRecord } from './types' + +export class WorkspaceClient { + private constructor ( + private readonly client: Client, + private readonly token: string, + private readonly workspace: WorkspaceId + ) {} + + static async create (workspace: string): Promise { + const workspaceId = getWorkspaceId(workspace) + const token = generateToken(systemAccountEmail, workspaceId) + const client = await connectPlatform(token) + + return new WorkspaceClient(client, token, workspaceId) + } + + async replyToMessage (message: ActivityMessage, account: PersonAccount, text: string): Promise { + const txFactory = new TxFactory(account._id) + const hierarchy = this.client.getHierarchy() + if (hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + const thread = message as ThreadMessage + const collectionTx = txFactory.createTxCollectionCUD( + thread.attachedToClass, + thread.attachedTo, + message.space, + 'replies', + txFactory.createTxCreateDoc(chunter.class.ThreadMessage, message.space, { + attachedTo: thread.attachedTo, + attachedToClass: thread.attachedToClass, + objectId: thread.objectId, + objectClass: thread.objectClass, + message: text, + attachments: 0, + collection: 'replies' + }) + ) + await this.client.tx(collectionTx) + } else { + const collectionTx = txFactory.createTxCollectionCUD( + message._class, + message._id, + message.space, + 'replies', + txFactory.createTxCreateDoc(chunter.class.ThreadMessage, message.space, { + attachedTo: message._id, + attachedToClass: message._class, + objectId: message.attachedTo, + objectClass: message.attachedToClass, + message: text, + attachments: 0, + collection: 'replies' + }) + ) + await this.client.tx(collectionTx) + } + } + + async replyToActivityNotification ( + it: ActivityInboxNotification, + account: PersonAccount, + text: string + ): Promise { + const message = await this.client.findOne(it.attachedToClass, { _id: it.attachedTo }) + + if (message !== undefined) { + await this.replyToMessage(message, account, text) + return true + } + + return false + } + + async replyToMention (it: MentionInboxNotification, account: PersonAccount, text: string): Promise { + const hierarchy = this.client.getHierarchy() + + if (!hierarchy.isDerived(it.mentionedInClass, activity.class.ActivityMessage)) { + return false + } + + const message = (await this.client.findOne(it.mentionedInClass, { _id: it.mentionedIn })) as ActivityMessage + + if (message !== undefined) { + await this.replyToMessage(message, account, text) + return true + } + + return false + } + + public async reply (record: NotificationRecord, text: string): Promise { + const account = await this.client.getModel().findOne(contact.class.PersonAccount, { email: record.email }) + if (account === undefined) { + return false + } + + const inboxNotification = await this.client.findOne(notification.class.InboxNotification, { + _id: record.notificationId + }) + + if (inboxNotification === undefined) { + return false + } + const hierarchy = this.client.getHierarchy() + if (hierarchy.isDerived(inboxNotification._class, notification.class.ActivityInboxNotification)) { + return await this.replyToActivityNotification(inboxNotification as ActivityInboxNotification, account, text) + } else if (hierarchy.isDerived(inboxNotification._class, notification.class.MentionInboxNotification)) { + return await this.replyToMention(inboxNotification as MentionInboxNotification, account, text) + } + + return false + } + + async close (): Promise { + await this.client.close() + } +} + +async function connectPlatform (token: string): Promise { + const endpoint = await getTransactorEndpoint(token) + return await createClient(endpoint, token) +} diff --git a/services/telegram-bot/pod-telegram-bot/tsconfig.json b/services/telegram-bot/pod-telegram-bot/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/services/telegram-bot/pod-telegram-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