├── .branchlintrc.json ├── .commitlintrc.json ├── .dockerignore ├── .editorconfig ├── .env.template ├── .eslint ├── jest.eslintrc.json ├── node.eslintrc.json ├── ordered-imports.eslintrc.json5 ├── prettier.eslintrc.json ├── smile-style.eslintrc.json ├── typescript.eslintrc.json └── unicorn.eslintrc.json ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .lintstagedrc.mjs ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .swcrc.json ├── .tslintrc.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── Dockerfile ├── Dockerfile.backend ├── Dockerfile.local ├── LICENSE ├── README.md ├── copy-swindlers.sh ├── dataset ├── append-excel-copy-in-cases.js ├── append-excel-copy-in-high_risk.js ├── append-excel-copy-in-locations.js ├── auto-strict-words.js ├── auto-swindlers.ts ├── cases │ └── .gitignore ├── check-swindlers.ts ├── dataset-antisemitism.ts ├── dataset-helpers.ts ├── dataset-obscene.ts ├── dataset-sort.js ├── dataset.ts ├── download-swindler-dataset.ts ├── get-channels │ ├── .gitignore │ ├── README.md │ ├── resolve-chat-name.js │ └── validate-dialogs.ts ├── get-swindlers-top-used.ts ├── get-top-used.ts ├── merge-old-and-new.js ├── normalize-dataset.pipeline.sh ├── obscene-optimization.ts ├── optimize-dataset-client │ ├── index.html │ ├── index.js │ └── main.css ├── optimize-dataset-server.js ├── optimize-dataset.js ├── optimize-swindler-dataset.ts ├── parse-excel-cases.pipeline.sh ├── parse-excel-copy.js ├── parse-excel-copy.pipeline.sh ├── parse-ukrposhta-addresses.js ├── prepare-tensor-csv-dataset.js ├── process-bot-trained-files.js ├── process-sessions.ts ├── remove-duplicates.js ├── remove-similar-logic.ts ├── remove-similar.ts ├── smart-remove-duplicates.js ├── strings │ ├── .gitignore │ ├── antisemitism_action.txt │ ├── antisemitism_nouns.txt │ ├── antisemitism_threads.txt │ ├── high_risk.json │ ├── houses.json │ ├── immediately.json │ ├── location_types.json │ ├── locations.json │ ├── obscene_dictionary_en.txt │ ├── obscene_dictionary_en_whitelist.txt │ ├── obscene_dictionary_ru.txt │ ├── obscene_dictionary_ru_whitelist.txt │ ├── obscene_dictionary_ua.txt │ ├── obscene_dictionary_ua_whitelist.txt │ ├── one_word.json │ ├── percent_100.json │ ├── strict_high_risk.json │ ├── strict_locations.json │ └── strict_percent_100.json ├── temp │ └── .gitignore └── tensor-vocab-to-json.js ├── docker-compose.yaml ├── jest.config.mjs ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── 20220406204759-migrate-redis-user-session.ts ├── @types │ └── tinyld.d.ts ├── __mocks__ │ └── bot.mocks.ts ├── __tests__ │ ├── bot.test.ts │ └── edit-message.test.ts ├── assets │ └── logs-chat-profile-photo.jpeg ├── bot-express.server.ts ├── bot.ts ├── bot │ ├── commands │ │ ├── command-setter.ts │ │ ├── index.ts │ │ ├── private │ │ │ ├── index.ts │ │ │ ├── rank.command.ts │ │ │ ├── session.command.ts │ │ │ ├── statistics.command.ts │ │ │ ├── swindlers-update.command.ts │ │ │ └── updates.command.ts │ │ └── public │ │ │ ├── __tests__ │ │ │ └── settings.command.test.ts │ │ │ ├── air-raid-alarm │ │ │ └── locations-menu-generator.ts │ │ │ ├── help.command.ts │ │ │ ├── index.ts │ │ │ ├── settings.command.ts │ │ │ └── start.command.ts │ ├── composers │ │ ├── __tests__ │ │ │ ├── before-any.composer.test.ts │ │ │ └── join-leave.composer.test.ts │ │ ├── before-any.composer.ts │ │ ├── create-logs-chat.composer.ts │ │ ├── creator-command.composer.ts │ │ ├── feature-poll.composer.ts │ │ ├── health-check.composer.ts │ │ ├── hotline-security.composer.ts │ │ ├── index.ts │ │ ├── join-leave.composer.ts │ │ ├── messages.composer.ts │ │ ├── messages │ │ │ ├── __tests__ │ │ │ │ ├── no-antisemitism.composer.test.ts │ │ │ │ ├── no-channel-messages.composer.test.ts │ │ │ │ ├── no-counteroffensive.composer.test.ts │ │ │ │ ├── no-locations.composer.test.ts │ │ │ │ ├── no-obscene.composer.test.ts │ │ │ │ ├── no-russian.composer.test.ts │ │ │ │ ├── nsfw-message-filter.composer.test.ts │ │ │ │ ├── warn-obscene.composer.test.ts │ │ │ │ └── warn-russian.composer.test.ts │ │ │ ├── index.ts │ │ │ ├── no-antisemitism.composer.ts │ │ │ ├── no-cards.composer.ts │ │ │ ├── no-channel-messages.composer.ts │ │ │ ├── no-counteroffensive.composer.ts │ │ │ ├── no-forward.composer.ts │ │ │ ├── no-locations.composer.ts │ │ │ ├── no-mentions.composer.ts │ │ │ ├── no-obscene.composer.ts │ │ │ ├── no-russian.composer.ts │ │ │ ├── no-urls.composer.ts │ │ │ ├── nsfw-filter.composer.ts │ │ │ ├── nsfw-message-filter.composer.ts │ │ │ ├── strategic.composer.ts │ │ │ ├── swindlers.composer.ts │ │ │ ├── warn-obscene.composer.ts │ │ │ └── warn-russian.composer.ts │ │ ├── photos.composer.ts │ │ ├── private-command.composer.ts │ │ ├── public-command.composer.ts │ │ ├── save-to-sheet.composer.ts │ │ ├── swindlers-statististics.composer.ts │ │ ├── tensor-training.composer.ts │ │ └── video-note-converter.composer.ts │ ├── filters │ │ ├── index.ts │ │ ├── is-not-channel.filter.ts │ │ ├── middleware-to.filter.ts │ │ ├── only-active-default-setting.filter.ts │ │ ├── only-active-optional-setting.filter.ts │ │ ├── only-creator-chat.filter.ts │ │ ├── only-creator.filter.ts │ │ ├── only-not-admin.filter.ts │ │ ├── only-not-deleted.filter.ts │ │ ├── only-swindlers-statistic-whitelisted.ts │ │ ├── only-when-bot-admin.filter.ts │ │ ├── only-whitelisted.filter.ts │ │ ├── only-with-photo.filter.ts │ │ └── only-with-text.filter.ts │ ├── listeners │ │ ├── index.ts │ │ ├── on-text.listener.ts │ │ └── test-tensor.listener.ts │ ├── message.handler.ts │ ├── middleware-menu.menu.ts │ ├── middleware │ │ ├── __tests__ │ │ │ └── admin-check-notify.test.ts │ │ ├── admin-check-notify.middleware.ts │ │ ├── bot-active.middleware.ts │ │ ├── bot-redis-active.middleware.ts │ │ ├── debug.middleware.ts │ │ ├── delete-message.middleware.ts │ │ ├── delete-spam-media-groups.middleware.ts │ │ ├── delete-swindlers.middleware.ts │ │ ├── global.middleware.ts │ │ ├── ignore-by-default-settings.middleware.ts │ │ ├── ignore-old.middleware.ts │ │ ├── index.ts │ │ ├── log-context.middleware.ts │ │ ├── log-creator-state.middleware.ts │ │ ├── log-parsed-photos.middleware.ts │ │ ├── nested.middleware.test.ts │ │ ├── nested.middleware.ts │ │ ├── only-admin.middleware.ts │ │ ├── only-creator.middleware.ts │ │ ├── only-not-admin.middleware.ts │ │ ├── only-not-forwarded.middleware.ts │ │ ├── only-when-bot-admin.middleware.ts │ │ ├── only-whitelisted.ts │ │ ├── only-with-photo.middleware.ts │ │ ├── only-with-text.middleware.ts │ │ ├── parse-cards.middleware.ts │ │ ├── parse-entities.middleware.ts │ │ ├── parse-is-counteroffensive.middleware.ts │ │ ├── parse-is-russian.middleware.ts │ │ ├── parse-locations.middleware.ts │ │ ├── parse-mentions.middleware.ts │ │ ├── parse-photo.middleware.ts │ │ ├── parse-text.middleware.ts │ │ ├── parse-urls.middleware.ts │ │ ├── parse-video-frames.middleware.ts │ │ ├── performance-end.middleware.ts │ │ ├── performance-start.middleware.ts │ │ ├── redis.middleware.ts │ │ ├── remove-system-information.middleware.ts │ │ ├── save-spam-media-group.middleware.ts │ │ ├── state.middleware.ts │ │ └── throw-error.middleware.ts │ ├── plugins │ │ ├── auto-comment-reply.plugin.ts │ │ ├── chain-filters.plugin.test.ts │ │ ├── chain-filters.plugin.ts │ │ ├── index.ts │ │ ├── self-destructed.plugin.test.ts │ │ └── self-destructed.plugin.ts │ ├── queries │ │ ├── bot-demote.query.ts │ │ ├── bot-invite.query.ts │ │ ├── bot-kick.query.ts │ │ ├── bot-promote.query.ts │ │ └── index.ts │ ├── sessionProviders │ │ ├── index.ts │ │ ├── redis-chat-session-storage.ts │ │ └── redis-session-storage.ts │ ├── spam.handlers.ts │ └── transformers │ │ ├── delete-message.transformer.ts │ │ ├── disable-logs-chat.transformer.test.ts │ │ ├── disable-logs-chat.transformer.ts │ │ └── index.ts ├── config.ts ├── const │ ├── google-sheets.const.ts │ ├── index.ts │ ├── logs.const.ts │ ├── message-query.const.ts │ └── telegram.const.ts ├── creator.ts ├── db │ ├── index.ts │ └── redis.ts ├── definitions │ └── input.d.ts ├── express-logic │ ├── api.router.ts │ ├── helpers.ts │ ├── index.ts │ ├── middleware │ │ ├── headers.middleware.ts │ │ ├── index.ts │ │ └── web-view-auth.middleware.ts │ ├── process.handler.ts │ └── verify-telegram-web-app-data.ts ├── express.server.ts ├── index.ts ├── message.ts ├── message │ ├── admin.message.ts │ ├── antisemitism.message.ts │ ├── index.ts │ ├── obscene.message.ts │ ├── settings.message.ts │ └── swindlers.message.ts ├── packages │ └── nsfwjs-2.4.2.tgz ├── services │ ├── __snapshots__ │ │ └── language-detect.service.test.ts.snap │ ├── _mocks │ │ ├── alarm.mocks.ts │ │ ├── helpers.mocks.ts │ │ ├── index.mocks.ts │ │ ├── index.ts │ │ └── language-detect.mocks.ts │ ├── alarm-chat.service.test.ts │ ├── alarm-chat.service.ts │ ├── alarm.service.ts │ ├── antisemitism.service.test.ts │ ├── antisemitism.service.ts │ ├── cards.service.test.ts │ ├── cards.service.ts │ ├── constants │ │ ├── index.ts │ │ └── swindlers-urls.constant.ts │ ├── counteroffensive.service.test.ts │ ├── counteroffensive.service.ts │ ├── dynamic-storage.service.test.ts │ ├── dynamic-storage.service.ts │ ├── google.service.ts │ ├── index.ts │ ├── language-detect.service.test.ts │ ├── language-detect.service.ts │ ├── locations.service.ts │ ├── mention.service.test.ts │ ├── mention.service.ts │ ├── nsfw-detect.service.test.ts │ ├── nsfw-detect.service.ts │ ├── obscene.service.test.ts │ ├── obscene.service.ts │ ├── redis.service.ts │ ├── s3.service.ts │ ├── spam-media-groups-storage.service.ts │ ├── statistics-google.service.ts │ ├── swindlers-bots.service.test.ts │ ├── swindlers-bots.service.ts │ ├── swindlers-cards.service.test.ts │ ├── swindlers-cards.service.ts │ ├── swindlers-detect.service.test.ts │ ├── swindlers-detect.service.ts │ ├── swindlers-google.service.ts │ ├── swindlers-urls.service.test.ts │ ├── swindlers-urls.service.ts │ ├── swindlers.container.ts │ ├── url.service.test.ts │ └── url.service.ts ├── tensor │ ├── base-tensor.service.ts │ ├── index.ts │ ├── nsfw-temp │ │ ├── group1-shard1of1 │ │ └── model.json │ ├── nsfw-tensor.service.ts │ ├── swindlers-temp │ │ ├── .gitignore │ │ ├── group1-shard1of1.bin │ │ ├── model.json │ │ └── vocab.json │ ├── swindlers-tensor.service.ts │ ├── temp │ │ └── .gitignore │ └── tensor.service.ts ├── testing-main.ts ├── testing │ ├── get-update.ts │ ├── index.ts │ ├── mock-context-field.ts │ ├── outgoing-requests.ts │ ├── prepare.ts │ ├── types │ │ └── index.ts │ └── updates │ │ ├── generic-mock.update.ts │ │ ├── index.ts │ │ ├── left-member-mock.update.ts │ │ ├── message-private-mock.update.ts │ │ ├── message-super-group-mock.update.ts │ │ ├── new-member-mock.update.ts │ │ └── updates.test.ts ├── types │ ├── alarm.ts │ ├── context.ts │ ├── dataset.ts │ ├── environment.ts │ ├── express.ts │ ├── generic.ts │ ├── google.ts │ ├── image.ts │ ├── index.ts │ ├── language-detection.ts │ ├── mtproto │ │ └── index.ts │ ├── nsfw.ts │ ├── object.ts │ ├── session.ts │ ├── state.ts │ └── swindlers.ts ├── userbot │ ├── .gitignore │ ├── api.ts │ ├── auth.ts │ ├── find-channel-admins.ts │ ├── index.ts │ ├── mt-proto-client.ts │ ├── storage.handler.ts │ └── updates.handler.ts ├── utils │ ├── __tests__ │ │ ├── censor-word.util.test.ts │ │ ├── domain-allow-list.test.ts │ │ ├── remove-system-information.util.test.ts │ │ └── search-set.test.ts │ ├── array-diff.util.ts │ ├── censor-word.util.ts │ ├── csv.util.ts │ ├── deep-copy.util.ts │ ├── domain-allow-list.ts │ ├── empty-functions.util.ts │ ├── error-handler.ts │ ├── generic.util.ts │ ├── get-typed-value.util.ts │ ├── index.test.ts │ ├── index.ts │ ├── message.util.ts │ ├── optimize-write-context.util.ts │ ├── remove-duplicates.util.ts │ ├── remove-repeated-letters.util.ts │ ├── remove-system-information.util.ts │ ├── reveal-hidden-urls.util.test.ts │ ├── reveal-hidden-urls.util.ts │ ├── search-set.ts │ ├── telegram.util.ts │ └── video.util.ts └── video │ ├── index.ts │ ├── temp │ └── .gitignore │ └── video.service.ts ├── temp └── .gitignore └── tsconfig.json /.branchlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branchNameLinter": { 3 | "regex": "^[a-z]+/([A-Z]+-[0-9]+_.{5,70})", 4 | "regexOptions": "i", 5 | "msgDoesNotMatchRegex": "Branch \"%s\" does not match the allowed pattern: \"%s\"", 6 | "msgBranchDisallowed": "Pushing to \"%s\" is not allowed, use git-flow.\n\nExample:\n* feature/TIC-20_branch_name\n\n" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ], 5 | "rules": { 6 | "type-enum": [ 7 | 2, 8 | "always", 9 | [ 10 | "ci", 11 | "chore", 12 | "docs", 13 | "feat", 14 | "fix", 15 | "perf", 16 | "refactor", 17 | "revert", 18 | "style", 19 | "test" 20 | ] 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .idea 4 | .vscode 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | 12 | [*.md] 13 | max_line_length = off 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslint/jest.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "env": { 5 | "jest/globals": true 6 | }, 7 | "files": ["src/**/*.test.ts", "src/**/*.spec.ts"], 8 | "plugins": ["jest"], 9 | "extends": ["plugin:jest/recommended"], 10 | "rules": { "jest/prefer-expect-assertions": "off" } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.eslint/node.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base"], 3 | "env": { 4 | "commonjs": true, 5 | "node": true 6 | }, 7 | "rules": { 8 | "class-methods-use-this": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.eslint/ordered-imports.eslintrc.json5: -------------------------------------------------------------------------------- 1 | // @description React Ordered imports 2 | // @install npm i eslint-plugin-simple-import-sort --save-dev 3 | { 4 | "overrides": [ 5 | { 6 | "files": [ 7 | "*.js", 8 | "*.jsx", 9 | "*.ts", 10 | "*.tsx" 11 | ], 12 | "plugins": ["simple-import-sort"], 13 | "rules": { 14 | "simple-import-sort/exports": "error", 15 | "simple-import-sort/imports": [ 16 | "error", 17 | { 18 | "groups": [ 19 | // Packages `node` related packages come first. 20 | ["^node", "^@?\\w"], 21 | // Internal packages. 22 | ["^(@|components)(/.*|$)"], 23 | // Side effect imports. 24 | ["^\\u0000"], 25 | // Parent imports. Put `..` last. 26 | ["^\\.\\.(?!/?$)", "^\\.\\./?$"], 27 | // Other relative imports. Put same-folder imports and `.` last. 28 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"], 29 | // Style imports. 30 | ["^.+\\.?(css)$"] 31 | ] 32 | } 33 | ] 34 | } 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.eslint/prettier.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "prettier" 4 | ], 5 | "plugins": [ 6 | "prettier" 7 | ], 8 | "rules": { 9 | "prettier/prettier": "error" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.eslint/typescript.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": [ 5 | "*.ts" 6 | ], 7 | "parserOptions": { 8 | "project": "./tsconfig.json" 9 | }, 10 | "parser": "@typescript-eslint/parser", 11 | "plugins": [ 12 | "@typescript-eslint" 13 | ], 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:@typescript-eslint/recommended", 17 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 18 | ], 19 | "rules": { 20 | "@typescript-eslint/consistent-type-definitions": "error", 21 | "import/prefer-default-export": "off", 22 | "no-useless-constructor": "off", 23 | "@typescript-eslint/no-unused-vars": "error", 24 | "no-shadow": "off", 25 | "default-case": "off", 26 | "@typescript-eslint/no-shadow": "error", 27 | "@typescript-eslint/consistent-type-imports": "error", 28 | "@typescript-eslint/no-useless-constructor": "error", 29 | "@typescript-eslint/switch-exhaustiveness-check": "error" 30 | } 31 | }, 32 | { 33 | "files": [ 34 | "*.test.ts", 35 | "*.spec.ts" 36 | ], 37 | "rules": { 38 | "@typescript-eslint/unbound-method": "off" 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.eslint/unicorn.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "unicorn" 4 | ], 5 | "extends": "plugin:unicorn/recommended", 6 | "rules": { 7 | "unicorn/no-null": "off", 8 | "unicorn/prefer-top-level-await": "off", 9 | "unicorn/no-array-for-each": "off" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .git 2 | .eslint 3 | .husky 4 | .vscode 5 | dist 6 | e2e 7 | node_modules 8 | 9 | # See http://help.github.com/ignore-files/ for more about ignoring files. 10 | 11 | # compiled output 12 | /dist 13 | /tmp 14 | /out-tsc 15 | 16 | # dependencies 17 | /node_modules 18 | 19 | # IDEs and editors 20 | /.idea 21 | .project 22 | .classpath 23 | .c9/ 24 | *.launch 25 | .settings/ 26 | *.sublime-workspace 27 | 28 | # IDE - VSCode 29 | .vscode/* 30 | !.vscode/settings.json 31 | !.vscode/tasks.json 32 | !.vscode/launch.json 33 | !.vscode/extensions.json 34 | 35 | # misc 36 | /.sass-cache 37 | /.eslintcache 38 | /connect.lock 39 | /coverage 40 | /libpeerconnection.log 41 | npm-debug.log 42 | yarn-error.log 43 | testem.log 44 | /typings 45 | 46 | # System Files 47 | .DS_Store 48 | Thumbs.db 49 | 50 | # Smile Track 51 | /.smile-track 52 | 53 | /arkit.svg 54 | 55 | # Smile Track 56 | /.smile-track 57 | /temp 58 | /dataset/**/*.js 59 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2020 4 | }, 5 | "env": { 6 | "es2021": true 7 | }, 8 | "ignorePatterns": [ 9 | "!src/**/*.{ts,js}", 10 | "!dataset/**/*.{ts,js}", 11 | "node_modules" 12 | ], 13 | "root": true, 14 | "extends": [ 15 | "./.eslint/node.eslintrc.json", 16 | "./.eslint/smile-style.eslintrc.json", 17 | "./.eslint/unicorn.eslintrc.json", 18 | "./.eslint/ordered-imports.eslintrc.json5", 19 | "./.eslint/jest.eslintrc.json", 20 | "./.eslint/typescript.eslintrc.json", 21 | "./.eslint/prettier.eslintrc.json" 22 | ], 23 | "rules": { 24 | "consistent-return": "off", 25 | "import/extensions": [ 26 | "error", 27 | "ignorePackages", 28 | { 29 | "js": "never", 30 | "ts": "never" 31 | } 32 | ] 33 | }, 34 | "settings": { 35 | "import/resolver": { 36 | "node": { 37 | "paths": ["src"], 38 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | *.sqlite 5 | # See http://help.github.com/ignore-files/ for more about ignoring files.# compiled output/dist/tmp/out-tsc# Only exists if Bazel was run/bazel-out# dependencies/node_modules# profiling fileschrome-profiler-events*.jsonspeed-measure-plugin*.json# IDEs and editors/.idea.project.classpath.c9/*.launch.settings/*.sublime-workspace# IDE - VSCode.vscode/*!.vscode/settings.json!.vscode/tasks.json!.vscode/launch.json!.vscode/extensions.json.history/*# misc/.sass-cache/connect.lock/coverage/libpeerconnection.lognpm-debug.logyarn-error.logtestem.log/typings# System Files.DS_StoreThumbs.db# Project filessecret.ts# Smile Track/.smile-track/.stylelintcache 6 | .eslintcache 7 | .idea 8 | telegraf-session.json 9 | .env.prod 10 | .env.dev 11 | .env.stg 12 | last-ctx.json 13 | negatives.json 14 | positives.json 15 | creds.json 16 | coverage 17 | .DS_Store 18 | */.DS_Store 19 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | echo "\033[0;33m" 5 | echo "***************************************************" 6 | echo "" 7 | echo "We use Conversational Commits Conversational." 8 | echo "To make a commit, be sure you follow it. Example:" 9 | echo "" 10 | echo " * feat(LBAT-20): added the users page" 11 | echo " * refactor(LBAT-10): refactored tests" 12 | echo "" 13 | echo "Read more: https://www.conventionalcommits.org" 14 | echo "" 15 | echo "***************************************************" 16 | echo "\033[0m" 17 | 18 | npx --no-install commitlint --edit 19 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | echo "\033[0;33m" 5 | echo "***************************************************" 6 | echo "" 7 | echo "We use branch-name-lint." 8 | echo "To push a branch, be sure you have right prefix, ticket name in uppercase, and description split by underscore. Example branch name:" 9 | echo "" 10 | echo " * feature/LBAT-8_create_lp_ui_elements" 11 | echo " * hotfix/LBAT-11_add_missing_login_routes" 12 | echo "" 13 | echo "Read more: https://github.com/barzik/branch-name-lint" 14 | echo "" 15 | echo "***************************************************" 16 | echo "\033[0m" 17 | 18 | echo "\033[0;33m" 19 | echo "***************************************************" 20 | echo "" 21 | echo "Lint time can be long for the first time." 22 | echo "" 23 | echo "***************************************************" 24 | echo "\033[0m" 25 | 26 | npm run lint 27 | -------------------------------------------------------------------------------- /.lintstagedrc.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | const getLintFlags = (absolutePaths) => { 4 | const cwd = process.cwd(); 5 | const relativePaths = absolutePaths.map((file) => path.relative(cwd, file)); 6 | /** 7 | * 8 | * Some file paths are too long and cannot be added as parameter to eslint 9 | * https://serverfault.com/questions/9546/filename-length-limits-on-linux/9548#9548 10 | * 11 | * */ 12 | const filePathLengthLimit = relativePaths.find((path) => path.length > 250); 13 | 14 | /** 15 | * 16 | * There is a case, when the command is too long and eslint cannot execute it. 17 | * 18 | * */ 19 | const isTooManyFilesToLint = relativePaths.length > 20; 20 | 21 | return { 22 | relativePaths, 23 | filePathLengthLimit, 24 | isTooManyFilesToLint, 25 | }; 26 | }; 27 | 28 | export default { 29 | '**/*.{js,ts}': (absolutePaths) => { 30 | const { filePathLengthLimit, isTooManyFilesToLint, relativePaths } = getLintFlags(absolutePaths); 31 | 32 | if (filePathLengthLimit || isTooManyFilesToLint) { 33 | return 'npm run lint:js'; 34 | } 35 | 36 | return `eslint ${relativePaths.join(' ')}`; 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.12.2 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | /node_modules 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 140, 4 | "trailingComma": "all", 5 | "overrides": [ 6 | { 7 | "files": "*.html", 8 | "options": { 9 | "parser": "html" 10 | } 11 | }, 12 | { 13 | "files": "*.component.html", 14 | "options": { 15 | "parser": "angular", 16 | "printWidth": 80 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.swcrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": false, 7 | "decorators": true, 8 | "dynamicImport": true 9 | }, 10 | "target": "es2020", 11 | "baseUrl": "./src" 12 | }, 13 | "module": { 14 | "type": "commonjs" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.tslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "curly": true, 5 | "eofline": true, 6 | "forin": true, 7 | "indent": [true, 4], 8 | "label-position": true, 9 | "label-undefined": true, 10 | "max-line-length": [true, 140], 11 | "no-arg": true, 12 | "no-bitwise": true, 13 | "no-console": [true, 14 | "debug", 15 | "info", 16 | "time", 17 | "timeEnd", 18 | "trace" 19 | ], 20 | "no-construct": true, 21 | "no-debugger": true, 22 | "no-duplicate-key": true, 23 | "no-duplicate-variable": true, 24 | "no-empty": true, 25 | "no-eval": true, 26 | "no-string-literal": true, 27 | "no-trailing-whitespace": true, 28 | "no-unreachable": true, 29 | "one-line": [true, 30 | "check-open-brace", 31 | "check-catch", 32 | "check-else", 33 | "check-whitespace" 34 | ], 35 | "quotemark": [true, "single"], 36 | "radix": true, 37 | "semicolon": true, 38 | "triple-equals": [true, "allow-null-check"], 39 | "variable-name": false, 40 | "whitespace": [true, 41 | "check-branch", 42 | "check-decl", 43 | "check-operator", 44 | "check-separator", 45 | "check-type" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "streetsidesoftware.code-spell-checker", 4 | "visualstudioexptteam.vscodeintellicode", 5 | "editorconfig.editorconfig", 6 | "coenraads.bracket-pair-colorizer-2", 7 | "eamodio.gitlens", 8 | "christian-kohler.path-intellisense", 9 | "christian-kohler.npm-intellisense", 10 | "pkief.material-icon-theme", 11 | "joshbolduc.commitlint", 12 | "dbaeumer.vscode-eslint", 13 | "esbenp.prettier-vscode" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch via NPM", 9 | "request": "launch", 10 | "runtimeArgs": ["run-script", "start:bot"], 11 | "runtimeExecutable": "npm", 12 | "skipFiles": ["/**"], 13 | "type": "pwa-node" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["Middlewares"] 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:20 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | # Copy packages 12 | COPY src/packages/* ./src/packages/ 13 | 14 | #RUN npm install 15 | # If you are building your code for production 16 | RUN npm i --only=production 17 | 18 | # Bundle app source 19 | COPY . . 20 | 21 | CMD [ "npm", "run", "start:bot:prod" ] 22 | -------------------------------------------------------------------------------- /Dockerfile.backend: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:20 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | # Copy packages 12 | COPY src/packages/* ./src/packages/ 13 | 14 | #RUN npm install 15 | # If you are building your code for production 16 | RUN npm i --only=production 17 | 18 | # Bundle app source 19 | COPY . . 20 | 21 | CMD [ "npm", "run", "start:server:js:prod" ] 22 | -------------------------------------------------------------------------------- /Dockerfile.local: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:20 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | # Copy packages 12 | COPY src/packages/* ./src/packages/ 13 | 14 | #RUN npm install 15 | # If you are building your code for production 16 | RUN npm i 17 | RUN npm i nodemon -g 18 | 19 | # Bundle app source 20 | COPY . . 21 | 22 | CMD [ "npm", "run", "start:bot" ] 23 | -------------------------------------------------------------------------------- /copy-swindlers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define source, destination and files 4 | SOURCE="src/tensor/swindlers-temp" 5 | DESTINATION="src/tensor/temp" 6 | FILES=("group1-shard1of1.bin" "model.json" "vocab.json") 7 | 8 | # Execute the copy command for each file 9 | for FILE in "${FILES[@]}"; do 10 | cp "$SOURCE/$FILE" "$DESTINATION" 11 | done 12 | -------------------------------------------------------------------------------- /dataset/append-excel-copy-in-cases.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | // eslint-disable-next-line import/no-unresolved 4 | const phrases = require('./cases/true-negatives.json'); 5 | // const phrases = require('./cases/true-positives.json'); 6 | // eslint-disable-next-line import/no-unresolved 7 | const parsedPhrases = require('./temp/parse.json'); 8 | 9 | const newPhrases = [...parsedPhrases, ...phrases]; 10 | 11 | fs.writeFileSync('./cases/true-negatives.json', `${JSON.stringify(newPhrases, null, 2)}\n`); 12 | // fs.writeFileSync('./cases/true-positives.json', `${JSON.stringify(newPhrases, null, 2)}\n`); 13 | -------------------------------------------------------------------------------- /dataset/append-excel-copy-in-high_risk.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | const highRisk = require('./strings/high_risk.json'); 4 | // eslint-disable-next-line import/no-unresolved 5 | const parsedHighRisk = require('./temp/parse.json'); 6 | 7 | const newHighRisk = [...parsedHighRisk, ...highRisk]; 8 | 9 | fs.writeFileSync('./strings/high_risk.json', `${JSON.stringify(newHighRisk, null, 2)}\n`); 10 | -------------------------------------------------------------------------------- /dataset/append-excel-copy-in-locations.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | const locations = require('./strings/locations.json'); 4 | // eslint-disable-next-line import/no-unresolved 5 | const parsedLocations = require('./temp/parse.json'); 6 | 7 | const newLocations = [...parsedLocations, ...locations]; 8 | 9 | fs.writeFileSync('./strings/locations.json', `${JSON.stringify(newLocations, null, 2)}\n`); 10 | -------------------------------------------------------------------------------- /dataset/auto-strict-words.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | const locations = require('./strings/locations.json'); 4 | const strictLocations = require('./strings/strict_locations.json'); 5 | 6 | const highRisk = require('./strings/high_risk.json'); 7 | const strictHighRisk = require('./strings/strict_high_risk.json'); 8 | 9 | const symbolsCount = 5; 10 | 11 | function processDataset(fullDataset, strictDataset) { 12 | const newFullDataset = fullDataset.filter((item) => item.length > symbolsCount); 13 | const shortDataset = fullDataset.filter((item) => item.length <= symbolsCount); 14 | 15 | const newStrictDataset = [...shortDataset, ...strictDataset]; 16 | 17 | return { 18 | newFullDataset, 19 | newStrictDataset, 20 | }; 21 | } 22 | 23 | const locationsResult = processDataset(locations, strictLocations); 24 | const highRiskResult = processDataset(highRisk, strictHighRisk); 25 | 26 | fs.writeFileSync('./strings/locations.json', `${JSON.stringify(locationsResult.newFullDataset, null, 2)}\n`); 27 | fs.writeFileSync('./strings/strict_locations.json', `${JSON.stringify(locationsResult.newStrictDataset, null, 2)}\n`); 28 | 29 | fs.writeFileSync('./strings/high_risk.json', `${JSON.stringify(highRiskResult.newFullDataset, null, 2)}\n`); 30 | fs.writeFileSync('./strings/strict_high_risk.json', `${JSON.stringify(highRiskResult.newStrictDataset, null, 2)}\n`); 31 | -------------------------------------------------------------------------------- /dataset/cases/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /dataset/dataset-antisemitism.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import { removeRepeatedLettersUtil } from '../src/utils/remove-repeated-letters.util'; 4 | import { SearchSet } from '../src/utils/search-set'; 5 | 6 | import { processMessage, processTxtMessage } from './dataset-helpers'; 7 | 8 | /** 9 | * Urls 10 | * */ 11 | export const antisemitismDictionaryActionUrl = new URL('strings/antisemitism_action.txt', import.meta.url); 12 | export const antisemitismDictionaryNounsUrl = new URL('strings/antisemitism_nouns.txt', import.meta.url); 13 | export const antisemitismDictionaryThreadsUrl = new URL('strings/antisemitism_threads.txt', import.meta.url); 14 | /** 15 | * Logic 16 | * */ 17 | function processAntisemitismDictionary(datasetUrl: URL) { 18 | return processMessage(processTxtMessage(fs.readFileSync(datasetUrl).toString()).map((item) => removeRepeatedLettersUtil(item))); 19 | } 20 | 21 | export const antisemitismDictionary = { 22 | action: new SearchSet(processAntisemitismDictionary(antisemitismDictionaryActionUrl)), 23 | nouns: new SearchSet(processAntisemitismDictionary(antisemitismDictionaryNounsUrl)), 24 | threads: new SearchSet(processAntisemitismDictionary(antisemitismDictionaryThreadsUrl)), 25 | }; 26 | -------------------------------------------------------------------------------- /dataset/dataset-helpers.ts: -------------------------------------------------------------------------------- 1 | import CyrillicToTranslit from 'cyrillic-to-translit-js'; 2 | 3 | import { removeDuplicates } from '../src/utils/remove-duplicates.util'; 4 | 5 | const translitRus = CyrillicToTranslit({ preset: 'ru' }); 6 | const translitUa = CyrillicToTranslit({ preset: 'uk' }); 7 | 8 | export function translitReplace(text: string): string { 9 | return text.replaceAll('ї', 'i').replaceAll('є', 'e').replaceAll('ґ', 'g'); 10 | } 11 | 12 | export function processMessage(dataset: string[]): string[] { 13 | const translitRussianDataset = dataset.map((word) => translitReplace(translitRus.transform(word, ' '))); 14 | const translitUkrainianDataset = dataset.map((word) => translitReplace(translitUa.transform(word, ' '))); 15 | 16 | return removeDuplicates([...dataset, ...translitUkrainianDataset, ...translitRussianDataset]); 17 | } 18 | 19 | export function processTxtMessage(dataset: string): string[] { 20 | return dataset.toLowerCase().split('\n').filter(Boolean); 21 | } 22 | -------------------------------------------------------------------------------- /dataset/dataset-sort.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | const path = require('node:path'); 3 | 4 | const types = { 5 | ALPHABET: 'alphabet', 6 | SHORTEST: 'shortest', 7 | }; 8 | 9 | const type = types.ALPHABET; 10 | 11 | const datasetPaths = ['./strings', './cases']; 12 | 13 | datasetPaths.forEach((datasetPath) => { 14 | const files = fs 15 | .readdirSync(datasetPath) 16 | .filter((file) => file.split('.').splice(-1)[0] === 'json') 17 | .map((filePath) => `./${path.join(datasetPath, filePath)}`); 18 | 19 | const sortShortest = (a, b) => 20 | // ASC -> a.length - b.length 21 | // DESC -> b.length - a.length 22 | b.length - a.length; 23 | 24 | const sortAlphabet = (a, b) => { 25 | if (a < b) { 26 | return -1; 27 | } 28 | 29 | if (a > b) { 30 | return 1; 31 | } 32 | 33 | return 0; 34 | }; 35 | 36 | files.forEach((filePath) => { 37 | // eslint-disable-next-line global-require,import/no-dynamic-require 38 | const datasetFile = require(filePath); 39 | 40 | switch (type) { 41 | case types.SHORTEST: { 42 | datasetFile.sort(sortShortest); 43 | break; 44 | } 45 | 46 | case types.ALPHABET: { 47 | datasetFile.sort(sortAlphabet); 48 | break; 49 | } 50 | 51 | default: { 52 | throw new Error(`Unknown type: ${type}. Use one of these: ${Object.values(types)}`); 53 | } 54 | } 55 | 56 | fs.writeFileSync(filePath, `${JSON.stringify(datasetFile, null, 2)}\n`); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /dataset/download-swindler-dataset.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-process-exit */ 2 | import { initSwindlersContainer, swindlersGoogleService } from '../src/services'; 3 | 4 | import { autoSwindlers } from './auto-swindlers'; 5 | import { getSwindlersTopUsed } from './get-swindlers-top-used'; 6 | 7 | const cases = Promise.all([ 8 | swindlersGoogleService.getTrainingPositives(), 9 | swindlersGoogleService.getBots(), 10 | swindlersGoogleService.getTestingPositives(), 11 | swindlersGoogleService.getCards(), 12 | swindlersGoogleService.getUsers(), 13 | ]); 14 | 15 | console.info('Loading training messages...'); 16 | 17 | cases 18 | .then(async ([positives, newSwindlersBots, testPositives, swindlersCards, swindlersUsers]) => { 19 | console.info('Received training messages.'); 20 | 21 | const { swindlersUrlsService, swindlersCardsService } = await initSwindlersContainer(); 22 | 23 | getSwindlersTopUsed([...positives, ...testPositives]); 24 | await autoSwindlers( 25 | swindlersUrlsService, 26 | swindlersCardsService, 27 | [...positives, ...testPositives], 28 | newSwindlersBots, 29 | swindlersCards, 30 | swindlersUsers, 31 | ); 32 | 33 | process.exit(0); 34 | }) 35 | .catch((error) => { 36 | console.error('FATAL: Cannot get the cases. Reason:', error); 37 | }); 38 | -------------------------------------------------------------------------------- /dataset/get-channels/.gitignore: -------------------------------------------------------------------------------- 1 | dialogs*.json 2 | -------------------------------------------------------------------------------- /dataset/get-channels/README.md: -------------------------------------------------------------------------------- 1 | Тут знаходиться спосіб як можна отримати список каналів з аккаунту. 2 | 3 | Необхідно: 4 | 5 | 1) Створити папку з каналами в телеграмі; 6 | 2) Відкрити Telegram Web, поставити розширення екрану в Хромі 9999px; 7 | 3) Запустити скрипт, почекати 30хв + - 8 | -------------------------------------------------------------------------------- /dataset/get-channels/validate-dialogs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop,no-restricted-syntax */ 2 | import fs from 'node:fs'; 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import * as input from 'input'; 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore 7 | import dialogs = require('./dialogs.json'); 8 | 9 | export interface Dialog { 10 | href: string; 11 | title: string; 12 | subtitle: string; 13 | isValid?: boolean; 14 | } 15 | 16 | const typedDialogs = dialogs as Dialog[]; 17 | 18 | (async () => { 19 | for (const [dialogIndex, dialog] of typedDialogs.entries()) { 20 | if (dialog.isValid === undefined) { 21 | typedDialogs[dialogIndex].isValid = await input.confirm(`${dialog.title} - ${dialog.subtitle}`); 22 | 23 | fs.writeFileSync('./dialogs.json', JSON.stringify(typedDialogs, null, 2)); 24 | } 25 | } 26 | 27 | fs.writeFileSync( 28 | './dialogs-rus.json', 29 | JSON.stringify( 30 | typedDialogs.filter((dialog) => dialog.isValid).map(({ href, title, subtitle }) => ({ href, title, subtitle })), 31 | null, 32 | 2, 33 | ), 34 | ); 35 | })().catch((error) => { 36 | console.error('Cannot run optimization due to:', error); 37 | }); 38 | -------------------------------------------------------------------------------- /dataset/get-swindlers-top-used.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import { getTopUsed } from './get-top-used'; 4 | 5 | /** 6 | * @param {string[]} dataset 7 | * */ 8 | export const getSwindlersTopUsed = (dataset: string[]) => { 9 | const whitelist = ['україн']; 10 | const sorted = getTopUsed(dataset, whitelist, ' '); 11 | const sortedTwo = getTopUsed(dataset, whitelist, ' ', (item2, index, self) => { 12 | if (index === dataset.length - 1) { 13 | return item2; 14 | } 15 | 16 | return `${item2} ${self[index + 1]}`; 17 | }); 18 | 19 | const result = {}; 20 | sorted.slice(0, 9).forEach((item) => { 21 | const [word, count] = item; 22 | result[word] = count; 23 | }); 24 | 25 | sortedTwo.slice(0, 20).forEach((item) => { 26 | const [word, count] = item; 27 | result[word] = count; 28 | }); 29 | 30 | console.info(sorted); 31 | console.info(sortedTwo); 32 | 33 | fs.writeFileSync(new URL('strings/swindlers_top_used.json', import.meta.url), JSON.stringify(result, null, 2)); 34 | }; 35 | -------------------------------------------------------------------------------- /dataset/get-top-used.ts: -------------------------------------------------------------------------------- 1 | import { optimizeText } from 'ukrainian-ml-optimizer'; 2 | 3 | /** 4 | * @param {string[]} array 5 | * @param {string[]} whitelist 6 | * @param {string} split 7 | * @param {(v: string) => string} additionalMap 8 | * */ 9 | export function getTopUsed( 10 | array: string[], 11 | whitelist: string[] = [], 12 | split = ' ', 13 | additionalMap: (v: string, index: number, self: string[]) => string = (v) => v, 14 | ) { 15 | const words: Map = new Map(); 16 | 17 | array.forEach((item) => 18 | optimizeText(item) 19 | .trim() 20 | .split(split) 21 | .map((element, index, self) => additionalMap(element, index, self)) 22 | .filter((word) => optimizeText(word)) 23 | .filter((word) => word.length > 3 && !whitelist.includes(word)) 24 | .forEach((word) => { 25 | const optimizedWord = optimizeText(word); 26 | words.set(optimizedWord, (words.get(optimizedWord) || 0) + 1); 27 | }), 28 | ); 29 | 30 | return [...words.entries()].sort((a, b) => b[1] - a[1]); 31 | } 32 | -------------------------------------------------------------------------------- /dataset/merge-old-and-new.js: -------------------------------------------------------------------------------- 1 | // const fs = require('fs'); 2 | // 3 | // const arrOld = require('./rules.json'); 4 | // const arr = require('./temp/rules-final.json'); 5 | // 6 | // arrOld.dataset.percent_100 = arrOld.dataset.percent_100.filter((word) => !arr.dataset.percent_100.includes(word)); 7 | // arrOld.dataset.percent_100 = arrOld.dataset.percent_100.filter((word) => !arr.dataset.strict_percent_100.includes(word)); 8 | // 9 | // fs.writeFileSync('./temp/sort-locations.json', JSON.stringify(arrOld, null, 2)); 10 | -------------------------------------------------------------------------------- /dataset/normalize-dataset.pipeline.sh: -------------------------------------------------------------------------------- 1 | node ./auto-strict-words.js && \ 2 | node ./remove-duplicates && \ 3 | node ./smart-remove-duplicates && \ 4 | node ./dataset-sort.js 5 | -------------------------------------------------------------------------------- /dataset/obscene-optimization.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import { removeDuplicates } from '../src/utils'; 4 | 5 | import { antisemitismDictionaryActionUrl, antisemitismDictionaryNounsUrl, antisemitismDictionaryThreadsUrl } from './dataset-antisemitism'; 6 | import { obsceneDictionaryEnUrl, obsceneDictionaryRuUrl, obsceneDictionaryUaUrl } from './dataset-obscene'; 7 | 8 | const filesToOptimize: URL[] = [ 9 | obsceneDictionaryUaUrl, 10 | obsceneDictionaryRuUrl, 11 | obsceneDictionaryEnUrl, 12 | antisemitismDictionaryActionUrl, 13 | antisemitismDictionaryNounsUrl, 14 | antisemitismDictionaryThreadsUrl, 15 | ]; 16 | 17 | const processDictionary = (text: string): string[] => 18 | removeDuplicates( 19 | text 20 | .toLowerCase() 21 | .split('\n') 22 | .filter(Boolean) 23 | .sort((a, b) => a.localeCompare(b)), 24 | ); 25 | 26 | filesToOptimize.forEach((url) => { 27 | const file = fs.readFileSync(url).toString(); 28 | fs.writeFileSync(url, `${processDictionary(file).join('\n')}\n`); 29 | console.info('Optimize file:', url.pathname); 30 | }); 31 | 32 | console.info('Done'); 33 | -------------------------------------------------------------------------------- /dataset/optimize-dataset-client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | Optimize Dataset Client 9 | 10 | 11 | 12 | 13 | 14 | 15 |

Optimize Dataset Client

16 | 17 | 18 | 26 | 27 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /dataset/optimize-dataset-client/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Roboto', sans-serif; 3 | } 4 | 5 | h4, ul { 6 | padding: 0; 7 | margin: 0; 8 | } 9 | 10 | .optimize-list { 11 | padding-left: 0; 12 | } 13 | 14 | .optimize-item { 15 | display: flex; 16 | position: relative; 17 | padding-bottom: 24px; 18 | margin-bottom: 48px; 19 | border-bottom: 1px solid #ccc; 20 | } 21 | 22 | .optimize-item-list { 23 | width: 50%; 24 | } 25 | 26 | .optimize-item__remove { 27 | position: absolute; 28 | top: 4px; 29 | right: 4px; 30 | } 31 | 32 | .optimize-item__case { 33 | padding: 4px; 34 | margin-bottom: 16px; 35 | } 36 | 37 | .positive { 38 | background: #fcc; 39 | } 40 | 41 | .negative { 42 | background: #cfc; 43 | } 44 | -------------------------------------------------------------------------------- /dataset/optimize-dataset-server.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | const path = require('node:path'); 3 | 4 | const express = require('express'); 5 | // eslint-disable-next-line import/no-extraneous-dependencies 6 | const cors = require('cors'); 7 | const { env } = require('typed-dotenv').config(); 8 | 9 | const { googleService } = require('../src/services/google.service'); 10 | 11 | // eslint-disable-next-line import/no-unresolved 12 | const optimizeResult = require('./temp/optimize-result.json'); 13 | 14 | const app = express(); 15 | 16 | app.use(express.json()); 17 | app.use(cors()); 18 | 19 | app.get('/optimize', (request, res) => { 20 | res.json(optimizeResult); 21 | }); 22 | 23 | app.post('/remove', (request, res) => { 24 | const { range, index, negativeIndex, type } = request.body; 25 | 26 | switch (type) { 27 | case 'positive': { 28 | optimizeResult[index].positive.resolved = true; 29 | break; 30 | } 31 | 32 | case 'negative': { 33 | optimizeResult[index].negativesMatch[negativeIndex].resolved = true; 34 | break; 35 | } 36 | 37 | default: { 38 | optimizeResult[index].resolved = true; 39 | } 40 | } 41 | 42 | fs.writeFileSync(path.join(__dirname, './temp/optimize-result.json'), JSON.stringify(optimizeResult, null, 2)); 43 | 44 | googleService.removeSheetRange(env.GOOGLE_SPREADSHEET_ID, range).then(() => { 45 | res.send({ status: 'ok' }); 46 | }); 47 | }); 48 | 49 | app.listen(3050); 50 | -------------------------------------------------------------------------------- /dataset/parse-excel-cases.pipeline.sh: -------------------------------------------------------------------------------- 1 | node ./parse-excel-copy.js && \ 2 | node ./append-excel-copy-in-cases.js && \ 3 | bash ./normalize-dataset.pipeline.sh 4 | -------------------------------------------------------------------------------- /dataset/parse-excel-copy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * It parses ./temp/parse.txt file. 3 | * You can pass there text copied from Excel and get JSON words 4 | * */ 5 | const fs = require('node:fs'); 6 | 7 | const file = fs.readFileSync('./temp/parse.txt').toString(); 8 | 9 | const fileWords = file 10 | .split('\n') 11 | .map((row) => row.trim()) 12 | .filter(Boolean) 13 | .flatMap((row) => row.split('\t')); 14 | 15 | fs.writeFileSync('./temp/parse.json', JSON.stringify(fileWords, null, 2)); 16 | -------------------------------------------------------------------------------- /dataset/parse-excel-copy.pipeline.sh: -------------------------------------------------------------------------------- 1 | node ./parse-excel-copy.js && \ 2 | node ./append-excel-copy-in-locations.js && \ 3 | bash ./normalize-dataset.pipeline.sh 4 | -------------------------------------------------------------------------------- /dataset/prepare-tensor-csv-dataset.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | // eslint-disable-next-line import/no-unresolved 4 | const truePositives = require('./cases/true-positives.json'); 5 | // eslint-disable-next-line import/no-unresolved 6 | const trueNegative = require('./cases/true-negatives.json'); 7 | 8 | const csvFileRows = ['commenttext,spam']; 9 | 10 | function processDatasetCase(item) { 11 | return item 12 | .replace(/[^\da-z\u0400-\u04FF]/gi, ' ') 13 | .replace(/\s\s+/g, ' ') 14 | .trim(); 15 | } 16 | 17 | const truePositivesRows = truePositives.map(processDatasetCase).map((item) => `${item},true`); 18 | const trueNegativeRows = trueNegative.map(processDatasetCase).map((item) => `${item},false`); 19 | 20 | const wordsCount = [...truePositivesRows, ...trueNegativeRows].map((word) => word.split(' ').length).sort((a, b) => b - a); 21 | 22 | fs.writeFileSync('./temp/tensor-csv-dataset.stats.txt', wordsCount.join('\n')); 23 | fs.writeFileSync('./temp/tensor-csv-dataset.csv', [...csvFileRows, ...truePositivesRows, ...trueNegativeRows].join('\n')); 24 | -------------------------------------------------------------------------------- /dataset/process-bot-trained-files.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | // eslint-disable-next-line import/no-unresolved 4 | const positives = require('../positives.json'); 5 | // eslint-disable-next-line import/no-unresolved 6 | const negatives = require('../negatives.json'); 7 | 8 | const processDataset = (array) => array.map((item) => item.replace(/\n/g, ' ')).join('\n'); 9 | 10 | fs.writeFileSync('../positives.csv', processDataset(positives)); 11 | fs.writeFileSync('../negatives.csv', processDataset(negatives)); 12 | 13 | fs.renameSync('../positives.json', '../positives.old.json'); 14 | fs.renameSync('../negatives.json', '../negatives.old.json'); 15 | -------------------------------------------------------------------------------- /dataset/remove-duplicates.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | const path = require('node:path'); 3 | 4 | function removeDuplicates(array) { 5 | return [...new Set(array)]; 6 | } 7 | 8 | const datasetPaths = ['./strings', './cases']; 9 | 10 | datasetPaths.forEach((datasetPath) => { 11 | const files = fs 12 | .readdirSync(datasetPath) 13 | .filter((file) => file.split('.').splice(-1)[0] === 'json') 14 | .map((filePath) => `./${path.join(datasetPath, filePath)}`); 15 | 16 | files.forEach((filePath) => { 17 | // eslint-disable-next-line global-require,import/no-dynamic-require 18 | const datasetFile = removeDuplicates(require(filePath)); 19 | fs.writeFileSync(filePath, `${JSON.stringify(datasetFile, null, 2)}\n`); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /dataset/remove-similar-logic.ts: -------------------------------------------------------------------------------- 1 | import stringSimilarity from 'string-similarity'; 2 | 3 | export interface RemoveSimilarProperties { 4 | first: string; 5 | second: string; 6 | rate: number; 7 | } 8 | 9 | export interface RemoveSimilarResult { 10 | result: number; 11 | rate: number; 12 | isSame: boolean; 13 | } 14 | 15 | export function execute({ first, second, rate }: RemoveSimilarProperties, callback: (value: RemoveSimilarResult) => void) { 16 | const result = stringSimilarity.compareTwoStrings(first, second); 17 | callback({ result, rate, isSame: result > rate }); 18 | } 19 | -------------------------------------------------------------------------------- /dataset/smart-remove-duplicates.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | const strictLocations = require('./strings/strict_locations.json'); 4 | const locations = require('./strings/locations.json'); 5 | 6 | const strictHighRisk = require('./strings/strict_high_risk.json'); 7 | const highRisk = require('./strings/high_risk.json'); 8 | 9 | const strictPercent100 = require('./strings/strict_percent_100.json'); 10 | const percent100 = require('./strings/percent_100.json'); 11 | 12 | const smartRemoveDuplicates = (mainArray, duplicateArrays) => 13 | mainArray.filter((word) => !duplicateArrays.flat().some((item) => item.toLowerCase() === word.toLowerCase())); 14 | 15 | const writeFile = (path, json) => { 16 | fs.writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`); 17 | }; 18 | 19 | const newPercent100 = smartRemoveDuplicates(percent100, [strictPercent100]); 20 | const newHighRisk = smartRemoveDuplicates(highRisk, [strictHighRisk, newPercent100, strictPercent100]); 21 | const newLocations = smartRemoveDuplicates(locations, [strictLocations]); 22 | 23 | writeFile('./strings/percent_100.json', newPercent100); 24 | writeFile('./strings/high_risk.json', newHighRisk); 25 | writeFile('./strings/locations.json', newLocations); 26 | -------------------------------------------------------------------------------- /dataset/strings/.gitignore: -------------------------------------------------------------------------------- 1 | swindlers_top_used.json 2 | -------------------------------------------------------------------------------- /dataset/strings/antisemitism_action.txt: -------------------------------------------------------------------------------- 1 | 1993 2 | cжигаем 3 | виноват 4 | виноваты 5 | вистріл 6 | вистрілами 7 | вистріли 8 | вистрілив 9 | вистрілила 10 | вистрілили 11 | вистрілило 12 | вистрілять 13 | выстрел 14 | выстрела 15 | выстрелами 16 | выстрелил 17 | выстрелила 18 | выстрелили 19 | выстрелило 20 | выстрелов 21 | выстрелом 22 | выстрелы 23 | выстрелят 24 | геноцид 25 | геноцида 26 | загроза 27 | знесений 28 | знесено 29 | знесли 30 | знищать 31 | знищена 32 | знищений 33 | знищеними 34 | знищених 35 | знищені 36 | знищення 37 | знищено 38 | знищеного 39 | знищеною 40 | знищену 41 | знищив 42 | знищила 43 | знищите 44 | знищить 45 | знищувати 46 | знищує 47 | знищуємо 48 | знищують 49 | їбашать 50 | їбашили 51 | їбашило 52 | їбашить 53 | казнить 54 | обстрел 55 | обстрела 56 | обстрелами 57 | обстрелов 58 | обстрелом 59 | обстріл 60 | обстріли 61 | обстрілів 62 | обстрілом 63 | обстрілу 64 | сжигаем 65 | сжигать 66 | снесен 67 | снесено 68 | снесли 69 | спалювати 70 | стреляем 71 | стреляет 72 | стреляли 73 | стрелять 74 | стреляют 75 | стріляє 76 | стріляємо 77 | стріляли 78 | стрілянина 79 | стрілянини 80 | стріляють 81 | убивали 82 | убивать 83 | убивають 84 | убил 85 | убить 86 | уничтожаем 87 | уничтожает 88 | уничтожат 89 | уничтожать 90 | уничтожают 91 | уничтожен 92 | уничтожена 93 | уничтожение 94 | уничтожено 95 | уничтожил 96 | уничтожила 97 | уничтожит 98 | уничтожить 99 | херакнули 100 | херакнуло 101 | херачать 102 | херачили 103 | херачило 104 | херачить 105 | хєрачать 106 | хєрачило 107 | хєрачить 108 | холокост 109 | холокосте 110 | -------------------------------------------------------------------------------- /dataset/strings/antisemitism_nouns.txt: -------------------------------------------------------------------------------- 1 | евреев 2 | евреи 3 | еврей 4 | еврейский 5 | еврейской 6 | еврею 7 | евреям 8 | евреями 9 | євреєві 10 | євреєм 11 | євреї 12 | євреїв 13 | єврей 14 | єврею 15 | єврея 16 | євреям 17 | євреями 18 | євреях 19 | хасид 20 | хасида 21 | хасидам 22 | хасидах 23 | хасиде 24 | хасиди 25 | хасидизм 26 | хасидов 27 | хасидом 28 | хасидский 29 | хасиду 30 | хасиды 31 | -------------------------------------------------------------------------------- /dataset/strings/antisemitism_threads.txt: -------------------------------------------------------------------------------- 1 | жид 2 | жида 3 | жидам 4 | жидами 5 | жидах 6 | жиде 7 | жидівка 8 | жидов 9 | жидовня 10 | жидовская 11 | жидовский 12 | жидоеб 13 | жидоёб 14 | жидоебка 15 | жидоёбка 16 | жидоебок 17 | жидоебская 18 | жидоёбская 19 | жидоебский 20 | жидоёбский 21 | жидоебским 22 | жидоёбским 23 | жидоебскими 24 | жидоёбскими 25 | жидоебских 26 | жидоёбских 27 | жидоебского 28 | жидоёбского 29 | жидоебское 30 | жидоёбское 31 | жидоебской 32 | жидоёбской 33 | жидоебском 34 | жидоёбском 35 | жидоебскому 36 | жидоёбскому 37 | жидоебскую 38 | жидоёбскую 39 | жидоебскый 40 | жидоёбскый 41 | жидоебскым 42 | жидоёбскым 43 | жидоебскыми 44 | жидоёбскыми 45 | жидоебскых 46 | жидоёбскых 47 | жидоести 48 | жидом 49 | жидоморда 50 | жиду 51 | жиды 52 | жидюга 53 | жидяр 54 | жидяра 55 | жидяры 56 | израиловка 57 | израиловки 58 | пархатий 59 | -------------------------------------------------------------------------------- /dataset/strings/immediately.json: -------------------------------------------------------------------------------- 1 | [ 2 | "@RSOTM_Z_BOT", 3 | "@alinaaaawwaa выложила очень много влажного и интересного 😍💦", 4 | "t.me/DiyyaUkraine24_bot", 5 | "https://t.me/+1sO6X0xpf-FmZjRi", 6 | "https://t.me/+BPx1pKydql45NzNi", 7 | "https://t.me/+YS_wZLCoTK4wMTgy", 8 | "https://t.me/+hqAliUEeZLY4M2Zi", 9 | "https://t.me/+idCYOuTGkgM4ZDYy", 10 | "https://t.me/+tbhMTQ7FHjNhOGRi", 11 | "https://t.me/Diya_UkraineGroup", 12 | "https://t.me/IDPhelpOON", 13 | "https://t.me/WalkenAirdrop_bot?start=r07014079250", 14 | "https://t.me/anna_chekhovatg", 15 | "https://t.me/diia_ua_gov_bot", 16 | "https://t.me/ePidtrimka_gov_ua_bot", 17 | "https://t.me/payppbbot", 18 | "https://t.me/privatbank_help2", 19 | "https://t.me/toprobotadoma", 20 | "https://t.me/ua_dia_bot" 21 | ] 22 | -------------------------------------------------------------------------------- /dataset/strings/location_types.json: -------------------------------------------------------------------------------- 1 | [ 2 | "c.", 3 | "алея", 4 | "братів", 5 | "будівельника", 6 | "бульв.", 7 | "вул.", 8 | "вул. ", 9 | "в’їзд", 10 | "дорога", 11 | "жилий", 12 | "завулок", 13 | "кв-л", 14 | "лінія", 15 | "м-р", 16 | "майдан", 17 | "масив", 18 | "містечко", 19 | "набережна", 20 | "острів", 21 | "парк", 22 | "пл.", 23 | "поля", 24 | "пров.", 25 | "просп.", 26 | "проїзд", 27 | "селек", 28 | "селище", 29 | "спуск", 30 | "станція", 31 | "тупік", 32 | "узвіз", 33 | "урочище", 34 | "хутір", 35 | "школа", 36 | "шлях", 37 | "шосе" 38 | ] 39 | -------------------------------------------------------------------------------- /dataset/strings/obscene_dictionary_en_whitelist.txt: -------------------------------------------------------------------------------- 1 | // These words are not right trimmed so we need to exclude them 2 | ass 3 | azz 4 | bbw 5 | kkk 6 | xxx 7 | -------------------------------------------------------------------------------- /dataset/strings/obscene_dictionary_ru_whitelist.txt: -------------------------------------------------------------------------------- 1 | // These words are not right trimmed so we need to exclude them 2 | неебу 3 | -------------------------------------------------------------------------------- /dataset/strings/obscene_dictionary_ua_whitelist.txt: -------------------------------------------------------------------------------- 1 | // These words are not right trimmed so we need to exclude them 2 | ссав 3 | ссався 4 | ссала 5 | ссали 6 | ссана 7 | ссаний 8 | ссаним 9 | ссаними 10 | ссанина 11 | ссаниною 12 | ссанині 13 | ссаних 14 | ссанню 15 | ссання 16 | ссаному 17 | ссаною 18 | ссаної 19 | ссану 20 | ссаную 21 | ссані 22 | ссаніна 23 | ссаніною 24 | ссати 25 | ссать 26 | -------------------------------------------------------------------------------- /dataset/strings/one_word.json: -------------------------------------------------------------------------------- 1 | [ 2 | "байрактар", 3 | "бахкало", 4 | "боеприпас", 5 | "бомба", 6 | "боєприпас", 7 | "бімба", 8 | "вертушка", 9 | "взрив", 10 | "вибух", 11 | "горить танк", 12 | "град", 13 | "грохот", 14 | "дрг", 15 | "зброя", 16 | "знову щось летить", 17 | "искандер", 18 | "кулемет", 19 | "летит самолет", 20 | "летить", 21 | "літак", 22 | "мина", 23 | "миномет", 24 | "мосин", 25 | "мосинка", 26 | "мосін", 27 | "уничтoжил", 28 | "працюють сапери", 29 | "працює ппо", 30 | "працює зсу", 31 | "працює байрактар", 32 | "працює стінгер", 33 | "мосінка", 34 | "міна", 35 | "міномет", 36 | "орудие", 37 | "оружие", 38 | "працює байрактар", 39 | "працює стінгер", 40 | "працює зсу", 41 | "працює ппо", 42 | "пулемет", 43 | "ракета", 44 | "самольот", 45 | "снаряд", 46 | "танк", 47 | "іскандер", 48 | "їде танк" 49 | ] 50 | -------------------------------------------------------------------------------- /dataset/strings/strict_high_risk.json: -------------------------------------------------------------------------------- 1 | [ 2 | "АК47", 3 | "ББМ", 4 | "БЛА", 5 | "БМП", 6 | "БПЛА", 7 | "БТР", 8 | "ВСУ", 9 | "ЗСУ", 10 | "ПВО", 11 | "ПММ", 12 | "ППО", 13 | "РСЗВ", 14 | "б`є", 15 | "бахає", 16 | "бачу", 17 | "бенз", 18 | "бмп", 19 | "бои", 20 | "бомби", 21 | "бомбы", 22 | "бої", 23 | "бтр", 24 | "впала", 25 | "впало", 26 | "газ", 27 | "горел", 28 | "горів", 29 | "град", 30 | "гуде", 31 | "гул", 32 | "дамба", 33 | "дрон", 34 | "катер", 35 | "колеи", 36 | "колея", 37 | "колія", 38 | "колії", 39 | "куль", 40 | "кулі", 41 | "лес", 42 | "летел", 43 | "летів", 44 | "ліс", 45 | "літак", 46 | "літає", 47 | "метка", 48 | "мєтка", 49 | "мєтки", 50 | "мітка", 51 | "поле", 52 | "пост", 53 | "пули", 54 | "пуля", 55 | "ружье", 56 | "суета", 57 | "суеты", 58 | "танк", 59 | "терО", 60 | "треш", 61 | "удар", 62 | "чутно", 63 | "чую" 64 | ] 65 | -------------------------------------------------------------------------------- /dataset/strings/strict_percent_100.json: -------------------------------------------------------------------------------- 1 | [ 2 | "BTR", 3 | "NLAW", 4 | "drg", 5 | "Армія", 6 | "БМП", 7 | "БТР", 8 | "Бетер", 9 | "Бтр", 10 | "Взрыв", 11 | "Вибив", 12 | "Вибух", 13 | "Воюют", 14 | "Выбил", 15 | "Гупає", 16 | "ДНР", 17 | "Дрон", 18 | "Дрона", 19 | "Дрони", 20 | "Дроны", 21 | "Збив", 22 | "Збили", 23 | "Збита", 24 | "Збите", 25 | "Збито", 26 | "Збиті", 27 | "Зброю", 28 | "Зброя", 29 | "Зброї", 30 | "КА-58", 31 | "ЛНР", 32 | "Летят", 33 | "Літав", 34 | "Менты", 35 | "Метро", 36 | "ПВО", 37 | "ПЗРК", 38 | "ППО", 39 | "Плен", 40 | "Полон", 41 | "Ракет", 42 | "СУ-30", 43 | "Сбили", 44 | "Сбито", 45 | "Схрон", 46 | "ТРО", 47 | "Танк", 48 | "Танке", 49 | "Танки", 50 | "Танку", 51 | "армія", 52 | "бʼють", 53 | "бетер", 54 | "бьют", 55 | "вибух", 56 | "ворог", 57 | "горит", 58 | "горят", 59 | "град", 60 | "града", 61 | "дрг", 62 | "ежами", 63 | "ежей", 64 | "ежов", 65 | "калаш", 66 | "колон", 67 | "копам", 68 | "копи", 69 | "копов", 70 | "копы", 71 | "копів", 72 | "литак", 73 | "менты", 74 | "метки", 75 | "метро", 76 | "мины", 77 | "міни", 78 | "мітки", 79 | "нлав", 80 | "омон", 81 | "палає", 82 | "пожар", 83 | "полон", 84 | "рпг", 85 | "русня", 86 | "су-29", 87 | "схрон", 88 | "танк", 89 | "штурм", 90 | "ёжик" 91 | ] 92 | -------------------------------------------------------------------------------- /dataset/temp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /dataset/tensor-vocab-to-json.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | const vocabTxt = fs.readFileSync('./temp/vocab.txt').toString(); 4 | const vocabJson = vocabTxt 5 | .trim() 6 | .split('\n') 7 | .map((row) => row.split(' ')[0]); 8 | 9 | fs.writeFileSync('./temp/vocab.json', JSON.stringify(vocabJson, null, 2)); 10 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | bot: 5 | container_name: "uaasb_bot" 6 | build: 7 | dockerfile: Dockerfile.local 8 | volumes: 9 | - ".:/usr/src/app" 10 | - "/usr/src/app/node_modules" 11 | env_file: ".env" 12 | depends_on: 13 | # - postgres 14 | - redis 15 | environment: 16 | REDIS_URL: redis://redis:6379 17 | restart: unless-stopped 18 | logging: 19 | driver: "json-file" 20 | options: 21 | max-size: "50m" 22 | # 23 | # backend: 24 | # container_name: "uaasb_backend" 25 | # build: ./Dockerfile.backend 26 | # volumes: 27 | # - ".:/app" 28 | # - "/app/node_modules" 29 | # ports: 30 | # - "${PORT}:${PORT}" 31 | # env_file: ".env" 32 | # restart: unless-stopped 33 | # logging: 34 | # driver: "json-file" 35 | # options: 36 | # max-size: "50m" 37 | 38 | # postgres: 39 | # container_name: "uaasb_postgres" 40 | # image: "postgres:13.6-alpine" 41 | # ports: 42 | # - "${PGPORT}:${PGPORT}" 43 | # env_file: ".env" 44 | 45 | redis: 46 | container_name: "uaasb_redis" 47 | image: 'redis:6.0-alpine' 48 | ports: 49 | - '6379:6379' 50 | 51 | volumes: 52 | data_backend_libs: 53 | driver: local 54 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-module */ 2 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 3 | const jestConfig = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | transform: { 7 | '^.+\\.(t|j)sx?$': ['@swc/jest'], 8 | }, 9 | }; 10 | 11 | export default jestConfig; 12 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": [ 4 | "./telegraf-session.json", 5 | "./last-ctx.json", 6 | "./positives.json", 7 | "./negatives.json", 8 | "node_modules" 9 | ], 10 | "exec": "tsx", 11 | "watch": ["src", ".env", "package.json", "tsconfig.json"] 12 | } 13 | -------------------------------------------------------------------------------- /src/@types/tinyld.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'tinyld/heavy' { 2 | export * from 'tinyld/dist/tinyld.heavy.node'; 3 | } 4 | -------------------------------------------------------------------------------- /src/__mocks__/bot.mocks.ts: -------------------------------------------------------------------------------- 1 | export const realSwindlerMessage = ` 2 | ❕Нова виплата від держави у розмірі 6500 гривень❕ 3 | 4 | ❗️Обмежана кількість виплат❗️ 5 | 6 | ❕Якщо вам не вдалось отримати виплату з першого разу спробуйте ще раз! 7 | 8 | 9 | 🏪Приват24 За необхідною інформацією як і що робити звертайте до @dariya5821 10 | 11 | 🏪Ваша виплата становить:6500₴ ❗️ 12 | Для того щоб отримати кошти натисніть на одне із 2 посиланнь 13 | ❕В зв’язку в великим навантаженням на сервіс час очікування зарахування коштів 15 хвилин 14 | 15 | 16 | ❤️Все буде Україна🇺🇦 17 | `.trim(); 18 | -------------------------------------------------------------------------------- /src/assets/logs-chat-profile-photo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoC-OSS/ua-anti-spam-bot/21d52e64009266b73bcf19577bd1107d095be40e/src/assets/logs-chat-profile-photo.jpeg -------------------------------------------------------------------------------- /src/bot-express.server.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import express from 'express'; 3 | import type { Bot } from 'grammy'; 4 | 5 | import { apiRouter } from './express-logic/api.router'; 6 | import { environmentConfig } from './config'; 7 | import type { GrammyContext } from './types'; 8 | 9 | export const runBotExpressServer = (bot: Bot) => { 10 | const app = express(); 11 | app.use(express.json()); 12 | app.use(cors({ origin: environmentConfig.FRONTEND_HOST })); 13 | app.use('/api', apiRouter(bot)); 14 | 15 | app.get('/health-check', (request, response) => response.json({ status: 'ok' })); 16 | 17 | app.listen(environmentConfig.BOT_PORT, environmentConfig.BOT_HOST, () => { 18 | console.info(`Bot-server started on https://${environmentConfig.BOT_HOST}:${environmentConfig.BOT_PORT}`); 19 | }); 20 | 21 | return app; 22 | }; 23 | -------------------------------------------------------------------------------- /src/bot/commands/command-setter.ts: -------------------------------------------------------------------------------- 1 | import type { Bot } from 'grammy'; 2 | import type { BotCommand } from 'typegram'; 3 | 4 | import type { GrammyContext } from '../../types'; 5 | import { formatDateIntoAccusative } from '../../utils'; 6 | 7 | /** 8 | * Handles bot public available commands 9 | * @see https://grammy.dev/guide/commands.html#usage 10 | * */ 11 | export class CommandSetter { 12 | commands: BotCommand[] = []; 13 | 14 | /** 15 | * @param {Bot} bot 16 | * @param {Date} startTime 17 | * @param {boolean} active 18 | * */ 19 | constructor(private bot: Bot, private startTime: Date, private active: boolean) {} 20 | 21 | /** 22 | * @description 23 | * Returns status depending on bot active status 24 | * 25 | * @returns {string} 26 | * */ 27 | buildStatus() { 28 | const activeStatus = this.active ? '🟢 Онлайн' : '🔴 Офлайн'; 29 | return `${activeStatus}, оновлений у ${formatDateIntoAccusative(this.startTime).replace(/GMT\+\d/, '')}`; 30 | } 31 | 32 | /** 33 | * @param {boolean} active 34 | */ 35 | async setActive(active: boolean) { 36 | this.active = active; 37 | await this.updateCommands(); 38 | } 39 | 40 | /** 41 | * @description 42 | * Build new commands and set them into the bot 43 | * */ 44 | async updateCommands() { 45 | /** 46 | * @param {BotCommand[]} 47 | * */ 48 | this.commands = [ 49 | { command: 'start', description: '🇺🇦 Почати роботу' }, 50 | { command: 'help', description: '🙋🏻 Отримати допомогу' }, 51 | { command: 'settings', description: '⚙️ Налаштування' }, 52 | { command: 'hotline_security', description: '🚓 Гаряча лінія з цифрової безпеки' }, 53 | { command: 'status', description: this.buildStatus() }, 54 | ]; 55 | 56 | await this.bot.api.setMyCommands(this.commands); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/bot/commands/index.ts: -------------------------------------------------------------------------------- 1 | export * from './command-setter'; 2 | export * from './private'; 3 | export * from './public'; 4 | -------------------------------------------------------------------------------- /src/bot/commands/private/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rank.command'; 2 | export * from './session.command'; 3 | export * from './statistics.command'; 4 | export * from './swindlers-update.command'; 5 | export * from './updates.command'; 6 | -------------------------------------------------------------------------------- /src/bot/commands/private/session.command.ts: -------------------------------------------------------------------------------- 1 | import { InputFile } from 'grammy'; 2 | 3 | import { creatorId } from '../../../creator'; 4 | import { redisClient } from '../../../db'; 5 | import type { GrammyMiddleware } from '../../../types'; 6 | 7 | export class SessionCommand { 8 | /** 9 | * @param {Date} startTime 10 | * */ 11 | constructor(private startTime: Date) {} 12 | 13 | /** 14 | * Handle /session 15 | * Returns session file 16 | * */ 17 | middleware(): GrammyMiddleware { 18 | /** 19 | * @param {GrammyContext} context 20 | * */ 21 | return async (context) => { 22 | const chatId = context?.update?.message?.chat?.id; 23 | 24 | if (chatId === creatorId) { 25 | const sessions = await redisClient.getAllChatRecords(); 26 | const sessionDocument = new InputFile( 27 | Buffer.from(JSON.stringify({ sessions }, null, 2)), 28 | `telegraf-chat-session-${this.startTime.toISOString()}.json`, 29 | ); 30 | await context.replyWithDocument(sessionDocument); 31 | } 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/bot/commands/private/swindlers-update.command.ts: -------------------------------------------------------------------------------- 1 | import { swindlersUpdateEndMessage, swindlersUpdateStartMessage } from '../../../message'; 2 | import type { DynamicStorageService } from '../../../services'; 3 | import type { GrammyContext } from '../../../types'; 4 | 5 | export class SwindlersUpdateCommand { 6 | /** 7 | * @param {DynamicStorageService} dynamicStorageService 8 | * */ 9 | 10 | constructor(private dynamicStorageService: DynamicStorageService) {} 11 | 12 | /** 13 | * Handle /swindlers_update 14 | * */ 15 | middleware() { 16 | /** 17 | * @param {GrammyContext} context 18 | * */ 19 | return async (context: GrammyContext) => { 20 | await context.reply(swindlersUpdateStartMessage).then(async (message) => { 21 | // eslint-disable-next-line camelcase 22 | const { message_id } = message; 23 | // eslint-disable-next-line camelcase,@typescript-eslint/ban-ts-comment 24 | // @ts-ignore 25 | // eslint-disable-next-line camelcase 26 | await this.dynamicStorageService.updateStorage().then(() => context.editMessageText(swindlersUpdateEndMessage, { message_id })); 27 | }); 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/bot/commands/public/help.command.ts: -------------------------------------------------------------------------------- 1 | import { getHelpMessage } from '../../../message'; 2 | import type { GrammyMiddleware } from '../../../types'; 3 | import { formatDate, getUserData, handleError } from '../../../utils'; 4 | 5 | export class HelpCommand { 6 | /** 7 | * @param {Date} startTime 8 | * */ 9 | constructor(private startTime: Date) {} 10 | 11 | /** 12 | * Handle /help 13 | * Returns help message 14 | * */ 15 | middleware(): GrammyMiddleware { 16 | /** 17 | * @param {GrammyContext} context 18 | * */ 19 | return async (context) => { 20 | const startLocaleTime = formatDate(this.startTime); 21 | 22 | const isAdmin = context.chatSession.isBotAdmin; 23 | let canDelete = false; 24 | 25 | try { 26 | canDelete = await context 27 | .deleteMessage() 28 | .then(() => true) 29 | .catch(() => false); 30 | } catch (error) { 31 | handleError(error); 32 | } 33 | 34 | const { writeUsername, userId } = getUserData(context); 35 | 36 | if (!userId) { 37 | throw new Error('Invalid user id'); 38 | } 39 | 40 | context 41 | .replyWithSelfDestructedHTML( 42 | getHelpMessage({ 43 | startLocaleTime, 44 | isAdmin, 45 | canDelete, 46 | user: writeUsername === '@GroupAnonymousBot' ? '' : writeUsername, 47 | userId, 48 | }), 49 | ) 50 | .catch(handleError); 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/bot/commands/public/index.ts: -------------------------------------------------------------------------------- 1 | export * from './air-raid-alarm/locations-menu-generator'; 2 | export * from './help.command'; 3 | export * from './settings.command'; 4 | export * from './start.command'; 5 | -------------------------------------------------------------------------------- /src/bot/commands/public/start.command.ts: -------------------------------------------------------------------------------- 1 | import { getGroupStartMessage, getStartMessage, makeAdminMessage } from '../../../message'; 2 | import type { GrammyMiddleware } from '../../../types'; 3 | import { getUserData, handleError, telegramUtil } from '../../../utils'; 4 | 5 | export class StartCommand { 6 | /** 7 | * Handle /start 8 | * Returns help message 9 | * 10 | * */ 11 | middleware(): GrammyMiddleware { 12 | /** 13 | * @param {GrammyContext} context 14 | * */ 15 | return async (context) => { 16 | if (context.chat?.type === 'private') { 17 | return context.replyWithHTML(getStartMessage()); 18 | } 19 | 20 | const isAdmin = context.chatSession.isBotAdmin; 21 | const canDelete = await context 22 | .deleteMessage() 23 | .then(() => true) 24 | .catch(() => false); 25 | 26 | const { writeUsername, userId } = getUserData(context); 27 | 28 | if (!isAdmin || !canDelete) { 29 | return context.replyWithSelfDestructedHTML( 30 | getGroupStartMessage({ isAdmin, canDelete, user: writeUsername === '@GroupAnonymousBot' ? '' : writeUsername, userId }), 31 | ); 32 | } 33 | 34 | if (!context.chat?.id) { 35 | throw new Error('StartMiddleware error: chat.id is undefined'); 36 | } 37 | 38 | return telegramUtil 39 | .getChatAdmins(context, context.chat?.id) 40 | .then(({ adminsString }) => { 41 | context 42 | .replyWithSelfDestructedHTML( 43 | getGroupStartMessage({ adminsString, isAdmin, canDelete, user: writeUsername === '@GroupAnonymousBot' ? '' : writeUsername }), 44 | ) 45 | .catch(async (getAdminsError) => { 46 | handleError(getAdminsError); 47 | await context.replyWithHTML(makeAdminMessage); 48 | }); 49 | }) 50 | .catch(handleError); 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/bot/composers/__tests__/before-any.composer.test.ts: -------------------------------------------------------------------------------- 1 | import { Bot } from 'grammy'; 2 | 3 | import type { OutgoingRequests } from '../../../testing'; 4 | import { MessageMockUpdate, prepareBotForTesting } from '../../../testing'; 5 | import type { GrammyContext } from '../../../types'; 6 | import { stateMiddleware } from '../../middleware'; 7 | import { getBeforeAnyComposer } from '../before-any.composer'; 8 | 9 | let outgoingRequests: OutgoingRequests; 10 | 11 | const bot = new Bot('mock'); 12 | const { beforeAnyComposer } = getBeforeAnyComposer(); 13 | 14 | describe('beforeAnyComposer', () => { 15 | beforeEach(() => { 16 | outgoingRequests.clear(); 17 | }); 18 | 19 | beforeAll(async () => { 20 | bot.use(stateMiddleware); 21 | bot.use(beforeAnyComposer); 22 | outgoingRequests = await prepareBotForTesting(bot); 23 | }, 5000); 24 | 25 | describe('my_chat_member', () => { 26 | describe('channel type', () => { 27 | it('should tell about not right chat for channel joining', () => { 28 | // TODO finish this test 29 | expect(outgoingRequests).toEqual(outgoingRequests); 30 | }); 31 | }); 32 | }); 33 | describe('message', () => { 34 | it('should identify is user admin', async () => { 35 | const update = new MessageMockUpdate('regular message').build(); 36 | await bot.handleUpdate(update); 37 | const expectedMethods = outgoingRequests.buildMethods(['getChatMember']); 38 | const actualMethods = outgoingRequests.getMethods(); 39 | expect(actualMethods).toEqual(expectedMethods); 40 | expect(outgoingRequests.length).toEqual(1); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/bot/composers/before-any.composer.ts: -------------------------------------------------------------------------------- 1 | import { Composer } from 'grammy'; 2 | 3 | import type { GrammyContext } from '../../types'; 4 | import { botDemoteQuery, botInviteQuery, botKickQuery, botPromoteQuery } from '../queries'; 5 | 6 | /** 7 | * @description Message handling composer 8 | * */ 9 | export const getBeforeAnyComposer = () => { 10 | const beforeAnyComposer = new Composer(); 11 | 12 | beforeAnyComposer.on('my_chat_member', botInviteQuery, botPromoteQuery, botDemoteQuery, botKickQuery); 13 | 14 | beforeAnyComposer.on('message', async (context, next) => { 15 | const fromId: number | undefined = context.from?.id; 16 | if (!fromId) { 17 | return next(); 18 | } 19 | 20 | if (context.chat?.type === 'private') { 21 | context.state.isUserAdmin = true; 22 | return next(); 23 | } 24 | const chatMember = await context.getChatMember(fromId); 25 | context.state.isUserAdmin = ['creator', 'administrator'].includes(chatMember.status); 26 | return next(); 27 | }); 28 | 29 | return { beforeAnyComposer }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/bot/composers/health-check.composer.ts: -------------------------------------------------------------------------------- 1 | import { Composer } from 'grammy'; 2 | 3 | import type { GrammyContext } from '../../types'; 4 | import { onlyCreatorFilter } from '../filters'; 5 | import { ignoreOld } from '../middleware'; 6 | 7 | /** 8 | * @description Health-check helper composer 9 | * */ 10 | export const getHealthCheckComposer = () => { 11 | const healthCheckComposer = new Composer(); 12 | 13 | const composer = healthCheckComposer.filter((context) => onlyCreatorFilter(context)); 14 | 15 | composer.command('break', ignoreOld(30), async (context) => { 16 | await context.reply('Breaking the bot for 1,5 min...\nIt should restart if health-check works.'); 17 | 18 | const end = Date.now() + 90_000; 19 | while (Date.now() < end) { 20 | // do something here ... 21 | } 22 | 23 | await context.reply('Bot is still alive. Health-check doesnt work'); 24 | }); 25 | 26 | return { healthCheckComposer }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/bot/composers/hotline-security.composer.ts: -------------------------------------------------------------------------------- 1 | import { Composer } from 'grammy'; 2 | 3 | import { swindlersHelpMessage } from '../../message'; 4 | import type { GrammyContext } from '../../types'; 5 | import { handleError } from '../../utils'; 6 | import { ignoreOld } from '../middleware'; 7 | 8 | /** 9 | * @description Health-check helper composer 10 | * */ 11 | export const getHotlineSecurityComposer = () => { 12 | const hotlineSecurityComposer = new Composer(); 13 | 14 | hotlineSecurityComposer.command('hotline_security', ignoreOld(30), async (context) => { 15 | await context.deleteMessage().catch(handleError); 16 | await context.replyWithSelfDestructedHTML(swindlersHelpMessage); 17 | }); 18 | 19 | return { hotlineSecurityComposer }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/bot/composers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './before-any.composer'; 2 | export * from './create-logs-chat.composer'; 3 | export * from './creator-command.composer'; 4 | export * from './health-check.composer'; 5 | export * from './hotline-security.composer'; 6 | export * from './join-leave.composer'; 7 | export * from './messages.composer'; 8 | export * from './photos.composer'; 9 | export * from './private-command.composer'; 10 | export * from './public-command.composer'; 11 | export * from './save-to-sheet.composer'; 12 | export * from './tensor-training.composer'; 13 | -------------------------------------------------------------------------------- /src/bot/composers/join-leave.composer.ts: -------------------------------------------------------------------------------- 1 | import type { ChatMember } from '@grammyjs/types/manage'; 2 | import { Composer } from 'grammy'; 3 | 4 | import type { GrammyContext } from '../../types'; 5 | import { onlyNotDeletedFilter, onlyWhenBotAdminFilter } from '../filters'; 6 | 7 | /** 8 | * @description Remove join and leave messages from chat 9 | * */ 10 | export const getJoinLeaveComposer = () => { 11 | const joinLeaveComposer = new Composer(); 12 | 13 | joinLeaveComposer 14 | .filter((context) => onlyWhenBotAdminFilter(context)) 15 | // Filter that feature is enabled 16 | .filter((context) => !context.chatSession.chatSettings.disableDeleteServiceMessage) 17 | // Filter if the message is already deleted 18 | .filter((context) => onlyNotDeletedFilter(context)) 19 | // Queries to filter 20 | .on([':new_chat_members', ':left_chat_member']) 21 | // Filter if the bot is not left chat member 22 | .filter((context: GrammyContext) => { 23 | const leftStatuses = new Set(['left', 'kicked']); 24 | return !(context.myChatMember && leftStatuses.has(context.myChatMember?.new_chat_member.status || 'left')); 25 | }) 26 | // Delete message 27 | .use(async (context, next) => { 28 | await context.deleteMessage(); 29 | return next(); 30 | }); 31 | 32 | return { joinLeaveComposer }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/bot/composers/messages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './no-cards.composer'; 2 | export * from './no-channel-messages.composer'; 3 | export * from './no-counteroffensive.composer'; 4 | export * from './no-forward.composer'; 5 | export * from './no-locations.composer'; 6 | export * from './no-mentions.composer'; 7 | export * from './no-obscene.composer'; 8 | export * from './no-russian.composer'; 9 | export * from './no-urls.composer'; 10 | export * from './nsfw-filter.composer'; 11 | export * from './strategic.composer'; 12 | export * from './swindlers.composer'; 13 | export * from './warn-obscene.composer'; 14 | export * from './warn-russian.composer'; 15 | -------------------------------------------------------------------------------- /src/bot/composers/messages/no-forward.composer.ts: -------------------------------------------------------------------------------- 1 | import { Composer } from 'grammy'; 2 | 3 | import { getDeleteFeatureMessage } from '../../../message'; 4 | import type { GrammyContext } from '../../../types'; 5 | import { getEnabledFeaturesString, getUserData } from '../../../utils'; 6 | 7 | /** 8 | * @description Remove strategic information logic 9 | * */ 10 | export const getNoForwardsComposer = () => { 11 | const noForwardsComposer = new Composer(); 12 | 13 | noForwardsComposer.use(async (context, next) => { 14 | const isFeatureEnabled = context.chatSession.chatSettings.enableDeleteForwards; 15 | const isForwarded = !!context.msg?.forward_origin; 16 | 17 | if (isFeatureEnabled && isForwarded) { 18 | await context.deleteMessage(); 19 | 20 | const { writeUsername, userId } = getUserData(context); 21 | const featuresString = getEnabledFeaturesString(context.chatSession.chatSettings); 22 | 23 | if (context.chatSession.chatSettings.disableDeleteMessage !== true) { 24 | await context.replyWithSelfDestructedHTML(getDeleteFeatureMessage({ writeUsername, userId, featuresString })); 25 | } 26 | } 27 | 28 | return next(); 29 | }); 30 | 31 | return { noForwardsComposer }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/bot/composers/messages/strategic.composer.ts: -------------------------------------------------------------------------------- 1 | import { Composer } from 'grammy'; 2 | 3 | import type { GrammyContext } from '../../../types'; 4 | import type { OnTextListener } from '../../listeners'; 5 | 6 | export interface StrategicComposerProperties { 7 | onTextListener: OnTextListener; 8 | } 9 | 10 | /** 11 | * @description Remove strategic information logic 12 | * */ 13 | export const getStrategicComposer = ({ onTextListener }: StrategicComposerProperties) => { 14 | const strategicComposer = new Composer(); 15 | 16 | strategicComposer.use(onTextListener.middleware()); 17 | 18 | return { strategicComposer }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/bot/composers/messages/swindlers.composer.ts: -------------------------------------------------------------------------------- 1 | import { Composer } from 'grammy'; 2 | 3 | import type { GrammyContext } from '../../../types'; 4 | import type { DeleteSwindlersMiddleware } from '../../middleware'; 5 | 6 | export interface SwindlersComposerProperties { 7 | deleteSwindlersMiddleware: DeleteSwindlersMiddleware; 8 | } 9 | 10 | /** 11 | * @description Remove swindler messages logic 12 | * */ 13 | export const getSwindlersComposer = ({ deleteSwindlersMiddleware }: SwindlersComposerProperties) => { 14 | const swindlersComposer = new Composer(); 15 | 16 | swindlersComposer.use(deleteSwindlersMiddleware.middleware()); 17 | 18 | return { swindlersComposer }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/bot/composers/public-command.composer.ts: -------------------------------------------------------------------------------- 1 | import { Composer } from 'grammy'; 2 | 3 | import { redisService } from '../../services'; 4 | import type { GrammyContext } from '../../types'; 5 | import { HelpCommand, SettingsCommand, StartCommand } from '../commands'; 6 | 7 | export interface PublicCommandsComposerProperties { 8 | startTime: Date; 9 | } 10 | 11 | /** 12 | * @description Public commands that are available for users 13 | * */ 14 | export const getPublicCommandsComposer = ({ startTime }: PublicCommandsComposerProperties) => { 15 | const publicCommandsComposer = new Composer(); 16 | 17 | /* Commands */ 18 | const startMiddleware = new StartCommand(); 19 | const helpMiddleware = new HelpCommand(startTime); 20 | const settingsMiddleware = new SettingsCommand(redisService); 21 | 22 | /* Command Register */ 23 | publicCommandsComposer.command('start', startMiddleware.middleware()); 24 | publicCommandsComposer.command(['help', 'status'], helpMiddleware.middleware()); 25 | publicCommandsComposer.command('settings', settingsMiddleware.middleware()); 26 | 27 | return { publicCommandsComposer }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/bot/composers/save-to-sheet.composer.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from '@grammyjs/menu'; 2 | import { Composer } from 'grammy'; 3 | import { isChatId } from 'grammy-guard'; 4 | 5 | import { messageQuery } from '../../const'; 6 | import type { swindlersGoogleService } from '../../services'; 7 | import type { GrammyContext, GrammyMenuContext } from '../../types'; 8 | import { onlyWithText, parseText, removeSystemInformationMiddleware } from '../middleware'; 9 | 10 | export interface SaveToSheetComposerProperties { 11 | chatId: number; 12 | rootMenu: Menu; 13 | updateMethod: typeof swindlersGoogleService.appendBot; 14 | } 15 | 16 | export const getSaveToSheetComposer = ({ chatId, rootMenu, updateMethod }: SaveToSheetComposerProperties) => { 17 | const saveToSheetComposer = new Composer(); 18 | 19 | const composer = saveToSheetComposer.filter(isChatId(chatId)); 20 | 21 | const menu = new Menu(`saveToSheetMenu_${chatId}`); 22 | 23 | menu 24 | .text('✅ Додати в базу', async (context) => { 25 | await context.deleteMessage(); 26 | await updateMethod(context.msg?.text || `$no_value_${chatId}`).catch(() => 27 | context.reply('Дуже погана помилка, терміново подивіться sheet!'), 28 | ); 29 | }) 30 | .text('⛔️ Не спам', async (context) => { 31 | await context.deleteMessage(); 32 | }); 33 | 34 | composer.on(messageQuery, parseText, onlyWithText, removeSystemInformationMiddleware, async (context) => { 35 | const text = context.state.clearText!; 36 | 37 | await context.deleteMessage(); 38 | await context.reply(text, { 39 | reply_markup: menu, 40 | }); 41 | }); 42 | 43 | rootMenu.register(menu); 44 | 45 | return { saveToSheetComposer, menu }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/bot/composers/tensor-training.composer.ts: -------------------------------------------------------------------------------- 1 | import type { Transformer } from 'grammy'; 2 | import { Composer } from 'grammy'; 3 | 4 | import { environmentConfig } from '../../config'; 5 | import { messageQuery } from '../../const'; 6 | import { trainingChat } from '../../creator'; 7 | import type { GrammyContext } from '../../types'; 8 | import type { TestTensorListener } from '../listeners'; 9 | import { onlyWithText, parseText } from '../middleware'; 10 | 11 | export interface TensorTrainingComposerProperties { 12 | tensorListener: TestTensorListener; 13 | trainingThrottler: Transformer; 14 | } 15 | 16 | /** 17 | * @description Message handling composer 18 | * */ 19 | export const getTensorTrainingComposer = ({ tensorListener, trainingThrottler }: TensorTrainingComposerProperties) => { 20 | const tensorTrainingComposer = new Composer(); 21 | 22 | /** 23 | * Only these messages will be processed in this composer 24 | * */ 25 | const composer = tensorTrainingComposer.filter((context) => context.chat?.id === trainingChat && environmentConfig.TEST_TENSOR); 26 | 27 | composer.on(messageQuery, parseText, onlyWithText, tensorListener.middleware(trainingThrottler)); 28 | 29 | return { tensorTrainingComposer }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/bot/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './is-not-channel.filter'; 2 | export * from './only-active-default-setting.filter'; 3 | export * from './only-active-optional-setting.filter'; 4 | export * from './only-creator.filter'; 5 | export * from './only-creator-chat.filter'; 6 | export * from './only-not-admin.filter'; 7 | export * from './only-not-deleted.filter'; 8 | export * from './only-when-bot-admin.filter'; 9 | export * from './only-whitelisted.filter'; 10 | export * from './only-with-photo.filter'; 11 | export * from './only-with-text.filter'; 12 | -------------------------------------------------------------------------------- /src/bot/filters/is-not-channel.filter.ts: -------------------------------------------------------------------------------- 1 | import { isChannel } from 'grammy-guard'; 2 | 3 | import type { GrammyFilter } from '../../types'; 4 | 5 | export const isNotChannel: GrammyFilter = (context) => !isChannel(context); 6 | -------------------------------------------------------------------------------- /src/bot/filters/middleware-to.filter.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | 3 | import type { GrammyContext, GrammyMiddleware } from '../../types'; 4 | 5 | export function middlewareToFilter(middleware: GrammyMiddleware) { 6 | let isNextCalled = false; 7 | 8 | const callNext: NextFunction = () => { 9 | isNextCalled = true; 10 | return Promise.resolve(); 11 | }; 12 | 13 | return async (context: GrammyContext): Promise => { 14 | await middleware(context, callNext); 15 | 16 | return isNextCalled; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/bot/filters/only-active-default-setting.filter.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultChatSettings, GrammyFilter } from '../../types'; 2 | 3 | /** 4 | * @returns {true} when default settings is enabled 5 | * */ 6 | export const onlyActiveDefaultSettingFilter = 7 | (key: keyof DefaultChatSettings): GrammyFilter => 8 | (context) => 9 | context.chatSession.chatSettings[key] !== true; 10 | -------------------------------------------------------------------------------- /src/bot/filters/only-active-optional-setting.filter.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyFilter, OptionalChatSettings } from '../../types'; 2 | 3 | /** 4 | * @returns {true} when optional settings is enabled 5 | * */ 6 | export const onlyActiveOptionalSettingFilter = 7 | (key: keyof OptionalChatSettings): GrammyFilter => 8 | (context) => 9 | context.chatSession.chatSettings[key] === true; 10 | -------------------------------------------------------------------------------- /src/bot/filters/only-creator-chat.filter.ts: -------------------------------------------------------------------------------- 1 | import { creatorId } from '../../creator'; 2 | import type { GrammyFilter } from '../../types'; 3 | 4 | /** 5 | * @description 6 | * Allow actions only it bot creator chat 7 | * */ 8 | export const onlyCreatorChatFilter: GrammyFilter = (context) => context.chat?.id === creatorId; 9 | -------------------------------------------------------------------------------- /src/bot/filters/only-creator.filter.ts: -------------------------------------------------------------------------------- 1 | import { creatorId } from '../../creator'; 2 | import type { GrammyContext } from '../../types'; 3 | 4 | /** 5 | * @description 6 | * Allow actions only for bot creator 7 | * */ 8 | export function onlyCreatorFilter(context: GrammyContext) { 9 | return context.from?.id === creatorId; 10 | } 11 | -------------------------------------------------------------------------------- /src/bot/filters/only-not-deleted.filter.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyContext } from '../../types'; 2 | 3 | export function onlyNotDeletedFilter(context: GrammyContext): boolean { 4 | return !context.state.isDeleted; 5 | } 6 | -------------------------------------------------------------------------------- /src/bot/filters/only-swindlers-statistic-whitelisted.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyContext } from '../../types'; 2 | import { isIdWhitelistedForSwindlersStatistic } from '../../utils/generic.util'; 3 | 4 | /** 5 | * @description 6 | * Allow actions only for bot whitelisted users 7 | * */ 8 | export function onlySwindlersStatisticWhitelistedFilter(context: GrammyContext) { 9 | return isIdWhitelistedForSwindlersStatistic(context.from?.id); 10 | } 11 | -------------------------------------------------------------------------------- /src/bot/filters/only-when-bot-admin.filter.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyFilter } from '../../types'; 2 | 3 | /** 4 | * @returns {true} when bot is admin 5 | * */ 6 | export const onlyWhenBotAdminFilter: GrammyFilter = (context) => { 7 | if (context.chat?.type === 'private') { 8 | return true; 9 | } 10 | 11 | const isMessageAfterBotAdmin = (context.msg?.date || 0) * 1000 > +new Date(context.chatSession.botAdminDate || 0); 12 | 13 | return !context.chatSession.botRemoved && !!context.chatSession.isBotAdmin && isMessageAfterBotAdmin; 14 | }; 15 | -------------------------------------------------------------------------------- /src/bot/filters/only-whitelisted.filter.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyContext } from '../../types'; 2 | import { isIdWhitelisted } from '../../utils'; 3 | 4 | /** 5 | * @description 6 | * Allow actions only for bot whitelisted users 7 | * */ 8 | export function onlyWhitelistedFilter(context: GrammyContext) { 9 | return isIdWhitelisted(context.from?.id); 10 | } 11 | -------------------------------------------------------------------------------- /src/bot/filters/only-with-photo.filter.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyFilter } from '../../types'; 2 | 3 | /** 4 | * Checks that state has photo 5 | * */ 6 | export const onlyWithPhotoFilter: GrammyFilter = (context) => !!context.state.photo; 7 | -------------------------------------------------------------------------------- /src/bot/filters/only-with-text.filter.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyFilter } from '../../types'; 2 | 3 | /** 4 | * Checks that state has text 5 | * */ 6 | export const onlyWithTextFilter: GrammyFilter = (context) => !!context.state.text; 7 | -------------------------------------------------------------------------------- /src/bot/listeners/index.ts: -------------------------------------------------------------------------------- 1 | export * from './on-text.listener'; 2 | export * from './test-tensor.listener'; 3 | -------------------------------------------------------------------------------- /src/bot/middleware-menu.menu.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from '@grammyjs/menu'; 2 | 3 | import type { GrammyMenuContext, GrammyMiddleware } from '../types'; 4 | 5 | /** 6 | * @description 7 | * Reimplementation of Grammy's menu to support menu-level middlewares. 8 | * */ 9 | export class MiddlewareMenu extends Menu { 10 | menuMiddlewares: GrammyMiddleware[] = []; 11 | 12 | addGlobalMiddlewares(...middlewares: GrammyMiddleware[]) { 13 | this.menuMiddlewares = middlewares; 14 | return this; 15 | } 16 | 17 | text(text: string | object, ...middleware: GrammyMiddleware[]) { 18 | const newMiddlewares = [...(this.menuMiddlewares || []), ...middleware]; 19 | 20 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 21 | // @ts-ignore 22 | return this.add(typeof text === 'object' ? { ...text, middleware: newMiddlewares } : { text, middleware: newMiddlewares }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/bot/middleware/admin-check-notify.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { checkAdminNotification } from '../../message'; 5 | import type { GrammyMiddleware } from '../../types'; 6 | import { logSkipMiddleware } from '../../utils'; 7 | /** 8 | * Used for notifying admins about checking their messages 9 | * */ 10 | export const adminCheckNotify: GrammyMiddleware = async (context: GrammyContext, next: NextFunction) => { 11 | const { isCheckAdminNotified } = context.chatSession; 12 | const { isDeleted, isUserAdmin } = context.state; 13 | const isChatNotPrivate = context.chat?.type !== 'private'; 14 | if (!isCheckAdminNotified && isUserAdmin && isDeleted && isChatNotPrivate) { 15 | return context.replyWithSelfDestructedHTML(checkAdminNotification as string); 16 | } 17 | 18 | logSkipMiddleware(context, 'bot kicked or not admin', context.chatSession); 19 | return next(); 20 | }; 21 | -------------------------------------------------------------------------------- /src/bot/middleware/bot-active.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { logSkipMiddleware } from '../../utils'; 5 | 6 | /** 7 | * Used for performance checking 8 | * */ 9 | export function botActiveMiddleware(context: GrammyContext, next: NextFunction) { 10 | // TODO use for ctx prod debug 11 | // console.info('enter botActiveMiddleware ******', ctx.chat?.title, '******', ctx.state.text); 12 | 13 | if (context.chat?.type !== 'private' && !context.chatSession.botRemoved && context.chatSession.isBotAdmin) { 14 | return next(); 15 | } 16 | 17 | if (context.chat?.type === 'private') { 18 | return next(); 19 | } 20 | 21 | logSkipMiddleware(context, 'bot kicked or not admin', context.chatSession); 22 | } 23 | -------------------------------------------------------------------------------- /src/bot/middleware/bot-redis-active.middleware.ts: -------------------------------------------------------------------------------- 1 | import { creatorId } from '../../creator'; 2 | import { redisService } from '../../services'; 3 | import type { GrammyMiddleware } from '../../types'; 4 | 5 | export const botRedisActive: GrammyMiddleware = async (context, next) => { 6 | const isDeactivated = await redisService.getIsBotDeactivated(); 7 | const isInLocal = context.chat?.type === 'private' && context.chat?.id === creatorId; 8 | 9 | if (!isDeactivated || isInLocal) { 10 | return next(); 11 | } 12 | 13 | console.info('Skip due to redis:', context.chat?.id); 14 | }; 15 | -------------------------------------------------------------------------------- /src/bot/middleware/debug.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyMiddleware } from '../../types'; 2 | 3 | export const debugMiddleware = 4 | (name: string): GrammyMiddleware => 5 | (context, next) => { 6 | console.info(name, context); 7 | return next(); 8 | }; 9 | -------------------------------------------------------------------------------- /src/bot/middleware/delete-message.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyMiddleware } from '../../types'; 2 | 3 | /** 4 | * Delete user entered message 5 | * 6 | * @param reason - why bot could not delete the message 7 | * */ 8 | export const deleteMessageMiddleware = 9 | (reason: string): GrammyMiddleware => 10 | (context, next) => { 11 | if (context.chatSession.isBotAdmin) { 12 | return context 13 | .deleteMessage() 14 | .then(next) 15 | .catch(() => (reason ? context.replyWithHTML(reason) : null)); 16 | } 17 | 18 | return context.replyWithHTML(reason); 19 | }; 20 | -------------------------------------------------------------------------------- /src/bot/middleware/delete-spam-media-groups.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | 3 | import { spamMediaGroupsStorage } from '../../services'; 4 | import type { GrammyContext } from '../../types'; 5 | 6 | /** 7 | * deleteSpamMediaGroup middleware 8 | * 9 | * If the message is part of a media group and the media group is spam, 10 | * then delete this message. 11 | * */ 12 | export async function deleteSpamMediaGroupMiddleware(context: GrammyContext, next: NextFunction) { 13 | const isMediaGroup = context.message?.media_group_id; 14 | 15 | if (!isMediaGroup) { 16 | return next(); 17 | } 18 | 19 | const { isDeleted } = context.state; 20 | const isSpam = spamMediaGroupsStorage.isSpamMediaGroup(context); 21 | 22 | if (isMediaGroup && !isDeleted && isSpam) { 23 | await context.deleteMessage(); 24 | } 25 | 26 | return next(); 27 | } 28 | -------------------------------------------------------------------------------- /src/bot/middleware/ignore-by-default-settings.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultChatSettings, GrammyMiddleware } from '../../types'; 2 | 3 | export const ignoreByDefaultSettingsMiddleware = 4 | (key: keyof DefaultChatSettings): GrammyMiddleware => 5 | async (context, next) => { 6 | if (context.chatSession.chatSettings[key] !== true) { 7 | await next(); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/bot/middleware/ignore-old.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyMiddleware } from '../../types'; 2 | 3 | export const ignoreOld = 4 | (threshold = 5 * 60): GrammyMiddleware => 5 | (context, next) => { 6 | const date = context.editedMessage?.edit_date || context.msg?.date; 7 | if (date && Date.now() / 1000 - date > threshold) { 8 | console.info( 9 | `Ignoring message from user ${context.from?.id || '$unknown'} at chat ${context.chat?.id || '$unknown'} (${ 10 | Date.now() / 1000 11 | }:${date})`, 12 | ); 13 | return; 14 | } 15 | 16 | return next(); 17 | }; 18 | -------------------------------------------------------------------------------- /src/bot/middleware/index.ts: -------------------------------------------------------------------------------- 1 | export * from './admin-check-notify.middleware'; 2 | export * from './bot-active.middleware'; 3 | export * from './bot-redis-active.middleware'; 4 | export * from './debug.middleware'; 5 | export * from './delete-message.middleware'; 6 | export * from './delete-spam-media-groups.middleware'; 7 | export * from './delete-swindlers.middleware'; 8 | export * from './global.middleware'; 9 | export * from './ignore-by-default-settings.middleware'; 10 | export * from './ignore-old.middleware'; 11 | export * from './log-context.middleware'; 12 | export * from './log-creator-state.middleware'; 13 | export * from './log-parsed-photos.middleware'; 14 | export * from './nested.middleware'; 15 | export * from './only-admin.middleware'; 16 | export * from './only-creator.middleware'; 17 | export * from './only-not-admin.middleware'; 18 | export * from './only-not-forwarded.middleware'; 19 | export * from './only-when-bot-admin.middleware'; 20 | export * from './only-whitelisted'; 21 | export * from './only-with-photo.middleware'; 22 | export * from './only-with-text.middleware'; 23 | export * from './parse-cards.middleware'; 24 | export * from './parse-entities.middleware'; 25 | export * from './parse-is-counteroffensive.middleware'; 26 | export * from './parse-is-russian.middleware'; 27 | export * from './parse-locations.middleware'; 28 | export * from './parse-mentions.middleware'; 29 | export * from './parse-photo.middleware'; 30 | export * from './parse-text.middleware'; 31 | export * from './parse-urls.middleware'; 32 | export * from './parse-video-frames.middleware'; 33 | export * from './performance-end.middleware'; 34 | export * from './performance-start.middleware'; 35 | export * from './remove-system-information.middleware'; 36 | export * from './save-spam-media-group.middleware'; 37 | export * from './state.middleware'; 38 | export * from './throw-error.middleware'; 39 | -------------------------------------------------------------------------------- /src/bot/middleware/log-context.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyMiddleware } from '../../types'; 2 | import { logContext } from '../../utils'; 3 | 4 | export const logContextMiddleware: GrammyMiddleware = (context, next) => { 5 | logContext(context); 6 | 7 | return next(); 8 | }; 9 | -------------------------------------------------------------------------------- /src/bot/middleware/log-creator-state.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyMiddleware } from '../../types'; 2 | import { optimizeWriteContextUtil } from '../../utils'; 3 | 4 | export const logCreatorState: GrammyMiddleware = async (context, next) => { 5 | const writeContext = optimizeWriteContextUtil(context); 6 | 7 | await context.reply(JSON.stringify({ isDeleted: !!writeContext.state.isDeleted, ...writeContext.state }, null, 2), { 8 | reply_to_message_id: context.state.isDeleted ? undefined : context.msg?.message_id, 9 | }); 10 | return next(); 11 | }; 12 | -------------------------------------------------------------------------------- /src/bot/middleware/log-parsed-photos.middleware.ts: -------------------------------------------------------------------------------- 1 | import { InputFile } from 'grammy'; 2 | import { isPrivate } from 'grammy-guard'; 3 | 4 | import { environmentConfig } from '../../config'; 5 | import type { GrammyMiddleware } from '../../types'; 6 | import { onlyCreatorFilter } from '../filters'; 7 | 8 | /** 9 | * Logs parsed photos 10 | * */ 11 | export const logParsedPhotosMiddleware: GrammyMiddleware = async (context, next) => { 12 | const { photo, isDeleted } = context.state; 13 | const isValidToLog = onlyCreatorFilter(context) || (isPrivate(context) && environmentConfig.ENV !== 'production'); 14 | 15 | if (isValidToLog && photo && 'fileFrames' in photo && photo.fileFrames) { 16 | const files = photo.fileFrames.map((frame, index) => new InputFile(frame, `${photo.meta.file_id}${index}.png`)); 17 | 18 | await context.replyWithMediaGroup( 19 | files.map((file) => ({ type: 'photo', media: file, caption: 'Parsed photo gallery screenshots' })), 20 | { reply_to_message_id: isDeleted ? undefined : context.msg?.message_id }, 21 | ); 22 | } 23 | 24 | return next(); 25 | }; 26 | -------------------------------------------------------------------------------- /src/bot/middleware/nested.middleware.test.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { nestedMiddleware } from './nested.middleware'; 5 | 6 | const mockContext = {} as Partial as GrammyContext; 7 | 8 | describe('nestedMiddleware', () => { 9 | it('should call all middlewares', async () => { 10 | const mockMiddleware = jest.fn(async (context, next: NextFunction) => { 11 | await next(); 12 | }); 13 | 14 | const finalMockMiddleware = jest.fn(); 15 | 16 | await nestedMiddleware( 17 | async (context: GrammyContext, next: NextFunction) => { 18 | await next(); 19 | }, 20 | mockMiddleware, 21 | mockMiddleware, 22 | )(mockContext, finalMockMiddleware); 23 | 24 | expect(mockMiddleware).toHaveBeenCalledTimes(2); 25 | expect(finalMockMiddleware).toHaveBeenCalled(); 26 | }); 27 | 28 | it('should not call all middlewares', async () => { 29 | const mockMiddleware = jest.fn(async (context: GrammyContext, next: NextFunction) => { 30 | await next(); 31 | }); 32 | 33 | const finalMockMiddleware = jest.fn(); 34 | 35 | await nestedMiddleware( 36 | async () => { 37 | // we're not calling this 38 | // await next(); 39 | }, 40 | mockMiddleware, 41 | mockMiddleware, 42 | )(mockContext, finalMockMiddleware); 43 | 44 | expect(mockMiddleware).not.toHaveBeenCalled(); 45 | expect(finalMockMiddleware).toHaveBeenCalled(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/bot/middleware/nested.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext, GrammyMiddleware } from 'types'; 3 | 4 | /** 5 | * @param {GrammyMiddleware} middlewares 6 | * @returns {GrammyMiddleware} 7 | * */ 8 | export const nestedMiddleware = 9 | (...middlewares: GrammyMiddleware[]) => 10 | async (context: GrammyContext, next: NextFunction) => { 11 | // eslint-disable-next-line no-restricted-syntax 12 | for (const middleware of middlewares) { 13 | let isNextCalled = false; 14 | 15 | // eslint-disable-next-line unicorn/consistent-function-scoping 16 | const localNext = () => { 17 | isNextCalled = true; 18 | return Promise.resolve(); 19 | }; 20 | 21 | // eslint-disable-next-line no-await-in-loop 22 | await middleware(context, localNext); 23 | 24 | if (!isNextCalled) { 25 | break; 26 | } 27 | } 28 | 29 | await next(); 30 | }; 31 | -------------------------------------------------------------------------------- /src/bot/middleware/only-admin.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { Chat, ChatMember } from '@grammyjs/types/manage'; 2 | import type { NextFunction } from 'grammy'; 3 | import type { GrammyContext } from 'types'; 4 | 5 | import { logSkipMiddleware } from '../../utils'; 6 | 7 | export const onlyAdmin = async (context: GrammyContext, next: NextFunction) => { 8 | // No chat = no service 9 | if (!context.chat) { 10 | logSkipMiddleware(context, 'User is not admin'); 11 | return; 12 | } 13 | 14 | // Channels and private chats are only postable by admins 15 | const defaultAdminChatType = new Set(['channel', 'private']); 16 | 17 | if (defaultAdminChatType.has(context.chat.type)) { 18 | return next(); 19 | } 20 | 21 | // Anonymous users are always admins 22 | if (context.from?.username === 'GroupAnonymousBot') { 23 | return next(); 24 | } 25 | 26 | // Surely not an admin 27 | if (!context.from?.id) { 28 | logSkipMiddleware(context, 'User is not admin'); 29 | return; 30 | } 31 | 32 | /** 33 | * Check the member status 34 | * 35 | * @description 36 | * 'creator', 'administrator' - for valid statuses. 37 | * 'left' - for anonymous admins when bot is not admin. 38 | * */ 39 | const adminStatuses = new Set(['creator', 'administrator', 'left']); 40 | const userStatuses = new Set(['member']); 41 | 42 | const chatMember = await context.getChatMember(context.from.id); 43 | if (adminStatuses.has(chatMember.status)) { 44 | return next(); 45 | } 46 | 47 | // Regular user 48 | if (userStatuses.has(chatMember.status)) { 49 | logSkipMiddleware(context, 'User is a regular member'); 50 | return; 51 | } 52 | 53 | logSkipMiddleware(context, 'User is neither admin nor regular'); 54 | }; 55 | -------------------------------------------------------------------------------- /src/bot/middleware/only-creator.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { getDeclinedMassSendingMessage } from '../../message'; 5 | import { onlyCreatorFilter } from '../filters'; 6 | 7 | /** 8 | * @description 9 | * Allow actions only for bot creator 10 | * */ 11 | export async function onlyCreator(context: GrammyContext, next: NextFunction) { 12 | if (onlyCreatorFilter(context)) { 13 | return next(); 14 | } 15 | 16 | await context.reply(getDeclinedMassSendingMessage); 17 | } 18 | -------------------------------------------------------------------------------- /src/bot/middleware/only-not-admin.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { logSkipMiddleware } from '../../utils'; 5 | import { onlyNotAdminFilter } from '../filters'; 6 | /** 7 | * @description 8 | * Allow to execute next middlewares only if the user is not admin 9 | * 10 | * Reversed copy from 11 | * @see https://github.com/backmeupplz/grammy-middlewares/blob/main/src/middlewares/onlyAdmin.ts 12 | * */ 13 | export async function onlyNotAdmin(context: GrammyContext, next: NextFunction) { 14 | const isNotAdmin = onlyNotAdminFilter(context); 15 | const isAdminCheckEnabled = context.chatSession.chatSettings.enableAdminCheck; 16 | if (isNotAdmin || isAdminCheckEnabled) { 17 | return next(); 18 | } 19 | logSkipMiddleware(context, 'message is older than bot admin'); 20 | } 21 | -------------------------------------------------------------------------------- /src/bot/middleware/only-not-forwarded.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { logSkipMiddleware } from '../../utils'; 5 | 6 | /** 7 | * @description 8 | * Allow to skip a forwarded message 9 | * */ 10 | export function onlyNotForwarded(context: GrammyContext, next: NextFunction) { 11 | // TODO use for ctx prod debug 12 | // console.info('enter onlyNotForwarded ******', ctx.chat?.title, '******', ctx.state.text); 13 | 14 | /** 15 | * Skip forwarded messages 16 | * */ 17 | if (context.update?.message?.forward_origin) { 18 | logSkipMiddleware(context, 'regular forward'); 19 | return; 20 | } 21 | 22 | return next(); 23 | } 24 | -------------------------------------------------------------------------------- /src/bot/middleware/only-when-bot-admin.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { logSkipMiddleware } from '../../utils'; 5 | import { onlyWhenBotAdminFilter } from '../filters'; 6 | 7 | /** 8 | * @description 9 | * Skip messages before bot became admin 10 | * */ 11 | export function onlyWhenBotAdmin(context: GrammyContext, next: NextFunction) { 12 | const isBotAdmin = onlyWhenBotAdminFilter(context); 13 | 14 | if (isBotAdmin) { 15 | return next(); 16 | } 17 | 18 | logSkipMiddleware(context, 'message is older than bot admin'); 19 | } 20 | -------------------------------------------------------------------------------- /src/bot/middleware/only-whitelisted.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { getDeclinedMassSendingMessage } from '../../message'; 5 | import { isIdWhitelisted } from '../../utils'; 6 | 7 | /** 8 | * @description 9 | * Allow actions only for whitelisted users 10 | * */ 11 | export async function onlyWhitelisted(context: GrammyContext, next: NextFunction) { 12 | if (isIdWhitelisted(context.from?.id)) { 13 | return next(); 14 | } 15 | 16 | await context.reply(getDeclinedMassSendingMessage); 17 | } 18 | -------------------------------------------------------------------------------- /src/bot/middleware/only-with-photo.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { logSkipMiddleware } from '../../utils'; 5 | 6 | /** 7 | * @description 8 | * Skip messages without ф photo 9 | * */ 10 | export function onlyWithPhoto(context: GrammyContext, next: NextFunction) { 11 | if (context.state.photo) { 12 | return next(); 13 | } 14 | 15 | logSkipMiddleware(context, 'no photo'); 16 | } 17 | -------------------------------------------------------------------------------- /src/bot/middleware/only-with-text.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { logSkipMiddleware } from '../../utils'; 5 | 6 | /** 7 | * @description 8 | * Skip messages without text and add text into state 9 | * */ 10 | export function onlyWithText(context: GrammyContext, next: NextFunction) { 11 | if (context.state.text) { 12 | return next(); 13 | } 14 | 15 | logSkipMiddleware(context, 'no text'); 16 | } 17 | -------------------------------------------------------------------------------- /src/bot/middleware/parse-cards.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { cardsService } from '../../services'; 5 | 6 | /** 7 | * @description 8 | * Add cards into state. Parses cards from parsed text from state, 9 | * */ 10 | export function parseCards(context: GrammyContext, next: NextFunction) { 11 | if (context.state.text && !context.state.cards) { 12 | context.state.cards = cardsService.parseCards(context.state.text); 13 | } 14 | 15 | return next(); 16 | } 17 | -------------------------------------------------------------------------------- /src/bot/middleware/parse-entities.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyMiddleware } from '../../types'; 2 | import type { StateEntity } from '../../types/state'; 3 | 4 | /** 5 | * It parses the value of entities and save it into the state 6 | * */ 7 | export const parseEntities: GrammyMiddleware = (context, next) => { 8 | const { text } = context.state; 9 | 10 | if (context.msg?.entities && text) { 11 | context.state.entities = context.msg.entities.map((entity): StateEntity => { 12 | switch (entity.type) { 13 | case 'text_link': { 14 | return { 15 | ...entity, 16 | value: entity.url, 17 | }; 18 | } 19 | 20 | case 'text_mention': { 21 | return { 22 | ...entity, 23 | value: entity.user, 24 | }; 25 | } 26 | 27 | default: { 28 | return { 29 | ...entity, 30 | value: text.slice(entity.offset, entity.offset + entity.length), 31 | }; 32 | } 33 | } 34 | }); 35 | } 36 | 37 | return next(); 38 | }; 39 | -------------------------------------------------------------------------------- /src/bot/middleware/parse-is-counteroffensive.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { CounteroffensiveService } from '../../services'; 2 | import type { GrammyMiddleware } from '../../types'; 3 | 4 | export const parseIsCounteroffensive = 5 | (counteroffensiveService: CounteroffensiveService): GrammyMiddleware => 6 | async (context, next) => { 7 | if (!context.state.text) { 8 | return next(); 9 | } 10 | 11 | if (context.state.isCounterOffensive === undefined) { 12 | context.state.isCounterOffensive = counteroffensiveService.isCounteroffensive(context.state.text); 13 | } 14 | 15 | return next(); 16 | }; 17 | -------------------------------------------------------------------------------- /src/bot/middleware/parse-is-russian.middleware.ts: -------------------------------------------------------------------------------- 1 | import { languageDetectService } from '../../services'; 2 | import type { GrammyMiddleware } from '../../types'; 3 | 4 | export const parseIsRussian: GrammyMiddleware = async (context, next) => { 5 | if (context.state.isRussian === undefined) { 6 | context.state.isRussian = languageDetectService.isRussian(context.state.text || ''); 7 | } 8 | 9 | return next(); 10 | }; 11 | -------------------------------------------------------------------------------- /src/bot/middleware/parse-locations.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { locationsService } from '../../services'; 5 | 6 | /** 7 | * @description 8 | * Add locations into state. 9 | * */ 10 | export function parseLocations(context: GrammyContext, next: NextFunction) { 11 | if (context.state.text && !context.state.locations) { 12 | const locations = locationsService.parseLocations(context.state.text); 13 | 14 | if (locations) { 15 | context.state.locations = locations; 16 | } 17 | } 18 | 19 | return next(); 20 | } 21 | -------------------------------------------------------------------------------- /src/bot/middleware/parse-mentions.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { mentionService } from '../../services'; 5 | import { removeDuplicates } from '../../utils'; 6 | 7 | /** 8 | * @description 9 | * Add mentions into state. Parses mentions from parsed text from state, 10 | * */ 11 | export function parseMentions(context: GrammyContext, next: NextFunction) { 12 | if (context.state.text && !context.state.mentions) { 13 | const mentions = mentionService.parseMentions(context.state.text); 14 | const entitiesMentions = 15 | context.state.entities 16 | ?.map((entity) => { 17 | if (entity.type === 'mention') { 18 | return entity.value; 19 | } 20 | 21 | if (entity.type === 'text_mention') { 22 | return entity.value.username; 23 | } 24 | 25 | return null; 26 | }) 27 | .filter((entity: string | undefined | null): entity is string => !!entity) || []; 28 | 29 | context.state.mentions = removeDuplicates([...mentions, ...entitiesMentions]); 30 | } 31 | 32 | return next(); 33 | } 34 | -------------------------------------------------------------------------------- /src/bot/middleware/parse-text.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from '@grammyjs/types'; 2 | import type { NextFunction } from 'grammy'; 3 | import type { GrammyContext } from 'types'; 4 | 5 | /** 6 | * Gets the text content from a given message. 7 | * 8 | * @param {Message | undefined} message - The message object from which to extract the text content. 9 | * @returns {string | undefined} The text content of the message if available, or undefined if the message is falsy or doesn't have any text content. 10 | */ 11 | const getTextFromMessage = (message: Message | undefined) => message && (message.text || message.caption || message.poll?.question); 12 | 13 | /** 14 | * @description 15 | * Add text into state 16 | * */ 17 | export function parseText(context: GrammyContext, next: NextFunction) { 18 | const text = getTextFromMessage(context.msg) || getTextFromMessage(context.editedMessage); 19 | 20 | if (text) { 21 | context.state.text = text; 22 | } 23 | 24 | return next(); 25 | } 26 | -------------------------------------------------------------------------------- /src/bot/middleware/parse-urls.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { GrammyContext } from 'types'; 3 | 4 | import { urlService } from '../../services'; 5 | import { removeDuplicates } from '../../utils'; 6 | 7 | /** 8 | * @description 9 | * Add URLs into state. Parses URLs from parsed text from state, 10 | * */ 11 | export function parseUrls(context: GrammyContext, next: NextFunction) { 12 | if (context.state.text && !context.state.urls) { 13 | const parsedUrls = urlService.parseUrls(context.state.text, true); 14 | const entitiesUrls = 15 | context.state.entities 16 | ?.map((entity) => { 17 | if (entity.type === 'url' || entity.type === 'text_link') { 18 | return entity.value; 19 | } 20 | 21 | return null; 22 | }) 23 | .filter((entity): entity is string => !!entity) || []; 24 | 25 | context.state.urls = removeDuplicates([...parsedUrls, ...entitiesUrls]); 26 | } 27 | 28 | return next(); 29 | } 30 | -------------------------------------------------------------------------------- /src/bot/middleware/performance-end.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | 3 | import { environmentConfig } from '../../config'; 4 | import type { GrammyContext } from '../../types'; 5 | 6 | /** 7 | * Used for performance checking 8 | * */ 9 | export async function performanceEndMiddleware(context: GrammyContext, next: NextFunction) { 10 | if (environmentConfig.DEBUG) { 11 | await context 12 | .replyWithHTML( 13 | [ 14 | `Time: ${performance.now() - (context.state?.performanceStart || 0)}`, 15 | '', 16 | 'Start:', 17 | context.state.performanceStart, 18 | '', 19 | 'End:', 20 | performance.now(), 21 | ].join('\n'), 22 | ) 23 | 24 | .then(() => next()); 25 | } else { 26 | return next(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/bot/middleware/performance-start.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | 3 | import { environmentConfig } from '../../config'; 4 | import type { GrammyContext } from '../../types'; 5 | 6 | /** 7 | * Used for performance checking 8 | * */ 9 | export function performanceStartMiddleware(context: GrammyContext, next: NextFunction) { 10 | if (environmentConfig.DEBUG) { 11 | context.state.performanceStart = performance.now(); 12 | } 13 | 14 | return next(); 15 | } 16 | -------------------------------------------------------------------------------- /src/bot/middleware/redis.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | import type { JsonObject } from 'type-fest'; 3 | 4 | import { redisClient } from '../../db'; 5 | import type { GrammyContext, RedisSessionOptions } from '../../types'; 6 | 7 | export class RedisMiddleware { 8 | constructor(private options: RedisSessionOptions) {} 9 | 10 | getSessionKey(context: GrammyContext): string { 11 | return this.options.getSessionKey(context); 12 | } 13 | 14 | getSession(key: string): Promise { 15 | return redisClient.getValue(key); 16 | } 17 | 18 | saveSession(key: string, data: JsonObject) { 19 | return redisClient.setValue(key, data); 20 | } 21 | 22 | middleware(property = this.options.property) { 23 | return async (context: GrammyContext, next: NextFunction) => { 24 | const key = this.getSessionKey(context); 25 | if (!key) return next(); 26 | let session = await this.getSession(key); 27 | Object.defineProperty(context, property, { 28 | get() { 29 | return session; 30 | }, 31 | set(newValue: JsonObject) { 32 | session = { ...newValue }; 33 | }, 34 | }); 35 | // Saving session object on the next middleware 36 | await next(); 37 | await this.saveSession(key, session); 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/bot/middleware/remove-system-information.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyMiddleware } from '../../types'; 2 | import { removeSystemInformationUtil } from '../../utils'; 3 | 4 | /** 5 | * Middleware function that removes system information from the text stored in the context state. 6 | */ 7 | export const removeSystemInformationMiddleware: GrammyMiddleware = (context, next) => { 8 | if (context.state.text) { 9 | context.state.clearText = removeSystemInformationUtil(context.state.text); 10 | } 11 | 12 | return next(); 13 | }; 14 | -------------------------------------------------------------------------------- /src/bot/middleware/save-spam-media-group.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction } from 'grammy'; 2 | 3 | import { spamMediaGroupsStorage } from '../../services'; 4 | import type { GrammyContext } from '../../types'; 5 | 6 | /** 7 | * saveSpamMediaGroup middleware. 8 | * 9 | * If the message is part of a media group and has been deleted, 10 | * then caches spam media group id. 11 | * */ 12 | export async function saveSpamMediaGroupMiddleware(context: GrammyContext, next: NextFunction) { 13 | const isMediaGroup = context.message?.media_group_id; 14 | const { isDeleted } = context.state; 15 | 16 | if (isMediaGroup && isDeleted) { 17 | spamMediaGroupsStorage.addSpamMediaGroup(context); 18 | } 19 | 20 | return next(); 21 | } 22 | -------------------------------------------------------------------------------- /src/bot/middleware/state.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyMiddleware } from '../../types'; 2 | 3 | /** 4 | * Add state into context 5 | * */ 6 | export const stateMiddleware: GrammyMiddleware = (context, next) => { 7 | if (!context.state) { 8 | context.state = {}; 9 | } 10 | return next(); 11 | }; 12 | -------------------------------------------------------------------------------- /src/bot/middleware/throw-error.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyMiddleware } from '../../types'; 2 | 3 | /** 4 | * @deprecated 5 | * @description Used to test global error handling 6 | * */ 7 | export const throwErrorMiddleware: GrammyMiddleware = () => { 8 | console.info('throwErrorMiddleware called'); 9 | throw new Error('Test error'); 10 | }; 11 | -------------------------------------------------------------------------------- /src/bot/plugins/auto-comment-reply.plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Context, Middleware, RawApi } from 'grammy'; 2 | import type { Methods, Payload } from 'grammy/out/core/client'; 3 | 4 | import { TELEGRAM_USER_ID } from '../../const'; 5 | 6 | const METHODS = new Set>([ 7 | 'sendMessage', 8 | 'sendPhoto', 9 | 'sendVideo', 10 | 'sendAnimation', 11 | 'sendAudio', 12 | 'sendDocument', 13 | 'sendSticker', 14 | 'sendVideoNote', 15 | 'sendVoice', 16 | 'sendLocation', 17 | 'sendVenue', 18 | 'sendContact', 19 | 'sendPoll', 20 | 'sendDice', 21 | 'sendInvoice', 22 | 'sendGame', 23 | 'sendMediaGroup', 24 | 'copyMessage', 25 | 'forwardMessage', 26 | ]); 27 | 28 | export function autoCommentReply(): Middleware { 29 | return async (context, next) => { 30 | const isReplyToChannelMessage = context.msg?.reply_to_message?.from?.id === TELEGRAM_USER_ID; 31 | 32 | if (isReplyToChannelMessage) { 33 | context.api.config.use(async (previous, method, payload, signal) => { 34 | const chatId = payload && typeof payload === 'object' && (payload as Payload<'sendMessage', RawApi>).chat_id; 35 | 36 | if (METHODS.has(method) && chatId === context.msg?.chat.id) { 37 | Object.assign(payload, { reply_to_message_id: context.msg?.reply_to_message?.message_id }); 38 | } 39 | 40 | return previous(method, payload, signal); 41 | }); 42 | } 43 | 44 | return next(); 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/bot/plugins/chain-filters.plugin.test.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'grammy'; 2 | 3 | import { chainFilters } from './chain-filters.plugin'; 4 | 5 | const mockContext = {} as Partial as Context; 6 | 7 | describe('chainFilters', () => { 8 | it('should chain if boolean is passed', () => { 9 | const positiveResult = chainFilters(100 > 20, 200 > 20, 300 > 20)(mockContext); 10 | const negativeResult = chainFilters(100 > 20, 0 > 20, 300 > 20)(mockContext); 11 | 12 | expect(positiveResult).toBeTruthy(); 13 | expect(negativeResult).toBeFalsy(); 14 | }); 15 | 16 | it('should chain if object is passed', () => { 17 | const positiveResult = chainFilters({ isValid: true, isTest: true }, { isValid: true, isTest: true })(mockContext); 18 | const negativeResult = chainFilters({ isValid: true, isTest: false }, { isValid: true, isTest: true })(mockContext); 19 | 20 | expect(positiveResult).toBeTruthy(); 21 | expect(negativeResult).toBeFalsy(); 22 | }); 23 | 24 | it('should chain if function is passed', () => { 25 | const mockFunction = jest.fn(() => true); 26 | 27 | const positiveResult = chainFilters(mockFunction, () => true)(mockContext); 28 | const negativeResult = chainFilters(mockFunction, () => false)(mockContext); 29 | 30 | expect(positiveResult).toBeTruthy(); 31 | expect(negativeResult).toBeFalsy(); 32 | expect(mockFunction).toHaveBeenCalledTimes(2); 33 | expect(mockFunction).toHaveBeenNthCalledWith(1, mockContext); 34 | expect(mockFunction).toHaveBeenNthCalledWith(2, mockContext); 35 | }); 36 | 37 | it('should chain if combined filters are passed', () => { 38 | const positiveResult = chainFilters(() => true, { isTrue: true }, true)(mockContext); 39 | const negativeResult = chainFilters(() => true, { isTrue: false }, true)(mockContext); 40 | 41 | expect(positiveResult).toBeTruthy(); 42 | expect(negativeResult).toBeFalsy(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/bot/plugins/chain-filters.plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'grammy'; 2 | 3 | export type AtLeastOneArgument = [T, ...T[]]; 4 | 5 | export type BooleanFilter = (context: C) => boolean; 6 | export type ChainFilter = boolean | BooleanFilter | Record>; 7 | 8 | /** 9 | * It helps to chain filters to simplify Grammy Composer's `filter` method logic. 10 | * 11 | * @example 12 | * ```ts 13 | * composer 14 | * .filter((context) => 15 | * chainFilters({ 16 | * isCreator: context.chat.id === creatorId, 17 | * isHaveDeleteReason: !!context.state.dataset || !!context.state.swindlersResult || !!context.state.nsfwResult, 18 | * })(context), 19 | * ) 20 | * ``` 21 | * */ 22 | export function chainFilters(...filters: AtLeastOneArgument>) { 23 | return (context: C): boolean => { 24 | // eslint-disable-next-line no-restricted-syntax 25 | for (const filter of filters) { 26 | switch (typeof filter) { 27 | /** 28 | * Raw boolean value 29 | * */ 30 | case 'boolean': { 31 | if (!filter) { 32 | return false; 33 | } 34 | 35 | break; 36 | } 37 | 38 | /** 39 | * Object that has booleans 40 | * */ 41 | case 'object': { 42 | return !Object.values(filter).some((value) => (typeof value === 'function' ? !value(context) : !value)); 43 | } 44 | 45 | /** 46 | * Function that returns boolean 47 | * */ 48 | case 'function': { 49 | if (!filter(context)) { 50 | return false; 51 | } 52 | 53 | break; 54 | } 55 | 56 | default: { 57 | throw new Error(`Unknown type has been passed. Type is ${typeof filter}`); 58 | } 59 | } 60 | } 61 | 62 | return true; 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/bot/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auto-comment-reply.plugin'; 2 | export * from './chain-filters.plugin'; 3 | export * from './self-destructed.plugin'; 4 | -------------------------------------------------------------------------------- /src/bot/queries/bot-demote.query.ts: -------------------------------------------------------------------------------- 1 | import { memberReadyMessage } from '../../message'; 2 | import type { GrammyQueryMiddleware } from '../../types'; 3 | import { handleError } from '../../utils'; 4 | 5 | /** 6 | * Demoted to regular user 7 | * */ 8 | export const botDemoteQuery: GrammyQueryMiddleware<'my_chat_member'> = async (context, next) => { 9 | if (context.myChatMember.old_chat_member.status === 'administrator' && context.myChatMember.new_chat_member.status === 'member') { 10 | delete context.chatSession.botAdminDate; 11 | context.chatSession.isBotAdmin = false; 12 | context.reply(memberReadyMessage).catch(handleError); 13 | } 14 | 15 | return next(); 16 | }; 17 | -------------------------------------------------------------------------------- /src/bot/queries/bot-invite.query.ts: -------------------------------------------------------------------------------- 1 | import type { ChatAdministratorRights, ChatMember } from '@grammyjs/types/manage'; 2 | 3 | import { getBotJoinMessage } from '../../message'; 4 | import type { GrammyQueryMiddleware } from '../../types'; 5 | import { telegramUtil } from '../../utils'; 6 | 7 | export const botInviteQuery: GrammyQueryMiddleware<'my_chat_member'> = async (context, next) => { 8 | const newStatuses = new Set(['member', 'administrator']); 9 | const oldStatuses = new Set(['left', 'kicked']); 10 | 11 | const isAdmin = context.myChatMember.new_chat_member.status === 'administrator'; 12 | const canDelete = (context.myChatMember.new_chat_member as ChatAdministratorRights).can_delete_messages; 13 | 14 | // Invite as a normal member or admin 15 | if (oldStatuses.has(context.myChatMember.old_chat_member.status) && newStatuses.has(context.myChatMember.new_chat_member.status)) { 16 | context.chatSession.botRemoved = false; 17 | const { adminsString } = await telegramUtil.getChatAdmins(context, context.chat.id); 18 | await context.replyWithHTML(getBotJoinMessage({ adminsString, isAdmin, canDelete })); 19 | } 20 | 21 | return next(); 22 | }; 23 | -------------------------------------------------------------------------------- /src/bot/queries/bot-kick.query.ts: -------------------------------------------------------------------------------- 1 | import type { ChatMember } from '@grammyjs/types/manage'; 2 | 3 | import type { GrammyQueryMiddleware } from '../../types'; 4 | 5 | export const botKickQuery: GrammyQueryMiddleware<'my_chat_member'> = (context, next) => { 6 | const oldStatuses = new Set(['left', 'kicked']); 7 | 8 | if (oldStatuses.has(context.myChatMember.new_chat_member.status)) { 9 | context.chatSession.botRemoved = true; 10 | delete context.chatSession.isBotAdmin; 11 | delete context.chatSession.botAdminDate; 12 | } 13 | 14 | return next(); 15 | }; 16 | -------------------------------------------------------------------------------- /src/bot/queries/bot-promote.query.ts: -------------------------------------------------------------------------------- 1 | import { adminReadyHasNoDeletePermissionMessage, getAdminReadyMessage, getStartChannelMessage } from '../../message'; 2 | import type { GrammyQueryMiddleware } from '../../types'; 3 | import { handleError } from '../../utils'; 4 | 5 | /** 6 | * Promoted to admin 7 | * */ 8 | export const botPromoteQuery: GrammyQueryMiddleware<'my_chat_member'> = async (context, next) => { 9 | if (context.myChatMember.new_chat_member.status === 'administrator') { 10 | if (context.chat.type === 'channel') { 11 | await context.replyWithHTML(getStartChannelMessage({ botName: context.me.username })).catch(handleError); 12 | } else { 13 | context.chatSession.botAdminDate = new Date(); 14 | context.chatSession.isBotAdmin = true; 15 | context 16 | .replyWithHTML( 17 | context.myChatMember.new_chat_member.can_delete_messages 18 | ? getAdminReadyMessage({ botName: context.me.username }) 19 | : adminReadyHasNoDeletePermissionMessage, 20 | ) 21 | .catch(handleError); 22 | } 23 | } 24 | 25 | return next(); 26 | }; 27 | -------------------------------------------------------------------------------- /src/bot/queries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bot-demote.query'; 2 | export * from './bot-invite.query'; 3 | export * from './bot-kick.query'; 4 | export * from './bot-promote.query'; 5 | -------------------------------------------------------------------------------- /src/bot/sessionProviders/index.ts: -------------------------------------------------------------------------------- 1 | export * from './redis-chat-session-storage'; 2 | export * from './redis-session-storage'; 3 | -------------------------------------------------------------------------------- /src/bot/sessionProviders/redis-chat-session-storage.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyContext, RedisSessionOptions } from '../../types'; 2 | import { RedisMiddleware } from '../middleware/redis.middleware'; 3 | 4 | export class RedisChatSession extends RedisMiddleware { 5 | constructor() { 6 | const _options: RedisSessionOptions = { 7 | property: 'chatSession', 8 | state: {}, 9 | format: {}, 10 | getSessionKey: (context: GrammyContext): string => { 11 | if (!context.from) return ''; // should never happen 12 | let chatInstance: number | string; 13 | if (context.chat) { 14 | chatInstance = context.chat.id; 15 | } else if (context.callbackQuery) { 16 | chatInstance = context.callbackQuery.chat_instance || ''; 17 | } else { 18 | chatInstance = context.from.id; 19 | } 20 | return `${chatInstance}`; 21 | }, 22 | }; 23 | super(_options); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/bot/sessionProviders/redis-session-storage.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyContext, RedisSessionOptions } from '../../types'; 2 | import { RedisMiddleware } from '../middleware/redis.middleware'; 3 | 4 | export class RedisSession extends RedisMiddleware { 5 | constructor() { 6 | const _options: RedisSessionOptions = { 7 | property: 'session', 8 | state: {}, 9 | format: {}, 10 | getSessionKey: (context: GrammyContext): string => { 11 | if (!context.from) return ''; // should never happen 12 | let chatInstance: number | string; 13 | if (context.chat) { 14 | chatInstance = context.chat.id; 15 | } else if (context.callbackQuery) { 16 | chatInstance = context.callbackQuery.chat_instance || ''; 17 | } else { 18 | chatInstance = context.from.id; 19 | } 20 | return `${chatInstance}:${context.from.id}`; 21 | }, 22 | }; 23 | super(_options); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/bot/spam.handlers.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyContext } from '../types'; 2 | 3 | import type { MessageHandler } from './message.handler'; 4 | 5 | /** 6 | * @param {GrammyContext} context 7 | * @param {MessageHandler} messageHandler 8 | */ 9 | export const isFilteredByRules = async (context: GrammyContext, messageHandler: MessageHandler) => { 10 | const originMessage = context.state.text; 11 | const message = messageHandler.sanitizeMessage(context, originMessage || ''); 12 | /** 13 | * Adapter for tensor 14 | * */ 15 | const result = await messageHandler.getTensorRank(message, originMessage || ''); 16 | 17 | return { 18 | rule: result.isSpam ? 'tensor' : null, 19 | dataset: result, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/bot/transformers/delete-message.transformer.ts: -------------------------------------------------------------------------------- 1 | import type { Transformer } from 'grammy'; 2 | 3 | import type { GrammyContext } from '../../types'; 4 | 5 | export const deleteMessageTransformer = 6 | (context: GrammyContext): Transformer => 7 | (previous, method, payload, signal) => { 8 | if (method === 'deleteMessage') { 9 | context.state.isDeleted = true; 10 | } 11 | 12 | return previous(method, payload, signal); 13 | }; 14 | -------------------------------------------------------------------------------- /src/bot/transformers/disable-logs-chat.transformer.ts: -------------------------------------------------------------------------------- 1 | import type { RawApi, Transformer } from 'grammy'; 2 | import type { Payload } from 'grammy/out/core/client'; 3 | 4 | import { logsChat, secondLogsChat } from '../../creator'; 5 | import type { RealApiMethodKeys } from '../../testing'; 6 | 7 | export const disableLogsChatTransformer: Transformer = (previous, method, payload, signal) => { 8 | const sendMethods = new Set([ 9 | 'sendMessage', 10 | 'sendAudio', 11 | 'sendDice', 12 | 'sendAnimation', 13 | 'sendChatAction', 14 | 'sendContact', 15 | 'sendDocument', 16 | 'sendGame', 17 | 'sendInvoice', 18 | 'sendLocation', 19 | 'sendMediaGroup', 20 | 'sendPhoto', 21 | 'sendPoll', 22 | 'sendSticker', 23 | 'sendVenue', 24 | 'sendVideo', 25 | 'sendVideoNote', 26 | 'sendVoice', 27 | ]); 28 | const chatId = payload && typeof payload === 'object' && (payload as Payload<'sendMessage', RawApi>).chat_id; 29 | 30 | const isSendMethod = sendMethods.has(method); 31 | const isLogsChatRequest = chatId === logsChat || chatId === secondLogsChat; 32 | 33 | if (isSendMethod && isLogsChatRequest) { 34 | console.info(`Disabled log into logs chat. Method: ${method}. Payload:`, payload); 35 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 36 | return Promise.resolve({ ok: true, result: true as never }); 37 | } 38 | 39 | return previous(method, payload, signal); 40 | }; 41 | -------------------------------------------------------------------------------- /src/bot/transformers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './delete-message.transformer'; 2 | export * from './disable-logs-chat.transformer'; 3 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as typedDotenv from 'typed-dotenv'; 2 | 3 | import type { EnvironmentConfig } from './types'; 4 | 5 | const { error, env } = typedDotenv.config(); 6 | 7 | if (error) { 8 | console.error('Something wrong with env variables'); 9 | console.error(error); 10 | // eslint-disable-next-line unicorn/no-process-exit 11 | process.exit(); 12 | } 13 | 14 | export const environmentConfig = env as unknown as EnvironmentConfig; 15 | -------------------------------------------------------------------------------- /src/const/google-sheets.const.ts: -------------------------------------------------------------------------------- 1 | export const GOOGLE_SHEETS_NAMES = { 2 | UKRAINIAN_PHRASES: 'Ukrainian_phrases', 3 | SWINDLERS: 'SWINDLERS', 4 | COUNTER_OFFENSIVE: 'Counter_offensive', 5 | NSFW: 'NSFW', 6 | STRATEGIC_NEGATIVE: 'NEGATIVE', 7 | STRATEGIC_POSITIVE: 'POSITIVE', 8 | STATISTICS: 'STATISTICS', 9 | }; 10 | -------------------------------------------------------------------------------- /src/const/index.ts: -------------------------------------------------------------------------------- 1 | export * from './google-sheets.const'; 2 | export * from './logs.const'; 3 | export * from './message-query.const'; 4 | export * from './telegram.const'; 5 | -------------------------------------------------------------------------------- /src/const/logs.const.ts: -------------------------------------------------------------------------------- 1 | export const LOGS_CHAT_THREAD_IDS = { 2 | PORN: 36, 3 | SWINDLERS: 37, 4 | ANTI_RUSSIAN: 38, 5 | STRATEGIC: 39, // WARN! Use only for errors, not for messages 6 | CARDS: 40, 7 | URLS: 41, 8 | LOCATIONS: 42, 9 | MENTIONS: 43, 10 | COUNTEROFFENSIVE: 44, 11 | OBSCENE: 45, 12 | ANTISEMITISM: 46, 13 | STATISTICS: 138, 14 | CHANNEL_MESSAGES: 384_547, 15 | PORN_MESSAGES: 464_266, 16 | }; 17 | 18 | export const SECOND_LOGS_CHAT_THREAD_IDS = { 19 | SWINDLERS: 10, 20 | }; 21 | -------------------------------------------------------------------------------- /src/const/message-query.const.ts: -------------------------------------------------------------------------------- 1 | import type { FilterQuery } from 'grammy'; 2 | 3 | /** 4 | * Returns a valid tuple type for query. 5 | * It returns the same type that has been passed but types it 6 | * */ 7 | export const getValidQueryType = (value: T): T => value; 8 | 9 | /** 10 | * @description 11 | * Query all: 12 | * regular messages 13 | * edited messages 14 | * forwarded messages 15 | * channel messages - we need to exclude it for anti-spam logic 16 | * caption messages - image with text (caption) 17 | * polls 18 | * 19 | * @use isNotChannel to exclude channels for this query 20 | * */ 21 | export const messageQuery = getValidQueryType([ 22 | ':text', 23 | ':forward_origin', 24 | ':poll', 25 | ':caption', 26 | // You need to add it explicitly because it won't work with omit values. 27 | // It could be a bug in this specific grammy version so we need to try to update it later and check if it works without it. 28 | // It works with unit tests but doesn't work in real bot so we need to check it in real telegram. 29 | // DO NOT REMOVE! 30 | 'edited_message:text', 31 | 'edited_message:forward_origin', 32 | 'edited_message:poll', 33 | 'edited_message:caption', 34 | ]); 35 | -------------------------------------------------------------------------------- /src/const/telegram.const.ts: -------------------------------------------------------------------------------- 1 | export const TELEGRAM_USER_ID = 777_000; 2 | -------------------------------------------------------------------------------- /src/creator.ts: -------------------------------------------------------------------------------- 1 | import { environmentConfig } from './config'; 2 | 3 | // TODO remove this file later and from Git-history 4 | export const creatorId = environmentConfig.CREATOR_ID ?? 341_977_297; 5 | export const helpChat = 'https://t.me/+UOWRWv3JSB1mYTZi'; 6 | export const logsChat = -1_002_169_799_987; 7 | export const secondLogsChat = -1_002_207_322_076; 8 | export const trainingChat = -1_001_527_463_076; 9 | export const privateTrainingChat = -788_538_459; 10 | 11 | export const swindlerMessageChatId = -1_001_769_124_427; 12 | export const swindlerBotsChatId = -672_793_621; 13 | export const swindlerHelpChatId = -1_001_750_209_242; 14 | 15 | export const swindlersRegex = 16 | /(?:https?:\/\/)?(privat24.|privatpay|privatbank.my-payment|app-raiffeisen|mono-bank|login24|privat\.|privat24.ua-|privatbank.u-|privatbank.m|privatbank.a|e-pidtrimka|perekazprivat|privatbank.|privatapp|da-pay|goo.su|p24.|8-pay|pay-raiffeisen|myprlvat|orpay|privat24-.|monobank.|tpays|mopays|leaf-pays|j-pay|i-pay|olx-ua|op-pay|ok-pay|uabuy|private24|darpayments|o-pay|u.to|privatgetmoney|inlnk.ru|privat-|-pay|ik-safe|transfer-go|24pay.|-pau.me|-pai.me|u-pau.com|uasafe|ua-talon|menlo.pw|prlvatbank)(?!ua).+/; 17 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | export * as redisClient from './redis'; 2 | -------------------------------------------------------------------------------- /src/definitions/input.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'input' { 2 | export function confirm(message: string): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/express-logic/index.ts: -------------------------------------------------------------------------------- 1 | export * from './process.handler'; 2 | export * from './verify-telegram-web-app-data'; 3 | -------------------------------------------------------------------------------- /src/express-logic/middleware/headers.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | 3 | export const headersMiddleware = (request: Request, response: Response, next: NextFunction) => { 4 | response.header('Access-Control-Allow-Origin', '*'); 5 | response.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); 6 | response.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); 7 | 8 | return next(); 9 | }; 10 | -------------------------------------------------------------------------------- /src/express-logic/middleware/index.ts: -------------------------------------------------------------------------------- 1 | export * from './headers.middleware'; 2 | export * from './web-view-auth.middleware'; 3 | -------------------------------------------------------------------------------- /src/express-logic/middleware/web-view-auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from 'express'; 2 | 3 | import { verifyTelegramWebAppData } from '../index'; 4 | 5 | export const validateMiddleware = (request: Request, response: Response, next: NextFunction) => { 6 | const isValid = verifyTelegramWebAppData(request.headers.authorization as string); 7 | if (!isValid) { 8 | return response.status(403).json({ message: 'Unauthorized' }); 9 | } 10 | 11 | next(); 12 | }; 13 | -------------------------------------------------------------------------------- /src/express-logic/process.handler.ts: -------------------------------------------------------------------------------- 1 | import lodashGet from 'lodash.get'; 2 | import { removeLatinPartialLetters, removeNumber, removeSpecialSymbols } from 'ukrainian-ml-optimizer'; 3 | 4 | import type { DatasetKeys } from '../../dataset/dataset'; 5 | import { dataset } from '../../dataset/dataset'; 6 | import { messageUtil } from '../utils'; 7 | 8 | class ProcessHandler { 9 | /** 10 | * @param {string} message 11 | * @param {DatasetKeys} datasetPath 12 | * @param {boolean} strict 13 | * 14 | * @returns {string | null} 15 | * */ 16 | processHandler(message: string, datasetPath: DatasetKeys, strict = false) { 17 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 18 | const words = lodashGet(dataset, datasetPath.replace('_$', '')) as string[]; 19 | 20 | if (datasetPath === 'one_word') { 21 | return this.processOneWordMessage(message, words); 22 | } 23 | 24 | const directHit = words.find((word) => messageUtil.findInText(message, word, strict)); 25 | 26 | if (directHit) { 27 | return directHit; 28 | } 29 | 30 | return messageUtil.fuseInText(message, words); 31 | } 32 | 33 | /** 34 | * @private 35 | * 36 | * @returns {string | null} 37 | * */ 38 | processOneWordMessage(message: string, words: string[]) { 39 | const processedMessage = removeNumber(removeLatinPartialLetters(removeSpecialSymbols(message))).toLowerCase(); 40 | 41 | return words.includes(processedMessage) ? message : null; 42 | } 43 | } 44 | 45 | export const processHandler = new ProcessHandler(); 46 | -------------------------------------------------------------------------------- /src/express-logic/verify-telegram-web-app-data.ts: -------------------------------------------------------------------------------- 1 | import CryptoJS from 'crypto-js'; 2 | 3 | import { environmentConfig } from '../config'; 4 | 5 | export const verifyTelegramWebAppData = (telegramInitData: string): boolean => { 6 | const initData = new URLSearchParams(telegramInitData); 7 | const hash = initData.get('hash'); 8 | const dataToCheck: string[] = []; 9 | 10 | initData.sort(); 11 | initData.forEach((value, key) => key !== 'hash' && dataToCheck.push(`${key}=${value}`)); 12 | 13 | const secret = CryptoJS.HmacSHA256(environmentConfig.BOT_TOKEN, 'WebAppData'); 14 | const _hash = CryptoJS.HmacSHA256(dataToCheck.join('\n'), secret).toString(CryptoJS.enc.Hex); 15 | 16 | return _hash === hash; 17 | }; 18 | -------------------------------------------------------------------------------- /src/message/admin.message.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line unicorn/prevent-abbreviations 2 | export const checkAdminNotification = `❗УВАГА! UA Anti Spam Bot перевіряє повідомлення адміністраторів, данну опцію можна вимкнути в налаштуваннях.`; 3 | -------------------------------------------------------------------------------- /src/message/index.ts: -------------------------------------------------------------------------------- 1 | export * from './admin.message'; 2 | export * from './antisemitism.message'; 3 | export * from './obscene.message'; 4 | export * from './settings.message'; 5 | export * from './swindlers.message'; 6 | -------------------------------------------------------------------------------- /src/message/settings.message.ts: -------------------------------------------------------------------------------- 1 | import { environmentConfig } from '../config'; 2 | 3 | /** 4 | * Generic - Settings 5 | * */ 6 | 7 | export const linkToWebView = `⚙️Відкрити налаштування: 8 | 9 | 🔗 ${environmentConfig.WEB_VIEW_URL}`; 10 | export const hasNoLinkedChats = ` 11 | ⛔️ У Вас немає прив'язаних чатів. 12 | 13 | Будь ласка, зайдіть у групу і натисніть /settings.`; 14 | export const isNotAdminMessage = '😔 Ви не є адміністратором чату!'; 15 | -------------------------------------------------------------------------------- /src/message/swindlers.message.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic - Swindlers 3 | * */ 4 | export const swindlersUpdateStartMessage = 'Починаю оновлення списку шахраїв...'; 5 | export const swindlersUpdateEndMessage = 'Оновлення спіску шахраїв завершено.'; 6 | export const swindlersWarningMessage = `❗УВАГА! UA Anti Spam Bot 🇺🇦 помітив повідомлення від шахраїв в цьому чаті! 7 | 8 | Будьте обережні та дотримуйтесь правил інформаційної безпеки: 9 | 10 | 🔶 Не переходьте за підозрілими посиланнями із чатів! 11 | 🔶 Уникайте реєстрацій та передачі персональних даних стороннім неперевіреним ресурсам. 12 | 🔶 Ніколи не вводьте захищені дані ваших платіжних карток (CVV-код та PIN). 13 | 14 | Якщо ви стали жертвою шахраїв або ваш акаунт зламали, звертайтесь на безоплатну гарячу лінію з цифрової безпеки. 15 | 16 | Отримати фахову консультацію: 17 | 👉 @nadiyno_bot 18 | 19 | Детальніше за командою /hotline_security 20 | `; 21 | 22 | export const swindlersHelpMessage = ` 23 | NADIYNO: гаряча лінія з цифрової безпеки 24 | 25 | * Отримали підозрілий дзвінок нібито з банку? 26 | * Купуєте онлайн, але не впевнені в безпечності сайту? 27 | * Ваш акаунт зламали? 28 | 29 | Опишіть вашу проблему та отримайте фахову консультацію. 30 | Це безоплатно та конфіденційно. 31 | 32 | Запитайте у фахівця! 33 | 👉 @nadiyno_bot 34 | 35 | Детальніше про платформу: 36 | 💻 https://nadiyno.org/ 37 | `.trim(); 38 | -------------------------------------------------------------------------------- /src/packages/nsfwjs-2.4.2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoC-OSS/ua-anti-spam-bot/21d52e64009266b73bcf19577bd1107d095be40e/src/packages/nsfwjs-2.4.2.tgz -------------------------------------------------------------------------------- /src/services/_mocks/helpers.mocks.ts: -------------------------------------------------------------------------------- 1 | export function generateRandomNumber(digits: number) { 2 | return Math.floor(Math.random() * 10 ** digits); 3 | } 4 | 5 | export function generateRandomString(length: number) { 6 | // eslint-disable-next-line unicorn/prefer-string-slice 7 | return Buffer.from(Math.random().toString()).toString('base64').substr(10, length); 8 | } 9 | 10 | export function generateRandomBoolean() { 11 | return Math.random() < 0.5; 12 | } 13 | -------------------------------------------------------------------------------- /src/services/_mocks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './alarm.mocks'; 2 | export * from './helpers.mocks'; 3 | -------------------------------------------------------------------------------- /src/services/_mocks/language-detect.mocks.ts: -------------------------------------------------------------------------------- 1 | export const languageDetectUaMocks = [ 2 | 'Вчу польску', 3 | 'Молодець', 4 | 'дякую вам', 5 | 'Це точно', 6 | 'Був запуск?', 7 | 'Токо вийшов гулять з собакой', 8 | 'Тримайся!!', 9 | 'щоб накидаться', 10 | ]; 11 | -------------------------------------------------------------------------------- /src/services/antisemitism.service.test.ts: -------------------------------------------------------------------------------- 1 | import { antisemitismService } from './antisemitism.service'; 2 | 3 | describe('AntisemitismService', () => { 4 | it('should remove message with threads', () => { 5 | const message = 'Нос жидовский, как у меня'; 6 | const actual = antisemitismService.checkAntisemitism(message); 7 | 8 | expect(actual).toBeTruthy(); 9 | }); 10 | 11 | it('should remove message with noun and action', () => { 12 | const message = 'этих евреев нужно сжигать. по другому никак'; 13 | const actual = antisemitismService.checkAntisemitism(message); 14 | 15 | expect(actual).toBeTruthy(); 16 | }); 17 | 18 | it('should remove message with noun and obscene', () => { 19 | const message = 'хасид, иди на хуй!'; 20 | const actual = antisemitismService.checkAntisemitism(message); 21 | 22 | expect(actual).toBeTruthy(); 23 | }); 24 | 25 | it('should not remove message with noun and a regular text', () => { 26 | const message = 'Миру мир, а евреям деньги'; 27 | const actual = antisemitismService.checkAntisemitism(message); 28 | 29 | expect(actual).toBeNull(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/services/antisemitism.service.ts: -------------------------------------------------------------------------------- 1 | import { dataset } from '../../dataset/dataset'; 2 | import { SearchSet } from '../utils/search-set'; 3 | 4 | import { ObsceneService } from './obscene.service'; 5 | 6 | export class AntisemitismService { 7 | private genericSet = new SearchSet(); 8 | 9 | constructor(private obsceneService: ObsceneService) {} 10 | 11 | checkAntisemitism(message: string) { 12 | const tokens = this.genericSet.tokenize(message); 13 | 14 | const foundThread = dataset.antisemitism_dictionary.threads.search(tokens); 15 | 16 | if (foundThread) { 17 | return foundThread; 18 | } 19 | 20 | const foundNoun = dataset.antisemitism_dictionary.nouns.search(tokens); 21 | 22 | if (!foundNoun) { 23 | return null; 24 | } 25 | 26 | const foundAction = dataset.antisemitism_dictionary.action.search(tokens); 27 | 28 | if (foundAction) { 29 | return foundAction; 30 | } 31 | 32 | return this.obsceneService.checkObscene(tokens); 33 | } 34 | } 35 | 36 | export const antisemitismService = new AntisemitismService(new ObsceneService()); 37 | -------------------------------------------------------------------------------- /src/services/cards.service.test.ts: -------------------------------------------------------------------------------- 1 | import { cardsService } from './cards.service'; 2 | 3 | describe('CardsService', () => { 4 | describe('parseCards', () => { 5 | it('should parse cards', () => { 6 | const text = '4111 1111 4555 1142 4111112014267661 4988/4388/4388/4305'; 7 | const result = cardsService.parseCards(text); 8 | 9 | expect(result).toStrictEqual(['4111111145551142', '4111112014267661', '4988438843884305']); 10 | }); 11 | 12 | it('should not parse invalid cards', () => { 13 | const text = '5555 5555 5555 5555 1114111411141114 1112/1112/1112/1112'; 14 | const result = cardsService.parseCards(text); 15 | 16 | expect(result).toStrictEqual([]); 17 | }); 18 | 19 | it('should not parse cards bigger than 16 digits', () => { 20 | const text = '7035633400000262074'; 21 | const result = cardsService.parseCards(text); 22 | 23 | expect(result).toStrictEqual([]); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/services/cards.service.ts: -------------------------------------------------------------------------------- 1 | import isCreditCard from 'validator/lib/isCreditCard'; 2 | 3 | export class CardsService { 4 | cardRegex = /(?:\d{4}.?){3}\d{4}/g; 5 | 6 | /** 7 | * @param {string} message - raw message from user to parse 8 | * 9 | * @returns {string[]} 10 | */ 11 | parseCards(message: string): string[] { 12 | return (message.match(this.cardRegex) || ([] as string[])) 13 | .map((card) => card.replaceAll(/\D/g, '')) 14 | .filter((card) => card && card.length === 16 && isCreditCard(card)); 15 | } 16 | } 17 | 18 | export const cardsService = new CardsService(); 19 | -------------------------------------------------------------------------------- /src/services/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './swindlers-urls.constant'; 2 | -------------------------------------------------------------------------------- /src/services/counteroffensive.service.ts: -------------------------------------------------------------------------------- 1 | import type { CounterOffensiveResult } from '../types/state'; 2 | 3 | import type { DynamicStorageService } from './dynamic-storage.service'; 4 | 5 | export class CounteroffensiveService { 6 | constructor(private dynamicStorageService: DynamicStorageService) {} 7 | 8 | isCounteroffensive(text: string): CounterOffensiveResult { 9 | const searchText = text.toLowerCase(); 10 | 11 | const reason = this.dynamicStorageService.counteroffensiveTriggers.find((trigger) => { 12 | if (trigger instanceof RegExp) { 13 | return trigger.test(searchText); 14 | } 15 | 16 | return searchText.includes(trigger); 17 | }); 18 | 19 | return reason 20 | ? { 21 | result: true, 22 | percent: 100, 23 | reason, 24 | } 25 | : { 26 | result: false, 27 | percent: 0, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/dynamic-storage.service.test.ts: -------------------------------------------------------------------------------- 1 | import { mockDataset, mockGoogleService, mockSwindlersGoogleService } from './_mocks/index.mocks'; 2 | import { DynamicStorageService } from './dynamic-storage.service'; 3 | 4 | /** 5 | * @type {DynamicStorageService} 6 | * */ 7 | let dynamicStorageService: DynamicStorageService; 8 | describe('DynamicStorageService', () => { 9 | beforeAll(() => { 10 | dynamicStorageService = new DynamicStorageService(mockSwindlersGoogleService, mockGoogleService, mockDataset); 11 | }); 12 | 13 | it('should init with mock dataset', () => { 14 | expect(dynamicStorageService.swindlerMessages).toHaveLength(0); 15 | expect(dynamicStorageService.swindlerMessages).toEqual([]); 16 | expect(dynamicStorageService.swindlerBots).toStrictEqual(mockDataset.swindlers_bots); 17 | }); 18 | 19 | it('should fetch dataset', async () => { 20 | await dynamicStorageService.updateStorage(); 21 | 22 | expect(dynamicStorageService.swindlerMessages).toHaveLength(1); 23 | expect(dynamicStorageService.swindlerBots).toEqual(['@Diia_move_bot']); 24 | }); 25 | 26 | it('should emit event on fetch dataset', async () => { 27 | dynamicStorageService.fetchEmitter.on('fetch', () => { 28 | console.info('emmited'); 29 | expect(true).toBeTruthy(); 30 | }); 31 | 32 | await dynamicStorageService.updateStorage(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './alarm.service'; 2 | export * from './alarm-chat.service'; 3 | export * from './cards.service'; 4 | export * from './counteroffensive.service'; 5 | export * from './dynamic-storage.service'; 6 | export * from './google.service'; 7 | export * from './language-detect.service'; 8 | export * from './locations.service'; 9 | export * from './mention.service'; 10 | export * from './obscene.service'; 11 | export * from './redis.service'; 12 | export * from './s3.service'; 13 | export * from './spam-media-groups-storage.service'; 14 | export * from './swindlers.container'; 15 | export * from './swindlers-bots.service'; 16 | export * from './swindlers-cards.service'; 17 | export * from './swindlers-detect.service'; 18 | export * from './swindlers-google.service'; 19 | export * from './swindlers-urls.service'; 20 | export * from './url.service'; 21 | -------------------------------------------------------------------------------- /src/services/locations.service.ts: -------------------------------------------------------------------------------- 1 | import { dataset } from '../../dataset/dataset'; 2 | 3 | export class LocationsService { 4 | /** 5 | * @param {string} message - raw message from user to parse 6 | * 7 | * @returns {string[]} 8 | */ 9 | parseLocations(message: string): string[] { 10 | const lowerMessage = message.toLowerCase(); 11 | const foundLocation = dataset.locations.find((location) => lowerMessage.includes(location)); 12 | 13 | if (foundLocation === undefined) { 14 | return []; 15 | } 16 | 17 | return [foundLocation]; 18 | } 19 | } 20 | 21 | export const locationsService = new LocationsService(); 22 | -------------------------------------------------------------------------------- /src/services/mention.service.ts: -------------------------------------------------------------------------------- 1 | import { removeDuplicates } from '../utils'; 2 | 3 | export class MentionService { 4 | readonly mentionRegexp = /\B@\w+/g; 5 | 6 | readonly nonWordRegex = /\W/; 7 | 8 | readonly urlRegexp = 9 | /(https?:\/\/(?:www\.|(?!www))?[\dA-Za-z][\dA-Za-z-]+[\dA-Za-z]\.\S{2,}|www\.[\dA-Za-z][\dA-Za-z-]+[\dA-Za-z]\.\S{2,}|(https?:\/\/(?:www\.|(?!www)))?[\dA-Za-z-]+\.\S{2,}|www\.?[\dA-Za-z]+\.\S{2,})/g; 10 | 11 | /** 12 | * @param {string} message - raw message from user to parse 13 | * @param exceptionMentions - mentions to exclude from list 14 | * 15 | * @returns {string[]} 16 | */ 17 | parseMentions(message: string, exceptionMentions: string[] = []): string[] { 18 | const directMentions = message.match(this.mentionRegexp) || []; 19 | const linkMentions = (message.match(this.urlRegexp) || []) 20 | .filter((url) => url.split('/').includes('t.me')) 21 | .map((url) => url.split('/').splice(-1)[0]) 22 | .map((mention) => (this.nonWordRegex.test(mention.slice(-1)) ? `@${mention.slice(0, -1)}` : `@${mention}`)); 23 | 24 | return removeDuplicates([...directMentions, ...linkMentions]).filter((item) => !exceptionMentions.includes(item)); 25 | } 26 | } 27 | 28 | export const mentionService = new MentionService(); 29 | -------------------------------------------------------------------------------- /src/services/nsfw-detect.service.ts: -------------------------------------------------------------------------------- 1 | import FuzzySet from 'fuzzyset'; 2 | 3 | import type { SwindlersBotsResult } from '../types'; 4 | 5 | import type { DynamicStorageService } from './dynamic-storage.service'; 6 | 7 | export class NsfwDetectService { 8 | nsfwMessagesFuzzySet!: FuzzySet; 9 | 10 | constructor(private dynamicStorageService: DynamicStorageService, private rate = 0.9) { 11 | this.initFuzzySet(); 12 | 13 | this.dynamicStorageService.fetchEmitter.on('fetch', () => { 14 | this.initFuzzySet(); 15 | }); 16 | } 17 | 18 | /** 19 | * @param {string} message - raw message from user to parse 20 | */ 21 | processMessage(message?: string): SwindlersBotsResult | null { 22 | const result = this.isSpamMessage(message); 23 | if (result.isSpam) { 24 | return result; 25 | } 26 | 27 | return null; 28 | } 29 | 30 | /** 31 | * @description 32 | * Create and saves FuzzySet based on latest data from dynamic storage 33 | * */ 34 | initFuzzySet() { 35 | this.nsfwMessagesFuzzySet = FuzzySet(this.dynamicStorageService.nsfwMessages); 36 | } 37 | 38 | /** 39 | * @param {string} message 40 | * @param {number} [customRate] 41 | */ 42 | isSpamMessage(message?: string, customRate?: number): SwindlersBotsResult { 43 | if (!message) { 44 | return { 45 | isSpam: false, 46 | rate: 0, 47 | nearestName: '', 48 | currentName: '', 49 | }; 50 | } 51 | 52 | const [[rate, nearestName]] = this.nsfwMessagesFuzzySet.get(message) || [[0]]; 53 | return { 54 | isSpam: rate > (customRate || this.rate), 55 | rate, 56 | nearestName, 57 | currentName: message, 58 | }; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/services/obscene.service.test.ts: -------------------------------------------------------------------------------- 1 | import { obsceneService } from './obscene.service'; 2 | 3 | describe('ObsceneService', () => { 4 | it('should detect obscene messages', () => { 5 | const message = 'Цей виступ був АФІГЄНИй, і всі залишились безмовними від захвату.'; 6 | const actual = obsceneService.checkObscene(message); 7 | 8 | expect(actual).toBeTruthy(); 9 | }); 10 | 11 | it('should not detect regular messages', () => { 12 | const message = 'Сонечко сьогодні палить класно.'; 13 | const actual = obsceneService.checkObscene(message); 14 | 15 | expect(actual).toBeNull(); 16 | }); 17 | 18 | it('should not delete russian warship', () => { 19 | const message = 'Русский военный корабль, иди на хуй!'; 20 | const actual = obsceneService.checkObscene(message); 21 | 22 | expect(actual).toBeNull(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/services/obscene.service.ts: -------------------------------------------------------------------------------- 1 | import { dataset } from '../../dataset/dataset'; 2 | import type { SearchSetTokens } from '../utils/search-set'; 3 | import { SearchSet } from '../utils/search-set'; 4 | 5 | export class ObsceneService { 6 | private readonly warshipAllowList = new SearchSet(['корабль', 'корабель', 'кораблю']); 7 | 8 | private readonly militaryAllowList = new SearchSet([ 9 | 'военний', 10 | 'воєнний', 11 | 'воений', 12 | 'военный', 13 | 'военый', 14 | 'військовий', 15 | 'русский', 16 | 'російський', 17 | ]); 18 | 19 | checkObscene(message: string | SearchSetTokens) { 20 | if (this.warshipAllowList.search(message) && this.militaryAllowList.search(message)) { 21 | return null; 22 | } 23 | 24 | return dataset.obscene_dictionary.search(message); 25 | } 26 | } 27 | 28 | export const obsceneService = new ObsceneService(); 29 | -------------------------------------------------------------------------------- /src/services/s3.service.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import type { S3 } from 'aws-sdk'; 4 | import AWS from 'aws-sdk'; 5 | 6 | import { environmentConfig } from '../config'; 7 | 8 | export class S3Service { 9 | s3: S3 = new AWS.S3({ 10 | region: environmentConfig.AWS_REGION, 11 | }); 12 | 13 | mlFiles: string[] = ['group1-shard1of1.bin', 'model.json', 'vocab.json']; 14 | 15 | config = { 16 | bucket: environmentConfig.S3_BUCKET, 17 | path: environmentConfig.S3_PATH, 18 | }; 19 | 20 | /** 21 | * Download tensor flow model into the specific folder 22 | * */ 23 | downloadTensorFlowModel(fsFolderPath: URL) { 24 | const loadFilePromises = this.mlFiles.map((fileName) => 25 | this.s3 26 | .getObject({ Bucket: this.config.bucket || '', Key: path.join(this.config.path, fileName) }) 27 | .promise() 28 | .then((response) => { 29 | console.info(new URL(fileName, fsFolderPath)); 30 | fs.writeFileSync(new URL(fileName, fsFolderPath), response.Body?.toString() || ''); 31 | }), 32 | ); 33 | 34 | return Promise.all(loadFilePromises); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/services/spam-media-groups-storage.service.ts: -------------------------------------------------------------------------------- 1 | import ms from 'ms'; 2 | 3 | import type { GrammyContext } from '../types'; 4 | 5 | const GROUP_EXPIRATION_TIME = ms('60s'); 6 | const GROUP_EXPIRATION_CHECK_INTERVAL = ms('10s'); 7 | 8 | interface SpamMediaGroupsStorageDataType { 9 | [key: string]: { 10 | createdAt: number; 11 | }; 12 | } 13 | 14 | export class SpamMediaGroupsStorage { 15 | private storage: SpamMediaGroupsStorageDataType = {}; 16 | 17 | private timer?: NodeJS.Timer; 18 | 19 | constructor() { 20 | this.initMediaGroupsExpiration(); 21 | } 22 | 23 | private initMediaGroupsExpiration() { 24 | if (this.timer) return; 25 | 26 | this.timer = setInterval(() => { 27 | const now = Date.now(); 28 | 29 | Object.keys(this.storage).forEach((key) => { 30 | const duration = now - this.storage[key].createdAt; 31 | if (duration > GROUP_EXPIRATION_TIME) delete this.storage[key]; 32 | }); 33 | // console.log('🟡', this.storage); //TODO check why empty server logs 34 | }, GROUP_EXPIRATION_CHECK_INTERVAL); 35 | } 36 | 37 | private extractKeyFromContext(context: GrammyContext): string { 38 | const chatId = context.chat?.id; 39 | const groupId = context.message?.media_group_id; 40 | 41 | if (!chatId || !groupId) throw new Error(`Invalid groupId or chatId`); 42 | 43 | return chatId && groupId ? `${chatId}_${groupId}` : ''; 44 | } 45 | 46 | addSpamMediaGroup(context: GrammyContext) { 47 | const key = this.extractKeyFromContext(context); 48 | 49 | if (key) this.storage[key] = { createdAt: Date.now() }; 50 | } 51 | 52 | isSpamMediaGroup(context: GrammyContext): boolean { 53 | const key = this.extractKeyFromContext(context); 54 | 55 | return key ? key in this.storage : false; 56 | } 57 | } 58 | 59 | export const spamMediaGroupsStorage = new SpamMediaGroupsStorage(); 60 | -------------------------------------------------------------------------------- /src/services/statistics-google.service.ts: -------------------------------------------------------------------------------- 1 | import { environmentConfig } from '../config'; 2 | import { GOOGLE_SHEETS_NAMES } from '../const'; 3 | 4 | import type { GoogleService } from './google.service'; 5 | import { googleService as localGoogleService } from './google.service'; 6 | 7 | export class StatisticsGoogleService { 8 | SHEETS_START_FROM = 'A2:A'; 9 | 10 | constructor(private googleService: GoogleService) {} 11 | 12 | /** 13 | * @private 14 | * @param {string|number[]} cases 15 | * 16 | * @returns Promise 17 | * */ 18 | async appendToSheet(cases: (string | number)[]) { 19 | return this.googleService.appendToSheet( 20 | environmentConfig.GOOGLE_SPREADSHEET_ID, 21 | GOOGLE_SHEETS_NAMES.STATISTICS, 22 | cases, 23 | this.SHEETS_START_FROM, 24 | ); 25 | } 26 | } 27 | 28 | export const statisticsGoogleService = new StatisticsGoogleService(localGoogleService); 29 | -------------------------------------------------------------------------------- /src/services/swindlers-bots.service.test.ts: -------------------------------------------------------------------------------- 1 | import { mockDynamicStorageService, mockNewBot } from './_mocks/index.mocks'; 2 | import { SwindlersBotsService } from './swindlers-bots.service'; 3 | 4 | /** 5 | * @type {SwindlersBotsService} 6 | * */ 7 | let swindlersBotsService: SwindlersBotsService; 8 | describe('SwindlersBotsService', () => { 9 | beforeAll(() => { 10 | swindlersBotsService = new SwindlersBotsService(mockDynamicStorageService, 0.6); 11 | }); 12 | 13 | it('should compare new bot', () => { 14 | const result = swindlersBotsService.isSpamBot(mockNewBot); 15 | console.info(result); 16 | 17 | expect(result.isSpam).toEqual(true); 18 | }); 19 | 20 | it('should recreate fuzzyset on fetch', async () => { 21 | const initFuzzySetSpy = jest.spyOn(swindlersBotsService, 'initFuzzySet'); 22 | const oldFuzzyMatch = swindlersBotsService.swindlersBotsFuzzySet; 23 | 24 | expect(initFuzzySetSpy).not.toHaveBeenCalled(); 25 | await mockDynamicStorageService.updateStorage(); 26 | const newFuzzyMatch = swindlersBotsService.swindlersBotsFuzzySet; 27 | 28 | expect(oldFuzzyMatch).not.toEqual(newFuzzyMatch); 29 | expect(initFuzzySetSpy).toHaveBeenCalled(); 30 | 31 | initFuzzySetSpy.mockRestore(); 32 | }); 33 | 34 | describe('processMessage', () => { 35 | it('should process message any find swindlers bots', () => { 36 | const result = swindlersBotsService.processMessage(`test message ${mockNewBot} with swindler bot`); 37 | 38 | expect(result).toBeTruthy(); 39 | expect(result?.isSpam).toBeTruthy(); 40 | }); 41 | 42 | it('should not process regular message', () => { 43 | const result = swindlersBotsService.processMessage(`test message without @test_bot swindler bot `); 44 | 45 | expect(result).toBeFalsy(); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/services/swindlers-cards.service.test.ts: -------------------------------------------------------------------------------- 1 | import { mockDynamicStorageService } from './_mocks/index.mocks'; 2 | import { SwindlersCardsService } from './swindlers-cards.service'; 3 | 4 | let swindlersCardsService: SwindlersCardsService; 5 | 6 | describe('SwindlersCardsService', () => { 7 | beforeAll(() => { 8 | swindlersCardsService = new SwindlersCardsService(mockDynamicStorageService); 9 | }); 10 | 11 | describe('isSpam', () => { 12 | it('should detect spam card', () => { 13 | const text = '4222422242224222'; 14 | const result = swindlersCardsService.isSpam(text); 15 | 16 | expect(result).toBe(true); 17 | }); 18 | 19 | it('should not detect spam card', () => { 20 | const text = '4111422242224222'; 21 | const result = swindlersCardsService.isSpam(text); 22 | 23 | expect(result).toBe(false); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/services/swindlers-cards.service.ts: -------------------------------------------------------------------------------- 1 | import { cardsService } from './cards.service'; 2 | import type { DynamicStorageService } from './dynamic-storage.service'; 3 | 4 | export class SwindlersCardsService { 5 | cards: string[] = []; 6 | 7 | constructor(private dynamicStorageService: DynamicStorageService) { 8 | this.cards = this.dynamicStorageService.swindlerCards; 9 | 10 | this.dynamicStorageService.fetchEmitter.on('fetch', () => { 11 | this.cards = this.dynamicStorageService.swindlerCards; 12 | }); 13 | } 14 | 15 | /** 16 | * @param {string} name 17 | */ 18 | isSpam(name: string): boolean { 19 | return this.cards.includes(name); 20 | } 21 | 22 | /** 23 | * @param {string} message - raw message from user to parse 24 | */ 25 | processMessage(message: string): true | null { 26 | const cards = cardsService.parseCards(message); 27 | if (cards.some((card) => this.cards.includes(card))) { 28 | return true; 29 | } 30 | 31 | return null; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/services/swindlers.container.ts: -------------------------------------------------------------------------------- 1 | import { dataset } from '../../dataset/dataset'; 2 | import { initSwindlersTensor } from '../tensor'; 3 | 4 | import { DynamicStorageService } from './dynamic-storage.service'; 5 | import { googleService } from './google.service'; 6 | import { SwindlersBotsService } from './swindlers-bots.service'; 7 | import { SwindlersCardsService } from './swindlers-cards.service'; 8 | import { SwindlersDetectService } from './swindlers-detect.service'; 9 | import { swindlersGoogleService } from './swindlers-google.service'; 10 | import { SwindlersUrlsService } from './swindlers-urls.service'; 11 | 12 | export const initSwindlersContainer = async () => { 13 | const swindlersTensorService = await initSwindlersTensor(); 14 | swindlersTensorService.setSpamThreshold(0.87); 15 | 16 | // Test that swindlersTensorService works 17 | // It throws an error if it's not working 18 | await swindlersTensorService.predict('test', null); 19 | 20 | const dynamicStorageService = new DynamicStorageService(swindlersGoogleService, googleService, dataset); 21 | await dynamicStorageService.init(); 22 | 23 | const swindlersBotsService = new SwindlersBotsService(dynamicStorageService, 0.6); 24 | const swindlersUrlsService = new SwindlersUrlsService(dynamicStorageService, 0.8); 25 | const swindlersCardsService = new SwindlersCardsService(dynamicStorageService); 26 | 27 | const swindlersDetectService = new SwindlersDetectService( 28 | dynamicStorageService, 29 | swindlersBotsService, 30 | swindlersCardsService, 31 | swindlersUrlsService, 32 | swindlersTensorService, 33 | ); 34 | 35 | return { 36 | dynamicStorageService, 37 | swindlersBotsService, 38 | swindlersCardsService, 39 | swindlersDetectService, 40 | swindlersGoogleService, 41 | swindlersTensorService, 42 | swindlersUrlsService, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/services/url.service.ts: -------------------------------------------------------------------------------- 1 | import { removeDuplicates } from '../utils'; 2 | 3 | import { EXCEPTION_DOMAINS, NON_WORD_REGEX, URL_REGEXP, VALID_URL_REGEXP } from './constants'; 4 | 5 | export class UrlService { 6 | /** 7 | * @param {string} message - raw message from user to parse 8 | * @param strict - is need to check in strict mode and doesn't check exception domains 9 | * 10 | * @returns {string[]} - parsed urls 11 | */ 12 | parseUrls(message: string, strict = false): string[] { 13 | return removeDuplicates( 14 | (message.match(URL_REGEXP) || ([] as string[])) 15 | .map((url) => { 16 | const clearUrl = url.trim(); 17 | const noSpecialSymbolUrl = NON_WORD_REGEX.test(clearUrl.slice(-1)) ? clearUrl.slice(0, -1) : clearUrl; 18 | const validUrl = noSpecialSymbolUrl.slice(0, 4) === 'http' ? noSpecialSymbolUrl : `https://${noSpecialSymbolUrl}`; 19 | 20 | return validUrl.slice(-1) === '/' ? validUrl.slice(0, -1) : validUrl; 21 | }) 22 | .filter((url) => { 23 | try { 24 | const urlInstance = new URL(url); 25 | const isNotExcluded = strict ? true : !EXCEPTION_DOMAINS.includes(urlInstance.host); 26 | return urlInstance && isNotExcluded && VALID_URL_REGEXP.test(url); 27 | } catch { 28 | return false; 29 | } 30 | }), 31 | ); 32 | } 33 | 34 | /** 35 | * @param {string} url 36 | * @returns {string | null} 37 | */ 38 | getUrlDomain(url: string): string { 39 | try { 40 | const validUrl = url.slice(0, 4) === 'http' ? url : `https://${url}`; 41 | return `${new URL(validUrl).host}/`; 42 | } catch (error) { 43 | console.error('Cannot get URL domain:', url, error); 44 | return url; 45 | } 46 | } 47 | } 48 | 49 | export const urlService = new UrlService(); 50 | -------------------------------------------------------------------------------- /src/tensor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base-tensor.service'; 2 | export * from './nsfw-tensor.service'; 3 | export * from './swindlers-tensor.service'; 4 | export * from './tensor.service'; 5 | -------------------------------------------------------------------------------- /src/tensor/nsfw-temp/group1-shard1of1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoC-OSS/ua-anti-spam-bot/21d52e64009266b73bcf19577bd1107d095be40e/src/tensor/nsfw-temp/group1-shard1of1 -------------------------------------------------------------------------------- /src/tensor/swindlers-temp/.gitignore: -------------------------------------------------------------------------------- 1 | #* 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /src/tensor/swindlers-temp/group1-shard1of1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoC-OSS/ua-anti-spam-bot/21d52e64009266b73bcf19577bd1107d095be40e/src/tensor/swindlers-temp/group1-shard1of1.bin -------------------------------------------------------------------------------- /src/tensor/swindlers-tensor.service.ts: -------------------------------------------------------------------------------- 1 | import { environmentConfig } from '../config'; 2 | 3 | import { BaseTensorService } from './base-tensor.service'; 4 | 5 | export class SwindlersTensorService extends BaseTensorService { 6 | constructor(modelPath: string, SPAM_THRESHOLD: number) { 7 | super(modelPath, SPAM_THRESHOLD); 8 | this.loadModelMetadata('./swindlers-temp/model.json', './swindlers-temp/vocab.json'); 9 | } 10 | } 11 | 12 | export const initSwindlersTensor = async () => { 13 | // if (environmentConfig.S3_BUCKET && s3Service) { 14 | // try { 15 | // console.info('* Staring new tensorflow S3 logic...'); 16 | // await s3Service.downloadTensorFlowModel(new URL('swindlers-temp/', import.meta.url); 17 | // console.info('Tensor flow model has been loaded from S3.'); 18 | // } catch (e) { 19 | // console.error('Cannot download tensor flow model from S3.\nReason: ', e); 20 | // console.error('Use the legacy model.'); 21 | // } 22 | // } else { 23 | // console.info('Skip loading model from S3 due to no S3_BUCKET or no s3Service.'); 24 | // } 25 | 26 | const tensorService = new SwindlersTensorService('./swindlers-temp/model.json', environmentConfig.TENSOR_RANK); 27 | await tensorService.loadModel(); 28 | 29 | return tensorService; 30 | }; 31 | -------------------------------------------------------------------------------- /src/tensor/temp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /src/tensor/tensor.service.ts: -------------------------------------------------------------------------------- 1 | import { environmentConfig } from '../config'; 2 | import type { S3Service } from '../services'; 3 | 4 | import { BaseTensorService } from './base-tensor.service'; 5 | 6 | export class TensorService extends BaseTensorService { 7 | constructor(modelPath: string, SPAM_THRESHOLD: number) { 8 | super(modelPath, SPAM_THRESHOLD); 9 | this.loadModelMetadata('./temp/model.json', './temp/vocab.json'); 10 | } 11 | } 12 | 13 | export const initTensor = async (s3Service?: S3Service) => { 14 | if (environmentConfig.S3_BUCKET && s3Service) { 15 | try { 16 | console.info('* Staring new tensorflow S3 logic...'); 17 | await s3Service.downloadTensorFlowModel(new URL('temp/', import.meta.url)); 18 | console.info('Tensor flow model has been loaded from S3.'); 19 | } catch (error) { 20 | console.error('Cannot download tensor flow model from S3.\nReason:', error); 21 | console.error('Use the legacy model.'); 22 | } 23 | } else { 24 | console.info('Skip loading model from S3 due to no S3_BUCKET or no s3Service.'); 25 | } 26 | 27 | const tensorService = new TensorService('./temp/model.json', environmentConfig.TENSOR_RANK); 28 | await tensorService.loadModel(); 29 | 30 | return tensorService; 31 | }; 32 | -------------------------------------------------------------------------------- /src/testing-main.ts: -------------------------------------------------------------------------------- 1 | import type { MockContextFieldReturnType } from './testing/mock-context-field'; 2 | import { mockContextField } from './testing/mock-context-field'; 3 | import type { GrammyContext } from './types'; 4 | 5 | /** 6 | * Mock Session 7 | * */ 8 | export interface MockSessionResult< 9 | R extends MockContextFieldReturnType = MockContextFieldReturnType, 10 | > { 11 | session: R['mocked']; 12 | mockSessionMiddleware: R['middleware']; 13 | } 14 | 15 | export const mockSession = mockContextField('session', ({ mocked, middleware }) => ({ 16 | session: mocked, 17 | mockSessionMiddleware: middleware, 18 | })); 19 | 20 | /** 21 | * Mock Chat Session 22 | * */ 23 | export interface MockChatSessionResult< 24 | R extends MockContextFieldReturnType = MockContextFieldReturnType, 25 | > { 26 | chatSession: R['mocked']; 27 | mockChatSessionMiddleware: R['middleware']; 28 | } 29 | 30 | export const mockChatSession = mockContextField( 31 | 'chatSession', 32 | ({ mocked, middleware }) => 33 | ({ 34 | chatSession: mocked, 35 | mockChatSessionMiddleware: middleware, 36 | } as const), 37 | ); 38 | 39 | /** 40 | * Mock State 41 | * */ 42 | export interface MockStateResult< 43 | R extends MockContextFieldReturnType = MockContextFieldReturnType, 44 | > { 45 | state: R['mocked']; 46 | mockStateMiddleware: R['middleware']; 47 | } 48 | 49 | export const mockState = mockContextField('state', ({ mocked, middleware }) => ({ 50 | state: mocked, 51 | mockStateMiddleware: middleware, 52 | })); 53 | -------------------------------------------------------------------------------- /src/testing/get-update.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import type { Api, Bot, Context } from 'grammy'; 3 | 4 | /** 5 | * Logs internal update to create mock updates. 6 | * Used for testing. 7 | * @internal 8 | * */ 9 | export const logUpdates = = Bot>(bot: B) => { 10 | const originUpdate = bot.handleUpdate.bind(bot); 11 | // eslint-disable-next-line no-param-reassign 12 | bot.handleUpdate = (update, webhookReplyEnvelope) => { 13 | const stringifiedUpdate = JSON.stringify(update, null, 2); 14 | console.info('logUpdates', stringifiedUpdate); 15 | fs.writeFileSync('./update.json', stringifiedUpdate); 16 | 17 | return originUpdate(update, webhookReplyEnvelope); 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/testing/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-update'; 2 | export * from './outgoing-requests'; 3 | export * from './prepare'; 4 | export * from './types'; 5 | export * from './updates'; 6 | -------------------------------------------------------------------------------- /src/testing/mock-context-field.ts: -------------------------------------------------------------------------------- 1 | import type { Context, MiddlewareFn } from 'grammy'; 2 | import type { PartialDeep } from 'type-fest'; 3 | 4 | export interface MockContextFieldReturnType { 5 | mocked: C[F]; 6 | middleware: MiddlewareFn; 7 | } 8 | 9 | /** 10 | * Mock field with strict partial typing and dynamically changing it for testing 11 | * 12 | * @example 13 | * ```ts 14 | * export interface MockSessionResult< 15 | * R extends MockContextFieldReturnType = MockContextFieldReturnType, 16 | * > { 17 | * session: R['mocked']; 18 | * mockSessionMiddleware: R['middleware']; 19 | * } 20 | * 21 | * export const mockSession = mockContextField('session', ({ mocked, middleware }) => ({ 22 | * session: mocked, 23 | * mockSessionMiddleware: middleware, 24 | * })); 25 | * ``` 26 | * */ 27 | export const mockContextField = 28 | (fieldName: F, remap: (value: MockContextFieldReturnType) => R) => 29 | (mocked: PartialDeep) => 30 | remap({ 31 | mocked: mocked as C[F], 32 | middleware: (context, next) => { 33 | context[fieldName] = mocked as C[F]; 34 | return next(); 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /src/testing/prepare.ts: -------------------------------------------------------------------------------- 1 | import type { Api, Bot, Context, RawApi } from 'grammy'; 2 | import type { AsyncReturnType } from 'type-fest'; 3 | 4 | import { OutgoingRequests } from './outgoing-requests'; 5 | import { NewMemberMockUpdate } from './updates'; 6 | 7 | /** 8 | * Override api responses if needed 9 | * */ 10 | export type ApiResponses = { 11 | [M in keyof RawApi]?: Partial>; 12 | }; 13 | 14 | /** 15 | * Prepares bot for testing. 16 | * Collects and mocks API requests. 17 | * Sets default bot info. 18 | * 19 | * @example 20 | * ```ts 21 | * beforeAll(async () => { 22 | * bot = await getBot(); 23 | * outgoingRequests = await prepareBotForTesting(bot, { 24 | * getChat: {}, 25 | * }); 26 | * }, 15_000); 27 | * ``` 28 | * */ 29 | export const prepareBotForTesting = async = Bot>( 30 | bot: B, 31 | apiResponses: ApiResponses = {}, 32 | ) => { 33 | const outgoingRequests = new OutgoingRequests(); 34 | 35 | bot.api.config.use((previous, method, payload, signal) => { 36 | outgoingRequests.push({ method, payload, signal }); 37 | 38 | if (apiResponses[method]) { 39 | return Promise.resolve({ ok: true, result: apiResponses[method] }); 40 | } 41 | 42 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any 43 | return Promise.resolve({ ok: true, result: true as any }); 44 | }); 45 | 46 | const genericUpdate = new NewMemberMockUpdate(); 47 | 48 | // eslint-disable-next-line no-param-reassign 49 | bot.botInfo = { 50 | ...genericUpdate.genericUserBot, 51 | can_join_groups: true, 52 | can_read_all_group_messages: true, 53 | supports_inline_queries: false, 54 | }; 55 | 56 | await bot.init(); 57 | 58 | return outgoingRequests; 59 | }; 60 | -------------------------------------------------------------------------------- /src/testing/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { Api, Bot, Context } from 'grammy'; 2 | 3 | import type { OutgoingRequests } from '../outgoing-requests'; 4 | 5 | /** 6 | * Reusing existing tests 7 | * */ 8 | export interface GenericTestParameters = Bot> { 9 | bot: B; 10 | outgoingRequests: OutgoingRequests; 11 | } 12 | -------------------------------------------------------------------------------- /src/testing/updates/index.ts: -------------------------------------------------------------------------------- 1 | // export * from './chat-member-mock.update'; 2 | export * from './generic-mock.update'; 3 | export * from './left-member-mock.update'; 4 | export * from './message-private-mock.update'; 5 | export * from './message-super-group-mock.update'; 6 | export * from './new-member-mock.update'; 7 | -------------------------------------------------------------------------------- /src/testing/updates/left-member-mock.update.ts: -------------------------------------------------------------------------------- 1 | import type { PartialUpdate } from './generic-mock.update'; 2 | import { GenericMockUpdate } from './generic-mock.update'; 3 | 4 | export class LeftMemberMockUpdate extends GenericMockUpdate { 5 | readonly minimalUpdate = GenericMockUpdate.getValidUpdate({ 6 | update_id: this.genericUpdateId, 7 | message: { 8 | message_id: 230, 9 | from: this.genericUser2, 10 | date: this.genericSentDate, 11 | chat: this.genericSuperGroup, 12 | left_chat_member: this.genericUser, 13 | }, 14 | } as const); 15 | 16 | build() { 17 | return this.minimalUpdate; 18 | } 19 | 20 | buildOverwrite(extra: E) { 21 | return this.deepMerge(this.minimalUpdate, extra); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/testing/updates/message-private-mock.update.ts: -------------------------------------------------------------------------------- 1 | import deepmerge from 'deepmerge'; 2 | 3 | import type { PartialUpdate } from './generic-mock.update'; 4 | import { GenericMockUpdate } from './generic-mock.update'; 5 | 6 | /** 7 | * Get private message update 8 | * */ 9 | export class MessagePrivateMockUpdate extends GenericMockUpdate { 10 | readonly minimalUpdate = GenericMockUpdate.getValidUpdate({ 11 | update_id: this.genericUpdateId, 12 | message: { 13 | date: this.genericSentDate, 14 | chat: this.genericPrivateChat, 15 | message_id: 1365, 16 | from: this.genericUser, 17 | }, 18 | } as const); 19 | 20 | readonly paramsUpdate = GenericMockUpdate.getValidUpdate({ 21 | message: { 22 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 23 | // @ts-expect-error 24 | text: this.text, 25 | }, 26 | }); 27 | 28 | readonly update = deepmerge(this.minimalUpdate, this.paramsUpdate); 29 | 30 | constructor(private text: string) { 31 | super(); 32 | } 33 | 34 | build() { 35 | return this.update; 36 | } 37 | 38 | buildOverwrite(extra: E) { 39 | return this.deepMerge(this.update, extra); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/testing/updates/message-super-group-mock.update.ts: -------------------------------------------------------------------------------- 1 | import type { PartialUpdate } from './generic-mock.update'; 2 | import { GenericMockUpdate } from './generic-mock.update'; 3 | 4 | export class MessageMockUpdate extends GenericMockUpdate { 5 | readonly minimalUpdate = GenericMockUpdate.getValidUpdate({ 6 | update_id: this.genericUpdateId, 7 | message: { 8 | date: this.genericSentDate, 9 | chat: this.genericSuperGroup, 10 | message_id: 1365, 11 | from: this.genericUser, 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-expect-error 14 | text: this.text, 15 | }, 16 | }); 17 | 18 | constructor(private text: string) { 19 | super(); 20 | } 21 | 22 | build() { 23 | return this.minimalUpdate; 24 | } 25 | 26 | buildOverwrite(extra: E) { 27 | return this.deepMerge(this.minimalUpdate, extra); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/testing/updates/new-member-mock.update.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@grammyjs/types/manage'; 2 | 3 | import type { PartialUpdate } from './generic-mock.update'; 4 | import { GenericMockUpdate } from './generic-mock.update'; 5 | 6 | export class NewMemberMockUpdate extends GenericMockUpdate { 7 | readonly minimalUpdate = GenericMockUpdate.getValidUpdate({ 8 | update_id: this.genericUpdateId, 9 | message: { 10 | message_id: 230, 11 | from: this.genericUser2, 12 | date: this.genericSentDate, 13 | chat: this.genericSuperGroup, 14 | new_chat_members: [this.genericUser] as User[], 15 | }, 16 | } as const); 17 | 18 | build() { 19 | return this.minimalUpdate; 20 | } 21 | 22 | buildOverwrite(extra: E) { 23 | return this.deepMerge(this.minimalUpdate, extra); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/testing/updates/updates.test.ts: -------------------------------------------------------------------------------- 1 | import { MessagePrivateMockUpdate } from './message-private-mock.update'; 2 | 3 | describe('Testing Update Mocks', () => { 4 | describe('MessagePrivateMockUpdate', () => { 5 | it('should create when regular build and work with typing', () => { 6 | const expectedText = 'test'; 7 | const update = new MessagePrivateMockUpdate(expectedText).build(); 8 | const isTypingWorks = update.message.chat.type === 'private'; 9 | 10 | expect(isTypingWorks).toEqual(true); 11 | expect(update.message.text).toEqual(expectedText); 12 | }); 13 | 14 | it('should create extended when buildOverwrite and work with typing', () => { 15 | const expectedText = 'test'; 16 | const update = new MessagePrivateMockUpdate(expectedText).buildOverwrite({ 17 | message: { 18 | chat: { 19 | type: 'group', 20 | id: 234, 21 | title: 'GrammyMock GroupTitle', 22 | }, 23 | }, 24 | } as const); 25 | 26 | const isTypingWorks = update.message.chat.type === 'group'; 27 | 28 | expect(isTypingWorks).toEqual(true); 29 | expect(update.message.text).toEqual(expectedText); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/types/alarm.ts: -------------------------------------------------------------------------------- 1 | export interface State { 2 | alert: boolean; 3 | id: number; 4 | name: string; 5 | name_en: string; 6 | changed: Date; 7 | } 8 | 9 | export interface AlarmNotification { 10 | state: State; 11 | notification_id: string; 12 | } 13 | 14 | export interface AlarmStates { 15 | states: State[]; 16 | last_update: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/types/context.ts: -------------------------------------------------------------------------------- 1 | import type { MenuFlavor } from '@grammyjs/menu'; 2 | import type { ParseModeFlavor } from '@grammyjs/parse-mode'; 3 | import type { Bot, CommandContext, Composer, Context, Filter, FilterQuery, MiddlewareFn, SessionFlavor } from 'grammy'; 4 | 5 | import type { SelfDestructedFlavor } from '../bot/plugins'; 6 | 7 | import type { ChatSessionData, ChatSessionFlavor, SessionData } from './session'; 8 | import type { State, StateFlavor } from './state'; 9 | 10 | export type GrammyContext = SelfDestructedFlavor> & 11 | SessionFlavor & 12 | ChatSessionFlavor & 13 | StateFlavor; 14 | 15 | export type GrammyMenuContext = GrammyContext & MenuFlavor; 16 | 17 | /** 18 | * Real object with hidden fields 19 | * */ 20 | export type RealGrammyContext = GrammyContext & { tg: never; telegram: never; api: never }; 21 | 22 | export type GrammyMiddleware = MiddlewareFn; 23 | export type GrammyCommandMiddleware = GrammyMiddleware>; 24 | export type GrammyQueryMiddleware = GrammyMiddleware>; 25 | 26 | export type GrammyBot = Bot; 27 | export type GrammyErrorHandler = Parameters['errorBoundary']>[0]; 28 | 29 | export type GrammyFilter = (context: GrammyContext) => boolean; 30 | -------------------------------------------------------------------------------- /src/types/dataset.ts: -------------------------------------------------------------------------------- 1 | export enum DatasetType { 2 | POSITIVES = 'positives', 3 | NEGATIVES = 'negatives', 4 | } 5 | -------------------------------------------------------------------------------- /src/types/environment.ts: -------------------------------------------------------------------------------- 1 | export interface GrammyEnvironmentConfig { 2 | BOT_TOKEN: string; 3 | CREATOR_ID?: string | null; 4 | USERS_WHITELIST: string; 5 | USERS_FOR_SWINDLERS_STATISTIC_WHITELIST: string; 6 | } 7 | 8 | export interface ServerEnvironmentConfig { 9 | PORT: number; 10 | HOST: string; 11 | BOT_PORT: number; 12 | BOT_HOST: string; 13 | USE_SERVER: boolean; 14 | FRONTEND_HOST: string; 15 | WEB_VIEW_URL: string; 16 | } 17 | 18 | export interface MiscellaneousEnvironmentConfig { 19 | ENV: 'local' | 'develop' | 'production'; 20 | UNIT_TESTING?: boolean; 21 | TEST_TENSOR: boolean; // deprecated 22 | TENSOR_RANK: number; 23 | REDIS_URL: string; 24 | DEBUG: boolean; 25 | DEBUG_MIDDLEWARE: boolean; 26 | DISABLE_LOGS_CHAT: boolean; 27 | ALARM_KEY: string; 28 | DISABLE_ALARM_API: boolean; 29 | } 30 | 31 | export interface UserbotEnvironmentConfig { 32 | USERBOT_APP_ID: string; 33 | USERBOT_API_HASH: string; 34 | USERBOT_LOGIN_PHONE: string; 35 | USERBOT_LOGIN_CODE: string; 36 | USERBOT_TRAING_CHAT_NAME: string; 37 | } 38 | 39 | export interface PostgresEnvironmentConfig { 40 | POSTGRES_PASSWORD: string; 41 | PGHOST: string; 42 | PGUSER: string; 43 | PGDATABASE: string; 44 | PGPORT: string; 45 | } 46 | 47 | export interface GoogleEnvironmentConfig { 48 | DISABLE_GOOGLE_API: boolean; 49 | GOOGLE_CREDITS: string; 50 | GOOGLE_SPREADSHEET_ID: string; 51 | } 52 | 53 | export interface AwsEnvironmentConfig { 54 | S3_BUCKET?: string | null; 55 | S3_PATH: string; 56 | AWS_REGION: string; 57 | } 58 | 59 | export type EnvironmentConfig = GrammyEnvironmentConfig & 60 | ServerEnvironmentConfig & 61 | MiscellaneousEnvironmentConfig & 62 | UserbotEnvironmentConfig & 63 | PostgresEnvironmentConfig & 64 | GoogleEnvironmentConfig & 65 | AwsEnvironmentConfig; 66 | -------------------------------------------------------------------------------- /src/types/express.ts: -------------------------------------------------------------------------------- 1 | import type { DatasetKeys } from '../../dataset/dataset'; 2 | 3 | import type { SwindlersResult, SwindlerTensorResult } from './swindlers'; 4 | 5 | /** 6 | * Process 7 | * */ 8 | export interface ProcessResponseBody { 9 | result: string | null; 10 | time: number; 11 | expressStartTime: string; 12 | } 13 | 14 | export interface ProcessRequestBody { 15 | message: string; 16 | datasetPath: DatasetKeys; 17 | strict: boolean; 18 | } 19 | 20 | /** 21 | * Tensor 22 | * */ 23 | export interface TensorResponseBody { 24 | result: SwindlerTensorResult; 25 | time: number; 26 | expressStartTime: string; 27 | } 28 | 29 | export interface TensorRequestBody { 30 | message: string; 31 | rate: number; 32 | } 33 | 34 | /** 35 | * Swindler 36 | * */ 37 | export interface SwindlerResponseBody { 38 | result: SwindlersResult; 39 | time: number; 40 | expressStartTime: string; 41 | } 42 | 43 | export interface SwindlerRequestBody { 44 | message: string; 45 | } 46 | 47 | /** 48 | * Parse video 49 | * */ 50 | export interface ParseVideoSuccessResponseBody { 51 | screenshots: ReturnType[]; 52 | time: number; 53 | expressStartTime: string; 54 | } 55 | 56 | export interface ParseVideoErrorResponseBody { 57 | error: string; 58 | } 59 | 60 | export type ParseVideoResponseBody = ParseVideoSuccessResponseBody | ParseVideoErrorResponseBody; 61 | 62 | export interface ParseVideoRequestBody { 63 | duration?: string; 64 | } 65 | -------------------------------------------------------------------------------- /src/types/generic.ts: -------------------------------------------------------------------------------- 1 | export type LooseAutocomplete = T | Omit; 2 | -------------------------------------------------------------------------------- /src/types/google.ts: -------------------------------------------------------------------------------- 1 | export type GoogleShortCellData = string; 2 | 3 | export interface GoogleFullCellData { 4 | value: string; 5 | index: number; 6 | sheetKey: string; 7 | fullPath: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/image.ts: -------------------------------------------------------------------------------- 1 | export enum ImageType { 2 | PHOTO = 'photo', 3 | STICKER = 'sticker', 4 | VIDEO_STICKER = 'video_sticker', 5 | VIDEO = 'video', 6 | VIDEO_NOTE = 'video_note', 7 | ANIMATION = 'animation', // GIFs 8 | UNKNOWN = 'unknown', 9 | } 10 | 11 | export type ImageVideoTypes = ImageType.VIDEO | ImageType.VIDEO_STICKER | ImageType.ANIMATION | ImageType.VIDEO_NOTE; 12 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './alarm'; 2 | export * from './context'; 3 | export * from './dataset'; 4 | export * from './environment'; 5 | export * from './express'; 6 | export * from './generic'; 7 | export * from './google'; 8 | export * from './image'; 9 | export * from './language-detection'; 10 | export * from './mtproto'; 11 | export * from './nsfw'; 12 | export * from './session'; 13 | export * from './swindlers'; 14 | -------------------------------------------------------------------------------- /src/types/language-detection.ts: -------------------------------------------------------------------------------- 1 | export interface LanguageDetectionResult { 2 | result: boolean; 3 | percent: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/nsfw.ts: -------------------------------------------------------------------------------- 1 | import type { predictionType } from 'nsfwjs'; 2 | 3 | export interface NsfwTensorNegativeResult { 4 | isSpam: false; 5 | predictions: predictionType[] | predictionType[][]; 6 | highestPrediction: predictionType; 7 | } 8 | 9 | export interface NsfwTensorPositiveResult { 10 | isSpam: true; 11 | predictions: predictionType[] | predictionType[][]; 12 | deletePrediction: predictionType; 13 | deleteRank: number; 14 | } 15 | 16 | export type NsfwTensorResult = NsfwTensorNegativeResult | NsfwTensorPositiveResult; 17 | -------------------------------------------------------------------------------- /src/types/object.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Matches a JSON array. 3 | 4 | @category JSON 5 | */ 6 | // eslint-disable-next-line no-use-before-define 7 | export type CustomJsonArray = CustomJsonValue[]; 8 | 9 | /** 10 | Matches any valid JSON primitive value. 11 | 12 | @category JSON 13 | */ 14 | export type CustomJsonPrimitive = string | number | boolean | null | undefined; 15 | 16 | /** 17 | Matches any valid JSON value. 18 | 19 | @see `Jsonify` if you need to transform a type to one that is assignable to `CustomJsonValue`. 20 | 21 | @category JSON 22 | */ 23 | // eslint-disable-next-line no-use-before-define 24 | export type CustomJsonValue = CustomJsonPrimitive | CustomJsonObject | CustomJsonArray; 25 | 26 | export type CustomJsonObject = { [Key in string]: CustomJsonValue } & { [Key in string]?: CustomJsonValue | undefined }; 27 | -------------------------------------------------------------------------------- /src/types/swindlers.ts: -------------------------------------------------------------------------------- 1 | // TODO use when ts will be available 2 | // export enum SwindlerTypeEnum { 3 | // SITE = 'site', 4 | // MENTION = 'mention', 5 | // CARD = 'card', 6 | // TENSOR = 'tensor', 7 | // COMPARE = 'compare', 8 | // NO_MATCH = 'no match', 9 | // } 10 | 11 | import type fs from 'node:fs'; 12 | 13 | export type SwindlerType = 'site' | 'mention' | 'card' | 'tensor' | 'compare' | 'no match'; 14 | 15 | export interface SwindlersBaseResult { 16 | isSpam: boolean; 17 | rate: number; 18 | } 19 | 20 | export interface SwindlersBotsResult extends SwindlersBaseResult { 21 | nearestName?: string; 22 | currentName: string; 23 | } 24 | 25 | export interface SwindlersUrlsResult extends SwindlersBaseResult { 26 | nearestName?: string; 27 | currentName: string; 28 | redirectUrl: string; 29 | } 30 | 31 | export interface SwindlerTensorResult { 32 | spamRate: number; 33 | deleteRank: number; 34 | isSpam: boolean; 35 | fileStat: fs.Stats; 36 | } 37 | 38 | export interface SwindlersResultSummary { 39 | foundSwindlerUrl?: SwindlersBaseResult | SwindlersUrlsResult | null; 40 | foundSwindlerMention?: SwindlersBotsResult | null; 41 | foundCard?: true | null; 42 | foundTensor?: SwindlerTensorResult; 43 | foundCompare?: { 44 | foundSwindler: boolean; 45 | spamRate: number; 46 | }; 47 | } 48 | 49 | export interface SwindlersResult extends SwindlersBaseResult { 50 | reason: SwindlerType; 51 | displayReason?: string; 52 | match?: string; 53 | results: SwindlersResultSummary; 54 | } 55 | -------------------------------------------------------------------------------- /src/userbot/.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | from-entities.json 3 | -------------------------------------------------------------------------------- /src/userbot/find-channel-admins.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-return,@typescript-eslint/restrict-template-expressions */ 2 | import fs from 'node:fs'; 3 | 4 | import type { ProtoUpdate, User } from '../types'; 5 | 6 | import type { API } from './api'; 7 | 8 | /** 9 | * @param {API} api 10 | * */ 11 | export async function findChannelAdmins(api: API) { 12 | const chat = ''; 13 | 14 | const resolvedPeer = await api.call('contacts.search', { 15 | q: chat, 16 | }); 17 | 18 | const testChannel = resolvedPeer.chats[0]; 19 | 20 | console.info('Search Channel Found:', testChannel); 21 | 22 | const chatPeer = { 23 | _: 'inputPeerChannel', 24 | channel_id: testChannel.id, 25 | access_hash: testChannel.access_hash, 26 | }; 27 | 28 | const admins = await api.call('channels.getParticipants', { 29 | channel: chatPeer, 30 | filter: { _: 'channelParticipantsAdmins' }, 31 | offset: 0, 32 | limit: 100, 33 | }); 34 | 35 | console.info(admins); 36 | 37 | fs.writeFileSync( 38 | `./admins.${chat}.txt`, 39 | (admins.users as (User & { username?: string; phone?: string })[]) 40 | .filter((admin) => admin.username || admin.phone) 41 | .map((admin) => `https://t.me/${admin.username || `+${admin.phone}`}`) 42 | .join('\t'), 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/__tests__/censor-word.util.test.ts: -------------------------------------------------------------------------------- 1 | import { censorWord } from '..'; 2 | 3 | describe('censorWord', () => { 4 | it('should censor a word', () => { 5 | const result = censorWord('anewword'); 6 | 7 | expect(result).toEqual('a******d'); 8 | }); 9 | 10 | it('should censor short 3 letter word till end', () => { 11 | const result = censorWord('new'); 12 | 13 | expect(result).toEqual('n**'); 14 | }); 15 | 16 | it('should censor short 2 letter word till end', () => { 17 | const result = censorWord('an'); 18 | 19 | expect(result).toEqual('a*'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/__tests__/remove-system-information.util.test.ts: -------------------------------------------------------------------------------- 1 | import { removeSystemInformationUtil } from '../remove-system-information.util'; 2 | 3 | describe('removeSystemInformationUtil', () => { 4 | it('should remove system information', () => { 5 | const message = `Looks like swindler's message (61.54%) from mention (@anna) by user @dmytro: 6 | 7 | Переселенці 8 | @anna 😊 А ви писали, що назавжди - те ж саме, тільки вже не фото листочка від руки написаного, а скріншот...`.trim(); 9 | 10 | const expectedMessage = '@anna 😊 А ви писали, що назавжди - те ж саме, тільки вже не фото листочка від руки написаного, а скріншот...'; 11 | 12 | expect(removeSystemInformationUtil(message)).toEqual(expectedMessage); 13 | }); 14 | 15 | it('should not remove system information if not any', () => { 16 | const message = '@anna 😊 А ви писали, що назавжди - те ж саме, тільки вже не фото листочка від руки написаного, а скріншот...'; 17 | const expectedMessage = '@anna 😊 А ви писали, що назавжди - те ж саме, тільки вже не фото листочка від руки написаного, а скріншот...'; 18 | 19 | expect(removeSystemInformationUtil(message)).toEqual(expectedMessage); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/__tests__/search-set.test.ts: -------------------------------------------------------------------------------- 1 | import { SearchSet } from '../search-set'; 2 | 3 | describe('SearchSet', () => { 4 | it('should find a word if it in set', () => { 5 | const searchSet = new SearchSet(['афігєний']); 6 | const message = 'Цей виступ був АФІГЄНИй, і всі залишились безмовними від захвату.'; 7 | const actual = searchSet.search(message); 8 | 9 | expect(actual).toStrictEqual({ found: 'афігєний', origin: 'афігєний', wordIndex: 3 }); 10 | }); 11 | 12 | it('should find a word with repeated letters in set', () => { 13 | const searchSet = new SearchSet(['афігєний']); 14 | const message = 'Цей виступ був АФІііііііГЄНИй, і всі залишились безмовними від захвату.'; 15 | const actual = searchSet.search(message); 16 | 17 | expect(actual).toStrictEqual({ found: 'афігєний', origin: 'афііііііігєний', wordIndex: 3 }); 18 | }); 19 | 20 | it('should find a two word combination', () => { 21 | const searchSet = new SearchSet(['це круто']); 22 | const message = 'Коли я там буваю, це КРУТО! І весело'; 23 | const actual = searchSet.search(message); 24 | 25 | expect(actual).toStrictEqual({ found: 'це круто', origin: 'це круто', wordIndex: 12 }); 26 | }); 27 | 28 | it('should not detect regular messages', () => { 29 | const searchSet = new SearchSet(['афігєний']); 30 | const message = 'Сонечко сьогодні палить класно.'; 31 | const actual = searchSet.search(message); 32 | 33 | expect(actual).toBeNull(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/utils/array-diff.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @template T 3 | * @param {T} smallArray 4 | * @param {T} bigArray 5 | * 6 | * @returns {T} 7 | * */ 8 | export function arrayDiff(bigArray: T[], smallArray: T[]): T[] { 9 | return bigArray.filter((item) => !smallArray.includes(item)); 10 | } 11 | 12 | /** 13 | * @template T 14 | * @param { [key: string]: string[] } smallSet 15 | * @param { [key: string]: string[] } bigSet 16 | * 17 | * @returns { [key: string]: string[] } 18 | * */ 19 | export function setOfArraysDiff(smallSet: { [key: string]: string[] }, bigSet: { [key: string]: string[] }): { [key: string]: string[] } { 20 | // eslint-disable-next-line unicorn/no-array-reduce 21 | return Object.entries(bigSet).reduce((accumulator, [key, value]) => { 22 | accumulator[key] = arrayDiff(value, smallSet[key]); 23 | return accumulator; 24 | }, {}); 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/censor-word.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Censors a word by replacing characters with asterisks (*) while keeping the first and last characters intact. 3 | * 4 | * If the word length is less than or equal to 3, only the last character is preserved and the rest are replaced with asterisks. 5 | * If the word length is more than 3, both the first and last characters are preserved, while the characters in between are replaced with asterisks. 6 | * 7 | * @param {string} word - The word to be censored. 8 | * @returns {string} The censored word. 9 | */ 10 | export function censorWord(word: string): string { 11 | if (word.length <= 3) { 12 | return word.charAt(0) + '*'.repeat(word.length - 1); 13 | } 14 | 15 | return word.charAt(0) + '*'.repeat(word.length - 2) + word.at(-1); 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/csv.util.ts: -------------------------------------------------------------------------------- 1 | import { InputFile } from 'grammy'; 2 | 3 | function processCsvValue(value: string) { 4 | return `"${value 5 | .replaceAll(/[^\da-z\u0400-\u04FF]/gi, ' ') 6 | .replaceAll(/\s\s+/g, ' ') 7 | .trim()}"`; 8 | } 9 | 10 | function toCsvRows(headers: string[], columns: string[][]) { 11 | const output = [headers]; 12 | // eslint-disable-next-line unicorn/no-array-reduce 13 | const numberRows = columns.map((col) => col.length).reduce((a, b) => Math.max(a, b)); 14 | for (let row = 0; row < numberRows; row += 1) { 15 | output.push(columns.map((c) => (c[row] ? processCsvValue(c[row]) : ''))); 16 | } 17 | return output; 18 | } 19 | 20 | function toCsvString(data: string[][]) { 21 | let output = ''; 22 | // eslint-disable-next-line no-return-assign 23 | data.forEach((row) => (output += `${row.join(',')}\n`)); 24 | return output; 25 | } 26 | 27 | export function csvConstructor(headers: string[], columns: string[][], fileName: string): InputFile { 28 | const csvString = toCsvString(toCsvRows(headers, columns)); 29 | return new InputFile(Buffer.from(csvString), `${fileName}.csv`); 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/deep-copy.util.ts: -------------------------------------------------------------------------------- 1 | export function deepCopy(entity: T): T { 2 | return JSON.parse(JSON.stringify(entity)) as T; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/empty-functions.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Temporary fix for catch error handling 3 | * TODO rework with global grammy error handling 4 | * */ 5 | // eslint-disable-next-line @typescript-eslint/no-empty-function 6 | export const emptyFunction = () => {}; 7 | export const emptyPromiseFunction = () => Promise.resolve(); 8 | -------------------------------------------------------------------------------- /src/utils/get-typed-value.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a function that accepts a value and enforces its type based on the type parameter. 3 | * 4 | * @template T - The expected type for the value. 5 | * @returns {(value: T) => T} A function that enforces the provided type for the input value. 6 | * 7 | * @example 8 | * ```ts 9 | * type MyUnion = 'value1' | 'value2' | 'value3'; 10 | * const typedUnion = getTypedValue()(['value1', 'value2']); // ('value1' | 'value2')[], not MyUnion[] 11 | * ``` 12 | */ 13 | export const getTypedValue = 14 | () => 15 | (value: S): S => 16 | value; 17 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { MessageUtil } from './message.util'; 2 | import { TelegramUtil } from './telegram.util'; 3 | 4 | export const messageUtil = new MessageUtil(); 5 | export const telegramUtil = new TelegramUtil(); 6 | 7 | export * from './censor-word.util'; 8 | export * from './deep-copy.util'; 9 | export * from './domain-allow-list'; 10 | export * from './empty-functions.util'; 11 | export * from './error-handler'; 12 | export * from './generic.util'; 13 | export * from './get-typed-value.util'; 14 | export * from './optimize-write-context.util'; 15 | export * from './remove-duplicates.util'; 16 | export * from './remove-repeated-letters.util'; 17 | export * from './remove-system-information.util'; 18 | export * from './reveal-hidden-urls.util'; 19 | export * from './search-set'; 20 | export * from './video.util'; 21 | -------------------------------------------------------------------------------- /src/utils/message.util.ts: -------------------------------------------------------------------------------- 1 | // const CyrillicToTranslit = require('cyrillic-to-translit-js'); 2 | import Fuse from 'fuse.js'; 3 | 4 | // const cyrillicToTranslit = new CyrillicToTranslit(); 5 | 6 | const options = { 7 | shouldSort: true, 8 | threshold: 0.1, 9 | location: 0, 10 | distance: 100_000, 11 | maxPatternLength: 32, 12 | minMatchCharLength: 6, 13 | }; 14 | 15 | export class MessageUtil { 16 | findInText(message: string, searchFor: string, strict = false) { 17 | /** 18 | * Direct hit 19 | * */ 20 | let directHit: string | boolean = false; 21 | 22 | if (searchFor.length <= 4) { 23 | directHit = strict 24 | ? message 25 | .replaceAll(/[^\da-z\u0400-\u04FF]/gi, ' ') 26 | .replaceAll(/\s\s+/g, ' ') 27 | .split(' ') 28 | .find((word) => word.toLowerCase() === searchFor.toLowerCase()) || false 29 | : message.toLowerCase().includes(searchFor.toLowerCase()); 30 | 31 | return directHit; 32 | } 33 | 34 | /** 35 | * Translit hit 36 | * */ 37 | // const translitHit = cyrillicToTranslit 38 | // .transform(message, ' ') 39 | // .toLowerCase() 40 | // .includes(cyrillicToTranslit.transform(searchFor, ' ').toLowerCase()); 41 | // 42 | // if (translitHit) { 43 | // return true; 44 | // } 45 | 46 | /** 47 | * Contains search 48 | * */ 49 | // return message.toLowerCase().includes(searchFor.toLowerCase()); 50 | return false; 51 | } 52 | 53 | /** 54 | * @param {string} message 55 | * @param {string[]} wordsArray 56 | * 57 | * @returns {string | null} 58 | * */ 59 | fuseInText(message: string, wordsArray: string[]) { 60 | /** 61 | * Fuse hit 62 | * */ 63 | const fuseInstance = new Fuse([message], options); 64 | 65 | return wordsArray.find((word) => fuseInstance.search(word).length > 0) || null; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/optimize-write-context.util.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyContext, RealGrammyContext } from '../types'; 2 | import type { NsfwPhotoResult } from '../types/state'; 3 | 4 | import { deepCopy } from './deep-copy.util'; 5 | 6 | /** 7 | * Optimize real grammy context for logging 8 | * */ 9 | export function optimizeWriteContextUtil(context: GrammyContext): RealGrammyContext { 10 | const writeContext = deepCopy(context) as RealGrammyContext; 11 | // noinspection JSConstantReassignment 12 | delete writeContext.tg; 13 | 14 | /** 15 | * Remove extra buffers to optimise the output log 16 | * */ 17 | if (writeContext.state.photo?.file) { 18 | writeContext.state.photo.file = Buffer.from([]); 19 | } 20 | 21 | if (writeContext.state.photo && 'fileFrames' in writeContext.state.photo) { 22 | writeContext.state.photo.fileFrames = []; 23 | } 24 | 25 | if (writeContext.state.nsfwResult && ((writeContext.state.nsfwResult as NsfwPhotoResult).tensor?.predictions?.length || 0 > 1)) { 26 | (writeContext.state.nsfwResult as NsfwPhotoResult).tensor.predictions = []; 27 | } 28 | 29 | return writeContext; 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/remove-duplicates.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @template T 3 | * @param {T} array 4 | * 5 | * @returns {T} 6 | * */ 7 | export function removeDuplicates(array: T[]): T[] { 8 | return [...new Set(array)]; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/remove-repeated-letters.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes repeated consecutive letters from a given string. 3 | * 4 | * @param {string} message - The input string containing repeated consecutive letters. 5 | * @returns {string} A new string with repeated consecutive letters removed. 6 | */ 7 | export function removeRepeatedLettersUtil(message: string): string { 8 | return message.replaceAll(/(.)\1+/g, '$1'); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/remove-system-information.util.ts: -------------------------------------------------------------------------------- 1 | import { logsStartMessages } from '../message'; 2 | 3 | /** 4 | * Removes system information from a given message. 5 | * If the message contains any of the predefined log start messages, 6 | * the system information portion of the message is removed. 7 | * 8 | * @param {string} message - The input message to process. 9 | * @returns {string} The modified message with system information removed, 10 | * or the original message if no system information is found. 11 | */ 12 | export const removeSystemInformationUtil = (message: string): string => { 13 | const hasLog = [...logsStartMessages.keys()].some((logStartMessage) => message.startsWith(logStartMessage)); 14 | 15 | return hasLog ? message.split('\n').slice(3).join('\n') : message; 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/reveal-hidden-urls.util.ts: -------------------------------------------------------------------------------- 1 | import type { GrammyContext } from '../types'; 2 | 3 | function cutInHiddenUrls(string_: string | undefined, cutStart: number, cutEnd: number, url: string): string { 4 | return string_ ? string_.slice(0, Math.max(0, cutStart)) + url + string_.slice(cutEnd) : ''; 5 | } 6 | 7 | /** 8 | * Reveals real link that were used in the message 9 | * 10 | * @param {GrammyContext} context 11 | * 12 | * @returns string 13 | * */ 14 | export function revealHiddenUrls(context: GrammyContext): string { 15 | let { text } = context.state; 16 | const entities = context.msg?.entities; 17 | 18 | if (entities) { 19 | let additionalUrlsLength = 0; 20 | let deletedTextLength = 0; 21 | entities.forEach((entity) => { 22 | if (entity.type === 'text_link') { 23 | const { offset } = entity; 24 | const { length, url } = entity; 25 | const hiddenUrl = url; 26 | text = 27 | additionalUrlsLength <= 0 28 | ? cutInHiddenUrls(text, offset, offset + length, hiddenUrl) 29 | : cutInHiddenUrls( 30 | text, 31 | offset + additionalUrlsLength - deletedTextLength, 32 | offset + length + additionalUrlsLength - deletedTextLength, 33 | hiddenUrl, 34 | ); 35 | deletedTextLength += length; 36 | additionalUrlsLength += hiddenUrl.length; 37 | } 38 | }); 39 | } 40 | 41 | return text!; 42 | } 43 | -------------------------------------------------------------------------------- /src/video/index.ts: -------------------------------------------------------------------------------- 1 | export * from './video.service'; 2 | -------------------------------------------------------------------------------- /src/video/temp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /temp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "ts-node": { 4 | "swc": true, 5 | "esm": true, 6 | "experimentalSpecifierResolution": "node" 7 | }, 8 | "compilerOptions": { 9 | "target": "ES2022", 10 | "outDir": "dist", 11 | "lib": ["es2022"], 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "strictNullChecks": true, 16 | "esModuleInterop": true, 17 | "strict": true, 18 | "skipLibCheck": true, 19 | "types": [ 20 | "jest" 21 | ], 22 | "typeRoots": [ 23 | "node_modules/@types", 24 | "src/lib-types" 25 | ], 26 | "paths": { 27 | "@mtproto/core": [ 28 | "./src/lib-types/mtproto__core" 29 | ] 30 | }, 31 | "forceConsistentCasingInFileNames": true, 32 | "noImplicitAny": false, 33 | "allowSyntheticDefaultImports": true, 34 | "baseUrl": "src" 35 | }, 36 | "display": "Recommended", 37 | "include": [ 38 | "src/**/*", 39 | "dataset/**/*" 40 | ], 41 | "exclude": [ 42 | "node_modules", 43 | "**/*.spec.ts", 44 | "src/lib-types/**" 45 | ] 46 | } 47 | --------------------------------------------------------------------------------