initial commit or smth

This commit is contained in:
soffee 2025-04-06 00:01:03 +03:00
parent 05a1bf047d
commit 98ae9d2e59
11 changed files with 410 additions and 30 deletions

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ dist/
*.log *.log
*.tsbuildinfo *.tsbuildinfo
.env .env
keywords.yml

View file

@ -1,6 +1,49 @@
# mtproto_exporter # mtproto_exporter
mtcute powered Telegram bot mtcute powered Prometheus metrics exporter
*this exporter is mostly useful only with userbot*
## Available Metrics
`messenger_dialog_info{peerId, peerType, displayName}`
Dialog information exposed as labels
`messenger_dialog_messages_count{peerId}`
Messages count since exporter startup
`messenger_dialog_unread_messages_count{peerId}`
Number of unread messages in dialogs
`messenger_dialog_keywords_count{peerId}`
Number of keywords found in messages since exporter startup
`messenger_dialog_words_count{peerId}`
Number of words in messages since exporter startup
This metric is disabled by default because it will produce a lot of unique time series (more info [here](https://prometheus.io/docs/practices/naming/#labels))
This will expose each **word** in each **message** in each **chat** as unique metric.
This metric can be enabled with command line flag `--words-counter`
## CLI Options
`--bind-host`, `-b` - ip address where http server will be listening on
`--port`, `-p` - port where http server will be listening on
`--words-counter` - enable each word counting metric
`--keywords-file`, `-k` - path to yaml file with keywords and patterns (see [keywords.yml.example](./keywords.yml.example))
## Environment Variables
`API_ID` - Telegram api id used for mtproto connection (see [mtcute.dev](https://mtcute.dev/guide/intro/sign-in.html))
`API_HASH` - Telegram api hash used for mtproto connection (see [mtcute.dev](https://mtcute.dev/guide/intro/sign-in.html))
## Development ## Development

View file

@ -1,19 +1,20 @@
import antfu from '@antfu/eslint-config' import antfu from "@antfu/eslint-config";
export default antfu({ export default antfu({
stylistic: { stylistic: {
indent: 4, indent: 4,
semi: true,
quotes: "double",
}, },
typescript: true, typescript: true,
yaml: false, yaml: false,
rules: { rules: {
'curly': ['error', 'multi-line'], "curly": ["error", "multi-line"],
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], "style/brace-style": ["error", "1tbs", { allowSingleLine: true }],
'style/quotes': ['error', 'single', { avoidEscape: true }], // "import/order": ["error", { "newlines-between": "always" }], this shit breaks eslint
'import/order': ['error', { 'newlines-between': 'always' }], "antfu/if-newline": "off",
'antfu/if-newline': 'off', "style/max-statements-per-line": ["error", { max: 2 }],
'style/max-statements-per-line': ['error', { max: 2 }], "no-console": "off",
'no-console': 'off', "antfu/no-top-level-await": "off",
'antfu/no-top-level-await': 'off',
}, },
}) });

4
keywords.yml.example Normal file
View file

@ -0,0 +1,4 @@
keywords:
- meow
- name: woof
pattern: 'w[oa]+f'

View file

@ -7,16 +7,21 @@
"scripts": { "scripts": {
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint --fix .", "lint:fix": "eslint --fix .",
"start": "dotenv tsx ./src/main.ts", "start": "dotenv tsx ./src/main.ts --",
"build": "tsc" "build": "tsc"
}, },
"dependencies": { "dependencies": {
"@mtcute/dispatcher": "^0.22.2", "@mtcute/dispatcher": "^0.22.2",
"@mtcute/node": "^0.22.3", "@mtcute/node": "^0.22.3",
"dotenv-cli": "^8.0.0" "command-line-args": "^6.0.1",
"dotenv-cli": "^8.0.0",
"js-yaml": "^4.1.0",
"prom-client": "^15.1.3"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^4.11.0", "@antfu/eslint-config": "^4.11.0",
"@types/command-line-args": "^5.2.3",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"tsx": "^4.19.3", "tsx": "^4.19.3",
"typescript": "^5.8.3" "typescript": "^5.8.3"

96
pnpm-lock.yaml generated
View file

@ -14,9 +14,24 @@ importers:
'@mtcute/node': '@mtcute/node':
specifier: ^0.22.3 specifier: ^0.22.3
version: 0.22.3 version: 0.22.3
'@types/command-line-args':
specifier: ^5.2.3
version: 5.2.3
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
command-line-args:
specifier: ^6.0.1
version: 6.0.1
dotenv-cli: dotenv-cli:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0 version: 8.0.0
js-yaml:
specifier: ^4.1.0
version: 4.1.0
prom-client:
specifier: ^15.1.3
version: 15.1.3
devDependencies: devDependencies:
'@antfu/eslint-config': '@antfu/eslint-config':
specifier: ^4.11.0 specifier: ^4.11.0
@ -421,6 +436,10 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@pkgr/core@0.1.2': '@pkgr/core@0.1.2':
resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@ -438,6 +457,9 @@ packages:
'@tybys/wasm-util@0.9.0': '@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
'@types/command-line-args@5.2.3':
resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@ -453,6 +475,9 @@ packages:
'@types/events@3.0.0': '@types/events@3.0.0':
resolution: {integrity: sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==} resolution: {integrity: sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -649,6 +674,10 @@ packages:
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
array-back@6.2.2:
resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==}
engines: {node: '>=12.17'}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@ -661,6 +690,9 @@ packages:
bindings@1.5.0: bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
bl@4.1.0: bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@ -728,6 +760,15 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
command-line-args@6.0.1:
resolution: {integrity: sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==}
engines: {node: '>=12.20'}
peerDependencies:
'@75lb/nature': latest
peerDependenciesMeta:
'@75lb/nature':
optional: true
comment-parser@1.4.1: comment-parser@1.4.1:
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@ -1097,6 +1138,15 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
find-replace@5.0.2:
resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==}
engines: {node: '>=14'}
peerDependencies:
'@75lb/nature': latest
peerDependenciesMeta:
'@75lb/nature':
optional: true
find-up-simple@1.0.1: find-up-simple@1.0.1:
resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1271,6 +1321,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@ -1566,6 +1619,10 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prom-client@15.1.3:
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
engines: {node: ^16 || ^18 || >=20}
pump@3.0.2: pump@3.0.2:
resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
@ -1726,6 +1783,9 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
tinyexec@0.3.2: tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
@ -1771,6 +1831,10 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
typical@7.3.0:
resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
engines: {node: '>=12.17'}
ufo@1.5.4: ufo@1.5.4:
resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
@ -2217,6 +2281,8 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1 fastq: 1.19.1
'@opentelemetry/api@1.9.0': {}
'@pkgr/core@0.1.2': {} '@pkgr/core@0.1.2': {}
'@pkgr/core@0.2.0': {} '@pkgr/core@0.2.0': {}
@ -2238,6 +2304,8 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/command-line-args@5.2.3': {}
'@types/debug@4.1.12': '@types/debug@4.1.12':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
@ -2253,6 +2321,8 @@ snapshots:
'@types/events@3.0.0': {} '@types/events@3.0.0': {}
'@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/mdast@4.0.4': '@types/mdast@4.0.4':
@ -2455,6 +2525,8 @@ snapshots:
argparse@2.0.1: {} argparse@2.0.1: {}
array-back@6.2.2: {}
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
base64-js@1.5.1: {} base64-js@1.5.1: {}
@ -2468,6 +2540,8 @@ snapshots:
dependencies: dependencies:
file-uri-to-path: 1.0.0 file-uri-to-path: 1.0.0
bintrees@1.0.2: {}
bl@4.1.0: bl@4.1.0:
dependencies: dependencies:
buffer: 5.7.1 buffer: 5.7.1
@ -2532,6 +2606,13 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
command-line-args@6.0.1:
dependencies:
array-back: 6.2.2
find-replace: 5.0.2
lodash.camelcase: 4.3.0
typical: 7.3.0
comment-parser@1.4.1: {} comment-parser@1.4.1: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
@ -2989,6 +3070,8 @@ snapshots:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
find-replace@5.0.2: {}
find-up-simple@1.0.1: {} find-up-simple@1.0.1: {}
find-up@5.0.0: find-up@5.0.0:
@ -3132,6 +3215,8 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash@4.17.21: {} lodash@4.17.21: {}
@ -3603,6 +3688,11 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prom-client@15.1.3:
dependencies:
'@opentelemetry/api': 1.9.0
tdigest: 0.1.2
pump@3.0.2: pump@3.0.2:
dependencies: dependencies:
end-of-stream: 1.4.4 end-of-stream: 1.4.4
@ -3768,6 +3858,10 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
tdigest@0.1.2:
dependencies:
bintrees: 1.0.2
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
tinyglobby@0.2.12: tinyglobby@0.2.12:
@ -3808,6 +3902,8 @@ snapshots:
typescript@5.8.3: {} typescript@5.8.3: {}
typical@7.3.0: {}
ufo@1.5.4: {} ufo@1.5.4: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}

View file

@ -1,10 +1,10 @@
import process from 'node:process' import process from "node:process";
const API_ID = Number.parseInt(process.env.API_ID!) const API_ID = Number.parseInt(process.env.API_ID!);
const API_HASH = process.env.API_HASH! const API_HASH = process.env.API_HASH!;
if (Number.isNaN(API_ID) || !API_HASH) { if (Number.isNaN(API_ID) || !API_HASH) {
throw new Error('API_ID or API_HASH not set!') throw new Error("API_ID or API_HASH not set!");
} }
export { API_HASH, API_ID } export { API_HASH, API_ID };

61
src/keywords.ts Normal file
View file

@ -0,0 +1,61 @@
import type { Dispatcher } from "@mtcute/dispatcher";
import { PropagationAction } from "@mtcute/dispatcher";
import { Counter } from "prom-client";
interface KeywordPattern {
name: string;
pattern: RegExp;
}
export type KeywordLike = string | KeywordPattern;
export function newWordsCounter(dp: Dispatcher) {
const counter = new Counter({
name: "messenger_dialog_words_count",
help: "Number of words in messages since exporter startup",
labelNames: ["peerId", "word"],
});
dp.onNewMessage(async (msg) => {
const words = msg.text.toLowerCase().split(" ");
for (const w of words) {
counter.inc({
peerId: msg.chat.id,
word: w,
});
}
return PropagationAction.Continue;
});
return counter;
}
export function newKeywordsCounter(dp: Dispatcher, keywords: KeywordLike[]) {
const counter = new Counter({
name: "messenger_dialog_keywords_count",
help: "Number of keywords found in messages since exporter startup",
labelNames: ["peerId", "keyword"],
});
dp.onNewMessage(async (msg) => {
for (const kw of keywords) {
let count;
let kwname;
if (typeof kw === "string") {
const words = msg.text.toLowerCase().split(" ");
count = words.filter(w => w === kw).length;
kwname = kw;
} else {
count = (msg.text.match(kw.pattern) || []).length;
kwname = kw.name;
}
if (count === 0) {
continue;
}
counter.inc({
peerId: msg.chat.id,
keyword: kwname,
}, count);
}
return PropagationAction.Continue;
});
return counter;
}

View file

@ -1,19 +1,74 @@
import { Dispatcher, filters } from '@mtcute/dispatcher' import type { OptionDefinition } from "command-line-args";
import { TelegramClient } from '@mtcute/node' import type { KeywordLike } from "./keywords.js";
import fs from "node:fs";
import { Dispatcher } from "@mtcute/dispatcher";
import { TelegramClient } from "@mtcute/node";
import cmdline from "command-line-args";
import yaml from "js-yaml";
import { collectDefaultMetrics, Registry } from "prom-client";
import * as env from './env.js' import * as env from "./env.js";
import * as metrics from "./metrics.js";
import MetricsServer from "./server.js";
const optionDefinitions: OptionDefinition[] = [
{ name: "bind-host", alias: "b", type: String, defaultValue: "0.0.0.0" },
{ name: "port", alias: "p", type: Number, defaultValue: 9669 },
{ name: "words-counter", type: Boolean, defaultValue: false },
{ name: "keywords-file", alias: "k", type: String },
];
const cli = cmdline(optionDefinitions);
const keywords: KeywordLike[] = [];
if (cli["keywords-file"]) {
if (!fs.existsSync(cli["keywords-file"])) {
throw new Error("--keywords-file set, but file does not exist.");
}
const doc = yaml.load(fs.readFileSync(cli["keywords-file"], "utf8")) as { keywords?: any[] };
if (doc.keywords && doc.keywords.constructor.name === "Array") {
for (const item of doc.keywords) {
if (typeof item === "string") {
keywords.push(item);
} else if (typeof item === "object" && item.name && item.pattern) {
keywords.push({
name: item.name,
pattern: new RegExp(item.pattern, "gi"),
});
}
}
} else {
throw new Error("Keywords file format error: no 'keywords' property, or not an array.");
}
}
const registry = new Registry();
collectDefaultMetrics({ register: registry });
const server = new MetricsServer(registry);
server.listen(cli["bind-host"], cli.port);
const tg = new TelegramClient({ const tg = new TelegramClient({
apiId: env.API_ID, apiId: env.API_ID,
apiHash: env.API_HASH, apiHash: env.API_HASH,
storage: 'bot-data/session', storage: "bot-data/session",
}) });
const dp = Dispatcher.for(tg) const dp = Dispatcher.for(tg);
dp.onNewMessage(filters.start, async (msg) => { registry.registerMetric(metrics.newStaticPeerInfoGauge(tg));
await msg.answerText('Hello, world!') registry.registerMetric(metrics.newUnreadCountGauge(tg));
}) registry.registerMetric(metrics.newMessagesCounter(dp));
const user = await tg.start() if (keywords.length > 0) {
console.log('Logged in as', user.username) registry.registerMetric(metrics.newKeywordsCounter(dp, keywords));
}
if (cli["words-counter"]) {
registry.registerMetric(metrics.newWordsCounter(dp));
}
const user = await tg.start();
console.log("Logged in as", user.username);

63
src/metrics.ts Normal file
View file

@ -0,0 +1,63 @@
import type { Dispatcher } from "@mtcute/dispatcher";
import type { TelegramClient } from "@mtcute/node";
import { PropagationAction } from "@mtcute/dispatcher";
import { Counter, Gauge } from "prom-client";
import { newKeywordsCounter, newWordsCounter } from "./keywords.js";
function newMessagesCounter(dp: Dispatcher) {
const counter = new Counter({
name: "messenger_dialog_messages_count",
help: "Messages count since exporter startup",
labelNames: ["peerId"],
});
dp.onNewMessage(async (msg) => {
counter.inc({
peerId: msg.chat.id,
});
return PropagationAction.Continue;
});
return counter;
}
function newStaticPeerInfoGauge(tg: TelegramClient) {
const gauge = new Gauge({
name: "messenger_dialog_info",
help: "Dialog information exposed as labels",
labelNames: ["peerId", "peerType", "displayName"],
collect: async () => {
for await (const d of tg.iterDialogs()) {
gauge.set({
peerId: d.peer.id,
peerType: d.peer.type,
displayName: d.peer.displayName,
}, 1);
}
},
});
return gauge;
}
function newUnreadCountGauge(tg: TelegramClient) {
const gauge = new Gauge({
name: "messenger_dialog_unread_messages_count",
help: "Number of unread messages in dialogs",
labelNames: ["peerId"],
collect: async () => {
for await (const d of tg.iterDialogs()) {
gauge.set({
peerId: d.peer.id,
}, d.unreadCount);
}
},
});
return gauge;
}
export {
newKeywordsCounter,
newMessagesCounter,
newStaticPeerInfoGauge,
newUnreadCountGauge,
newWordsCounter,
};

51
src/server.ts Normal file
View file

@ -0,0 +1,51 @@
import type { Registry } from "prom-client";
import http from "node:http";
export default class MetricsServer {
private _registry: Registry;
private _httpServer?: http.Server;
public constructor(registry: Registry) {
this._registry = registry;
}
public get registry() {
return this._registry;
}
public listen(address: string, port: number) {
if (this._httpServer) {
throw new Error("This server is already listening");
}
this._httpServer = http.createServer(this._requestHandler.bind(this));
this._httpServer.listen(port, address);
console.log(`HTTP Metrics Server is listening on ${address}:${port}`);
}
private async _requestHandler(req: http.IncomingMessage, res: http.ServerResponse) {
const url = new URL(`http://${req.headers.host ?? "localhost"}${req.url}`);
console.log(`[HTTP] ${req.method} - ${url.href} from ${req.socket.remoteAddress}:${req.socket.remotePort}`);
if (req.method === "GET" && url.pathname === "/metrics") {
try {
const metrics = await this._registry.metrics();
res.statusCode = 200;
res.setHeader("Content-Type", this._registry.contentType);
res.write(metrics);
} catch (e) {
console.error("Metrics collection failed:", e);
res.statusCode = 500;
res.setHeader("Content-Type", "application/json; charset=utf-8;");
res.write("{ \"error\":{\"message\":\"failed to collect metrics.\"}}");
}
return res.end();
}
if (!res.writableEnded) {
res.statusCode = 404;
res.setHeader("Content-Type", "application/json; charset=utf-8;");
res.write("{\"error\":{\"message\":\"not found.\"}}");
return res.end();
}
}
}