UBERF-7604: Telegram notifications service (#6182)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-08-07 10:53:17 +04:00 committed by GitHub
parent c577282192
commit 36cbccfeb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1557 additions and 26 deletions

4
.vscode/launch.json vendored
View File

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

View File

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

View File

@ -221,6 +221,7 @@ export async function configurePlatform (): Promise<void> {
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)

View File

@ -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 {

View File

@ -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}
<div class="flex-col mt-2">
<div class="flex-row-center flex-gap-2">
<!--TODO: use telegram bot avatar-->
<Icon icon={TelegramColor} size="medium" />
{#if info.photoUrl !== ''}
<img class="photo" src={info.photoUrl} alt="" />
{:else}
<Icon icon={TelegramColor} size="x-large" />
{/if}
{info.name} (@{info.username})
<ModernButton
label={telegram.string.TestConnection}
@ -186,4 +189,10 @@
color: var(--theme-link-color);
}
}
.photo {
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
}
</style>

View File

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

View File

@ -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, {

View File

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

View File

@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}

View File

@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/

View File

@ -0,0 +1,8 @@
FROM node:20-alpine
WORKDIR /usr/src/app
COPY bundle/bundle.js ./
EXPOSE 4020
CMD [ "node", "bundle.js" ]

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}

View File

@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}

View File

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

View File

@ -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<void> {
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<void> {
const lang = ctx.from?.language_code ?? 'en'
const commandsHelp = await getCommandsHelp(lang)
await ctx.reply(commandsHelp)
}
async function onStop (ctx: Context, worker: PlatformWorker): Promise<void> {
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<void> {
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<Context<Update>, Update.MessageUpdate<TextMessage>>,
worker: PlatformWorker
): Promise<void> {
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<Telegraf> {
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
}

View File

@ -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<Config> = {
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<keyof Config>).filter((key) => params[key] === undefined)
if (missingEnv.length > 0) {
throw Error(`Missing config for attributes: ${missingEnv.join(', ')}`)
}
return params as Config
})()
export default config

View File

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

View File

@ -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()

View File

@ -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<number, number>()
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<T>(op: () => Promise<T>): Promise<void> {
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<T>(telegramId: number, op: () => Promise<T>): Promise<void> {
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<void> {
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<void> {
const now = Date.now()
const diff = Math.max(this.clearChatLimitOn - now, 0)
await new Promise((resolve) => setTimeout(resolve, diff))
}
}

View File

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

View File

@ -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<void>
const handleRequest = async (
fn: AsyncRequestHandler,
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
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)
}

View File

@ -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<void> => {
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)
})
}

View File

@ -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)
}
})()

View File

@ -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<InboxNotification>
workspace: string
email: string
telegramId: number
}
export interface OtpRecord {
telegramId: number
code: string
expires: Timestamp
createdOn: Timestamp
}

View File

@ -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<OtpRecord>): Promise<string> {
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<BotCommand[]> {
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<string> {
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 !== '' ? `<b>${platformToTelegram(record.title, maxTitleLength)}</b>` + '\n' : ''
const quote =
record.quote !== undefined && record.quote !== ''
? `<blockquote>${platformToTelegram(record.quote, maxQuoteLength)}</blockquote>` + '\n'
: ''
const body = platformToTelegram(record.body, maxBodyLength)
const sender = `<i>— ${record.sender.slice(0, maxSenderLength)}</i>`
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 += `</${tag}>`
}
})
parser.write(message)
parser.end()
return newMessage.trim()
}

View File

@ -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<string, WorkspaceClient>()
private readonly closeWorkspaceTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>()
private readonly intervalId: NodeJS.Timeout | undefined
private constructor (
private readonly usersStorage: Collection<UserRecord>,
private readonly notificationsStorage: Collection<NotificationRecord>,
private readonly otpStorage: Collection<OtpRecord>
) {
this.intervalId = setInterval(
() => {
void otpStorage.deleteMany({ expires: { $lte: Date.now() } })
},
3 * 60 * 1000
)
}
async close (): Promise<void> {
if (this.intervalId !== undefined) {
clearInterval(this.intervalId)
}
}
async closeWorkspaceClient (workspace: string): Promise<void> {
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<UserRecord[]> {
return await this.usersStorage.find().toArray()
}
async addUser (id: number, email: string): Promise<UserRecord | undefined> {
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<void> {
await this.notificationsStorage.insertOne(record)
}
async removeUserByTelegramId (id: number): Promise<void> {
await this.usersStorage.deleteOne({ telegramId: id })
}
async removeUserByAccount (_id: Ref<Account>): Promise<void> {
await this.usersStorage.deleteOne({ account: _id })
}
async getNotificationRecord (id: number, email: string): Promise<NotificationRecord | undefined> {
return (await this.notificationsStorage.findOne({ telegramId: id, email })) ?? undefined
}
async getUserRecord (id: number): Promise<UserRecord | undefined> {
return (await this.usersStorage.findOne({ telegramId: id })) ?? undefined
}
async getUserRecordByEmail (email: string): Promise<UserRecord | undefined> {
return (await this.usersStorage.findOne({ email })) ?? undefined
}
async getWorkspaceClient (workspace: string): Promise<WorkspaceClient> {
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<boolean> {
const client = await this.getWorkspaceClient(notification.workspace)
return await client.reply(notification, text)
}
async authorizeUser (code: string, email: string): Promise<UserRecord | undefined> {
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<string> {
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<UserRecord>, Collection<NotificationRecord>, Collection<OtpRecord>]
> {
const db = await getDB()
const userStorage = db.collection<UserRecord>('users')
const notificationsStorage = db.collection<NotificationRecord>('notifications')
const otpStorage = db.collection<OtpRecord>('otp')
return [userStorage, notificationsStorage, otpStorage]
}
static async create (): Promise<PlatformWorker> {
const [userStorage, notificationsStorage, otpStorage] = await PlatformWorker.createStorages()
return new PlatformWorker(userStorage, notificationsStorage, otpStorage)
}
}

View File

@ -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<WorkspaceClient> {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
await this.client.close()
}
}
async function connectPlatform (token: string): Promise<Client> {
const endpoint = await getTransactorEndpoint(token)
return await createClient(endpoint, token)
}

View File

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