├── .banner ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ └── codeql-analysis.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc ├── .mocharc.ts ├── .npmrc ├── .nycrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── commitlint.config.js ├── docs ├── .gitkeep └── index.html ├── package-lock.json ├── package.json ├── src ├── bot │ ├── app.ts │ └── logger.ts ├── cli │ └── sort-words.ts ├── data │ ├── accounts-not-to-follow.json │ ├── words-not-to-follow.json │ ├── words-to-follow.json │ └── words-with-suspicion.json ├── database │ └── migrations │ │ ├── 20200409211407_create_table_users.ts │ │ ├── 20200410163135_create_table_tweets.ts │ │ ├── 20200410173130_create_table_medias.ts │ │ ├── 20200512190140_create_table_words.ts │ │ └── 20201229094527_fix_database_collation.ts ├── env.ts ├── knex-export.ts ├── knexfile.ts ├── translations │ └── en.json ├── twit.ts ├── types │ └── general.d.ts └── utils │ ├── date.ts │ ├── i18n.ts │ ├── index.ts │ ├── misc.ts │ ├── string.ts │ └── tweet.ts ├── test ├── 0 - express-server │ ├── routes.ts │ ├── server.ts │ └── tweets.json ├── 1 - unit │ ├── date-utils.spec.ts │ ├── loadFiles.spec.ts │ └── string-utils.spec.ts ├── 2 - integration │ └── integration.spec.ts └── mocha.opts └── tsconfig.json /.banner: -------------------------------------------------------------------------------- 1 | _____ ______ _ 2 | | __ \ | ____| (_) 3 | | |__) |_ __ ___ __ _ _ __ __ _ _ __ ___ _ __ ___ ___ _ __ | |__ __ _ _ __ ___ _ 4 | | ___/| '__|/ _ \ / _` || '__|/ _` || '_ ` _ \ | '_ ` _ \ / _ \| '__| | __|/ _` || '__|/ __|| | 5 | | | | | | (_) || (_| || | | (_| || | | | | || | | | | || __/| | | | | (_| || | \__ \| | 6 | |_| |_| \___/ \__, ||_| \__,_||_| |_| |_||_| |_| |_| \___||_| |_| \__,_||_| |___/|_| 7 | __/ | 8 | |___/ 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | ij_javascript_enforce_trailing_comma = whenmultiline 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Application 2 | NODE_ENV=development 3 | DEBUG_MODE=true 4 | IGNORE_USERS_NEWER_THAN= 5 | 6 | # Twitter credentials: Acquire yours form https://developer.twitter.com/en.html 7 | CONSUMER_KEY= 8 | CONSUMER_SECRET= 9 | ACCESS_TOKEN= 10 | ACCESS_TOKEN_SECRET= 11 | STRICT_SSL=true 12 | 13 | # AppSignal 14 | APPSIGNAL_PUSH_API_KEY= 15 | APPSIGNAL_APP_NAME= 16 | 17 | # Database connection 18 | DB_ENABLE=true # Store tweets in a database? 19 | DB_HOST=localhost 20 | DB_PORT=3306 21 | DB_NAME= 22 | DB_USERNAME= 23 | DB_PASSWORD= 24 | # pg | sqlite3 | mysql* | mysql2 | oracledb | mssql 25 | DB_DRIVER=mysql 26 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | knex 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'airbnb', 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:mocha/recommended', 6 | ], 7 | parser: '@typescript-eslint/parser', 8 | plugins: [ 9 | '@typescript-eslint', 10 | 'prettier', 11 | 'mocha', 12 | 'chai-friendly' 13 | ], 14 | settings: { 15 | 'import/parsers': { 16 | '@typescript-eslint/parser': ['.ts'], 17 | }, 18 | 'import/resolver': { 19 | typescript: {}, 20 | }, 21 | }, 22 | rules: { 23 | 'import/no-extraneous-dependencies': [ 24 | 2, 25 | { 26 | devDependencies: 27 | ['test/**/*.ts', 'test/**/*.js'] 28 | } 29 | ], 30 | '@typescript-eslint/indent': [2, 2], 31 | '@typescript-eslint/camelcase': [0, 0], 32 | '@typescript-eslint/no-var-requires': [0, 0], 33 | 'no-param-reassign': [0, 0], 34 | "import/extensions": [ 35 | "error", 36 | "ignorePackages", 37 | { 38 | "js": "never", 39 | "ts": "never", 40 | } 41 | ], 42 | 'no-cond-assign': 0, 43 | 'no-unused-expressions': 0, 44 | 'chai-friendly/no-unused-expressions': 2, 45 | 'import/first': 0, 46 | 'import/order': 0, 47 | 'implicit-arrow-linebreak': 0, 48 | 'operator-linebreak': 0, 49 | 'import/no-cycle': 0, 50 | 'import/prefer-default-export': 0, 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [ 14.x ] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Node.js 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | file_name: .env 21 | - name: Create env. file 22 | run: | 23 | touch .env 24 | echo NODE_ENV=staging >> .env 25 | echo DEBUG_MODE=true >> .env 26 | echo CONSUMER_KEY=${{ secrets.CONSUMER_KEY }} >> .env 27 | echo CONSUMER_SECRET=${{ secrets.CONSUMER_SECRET }} >> .env 28 | echo ACCESS_TOKEN=${{ secrets.ACCESS_TOKEN }} >> .env 29 | echo ACCESS_TOKEN_SECRET=${{ secrets.ACCESS_TOKEN_SECRET }} >> .env 30 | echo STRICT_SSL=true >> .env 31 | echo DB_ENABLE=false >> .env 32 | cat .env 33 | - name: npm install, and test 34 | run: | 35 | npm i 36 | npm t 37 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '25 17 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript', 'typescript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Directories # 2 | ############### 3 | .idea/ 4 | node_modules/ 5 | jspm_packages/ 6 | lib-cov 7 | act 8 | 9 | 10 | # Environment # 11 | ############## 12 | .env 13 | .env.testing 14 | 15 | 16 | # Logs and databases # 17 | ###################### 18 | logs 19 | *.log 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | *.sqlite 24 | 25 | 26 | 27 | # Coverage directory used by tools like istanbul # 28 | ################################################## 29 | coverage 30 | 31 | 32 | # nyc test coverage # 33 | ##################### 34 | .nyc_output 35 | 36 | 37 | # Bower dependency directory # 38 | bower_components 39 | 40 | 41 | # node-waf configuration # 42 | ########################## 43 | .lock-wscript 44 | 45 | 46 | # Compiled binary addons / Generated files # 47 | ########################## 48 | build/Release 49 | dist 50 | 51 | 52 | # Typescript v1 declaration files # 53 | ################################### 54 | typings/ 55 | 56 | 57 | # Optional npm cache directory # 58 | ################################ 59 | .npm 60 | 61 | 62 | # Optional eslint cache # 63 | ######################### 64 | .eslintcache 65 | 66 | 67 | # Optional REPL history # 68 | ######################### 69 | .node_repl_history 70 | 71 | 72 | # Output of 'npm pack' # 73 | ######################## 74 | *.tgz 75 | 76 | 77 | # Yarn Integrity file # 78 | ####################### 79 | .yarn-integrity 80 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | 6 | #npm t -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts}": "eslint --cache --fix", 3 | "*.{js,ts,scss,css}": "prettier --write --ignore-unknown" 4 | } 5 | -------------------------------------------------------------------------------- /.mocharc.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | spec: 'test/**/*.spec.ts', 3 | }; 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-typescript", 3 | "all": true 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "arrowParens": "always", 4 | "singleQuote": true, 5 | "tabs": 2, 6 | "trailingComma": "all", 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [2.0.0-alpha.2](https://github.com/amirhoseinsalimi/programmer-fa/compare/v2.0.0-alpha.1...v2.0.0-alpha.2) (2023-01-26) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * add type-safety to callback function ([a0e0484](https://github.com/amirhoseinsalimi/programmer-fa/commit/a0e0484895f988eb40fe821d6cd838bbd743ef8c)) 7 | * move a function to "utils" directory ([693195d](https://github.com/amirhoseinsalimi/programmer-fa/commit/693195dcd28e91cf0f7f57051fe620b85bd5a39c)) 8 | 9 | 10 | ### Features 11 | 12 | * add changelogs ([e45b145](https://github.com/amirhoseinsalimi/programmer-fa/commit/e45b1456bf9d39222cda5139c80e84243ccbdbb9)) 13 | 14 | 15 | 16 | # [2.0.0-alpha.1](https://github.com/amirhoseinsalimi/programmer-fa/compare/v1.0.0-beta...v2.0.0-alpha.1) (2022-12-24) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * correct calculating retweeter's register date ([b256f76](https://github.com/amirhoseinsalimi/programmer-fa/commit/b256f765271591ac2b5eec290b18f28071a58385)) 22 | * remove redundant utils file ([d4f9854](https://github.com/amirhoseinsalimi/programmer-fa/commit/d4f98543fc5e5f4ea60fbd8a61f627149451ddb3)) 23 | 24 | 25 | ### Features 26 | 27 | * add I18n support ([970bf6a](https://github.com/amirhoseinsalimi/programmer-fa/commit/970bf6ad942ad349cd8acb0c19b803e02acb4f2d)) 28 | 29 | 30 | 31 | # [1.0.0-beta](https://github.com/amirhoseinsalimi/programmer-fa/compare/v0.5.0...v1.0.0-beta) (2020-12-29) 32 | 33 | 34 | ### Reverts 35 | 36 | * Revert "Update words!" ([f2cfb1e](https://github.com/amirhoseinsalimi/programmer-fa/commit/f2cfb1e4deefb9b41cf55fa7eef99d8116855577)) 37 | 38 | 39 | 40 | # 0.5.0 (2020-05-25) 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Amir Hosein Salimi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Programmer Farsi Twitter Bot 2 | 3 | [![Follow on Twitter](http://img.shields.io/twitter/follow/programmer_fa.svg?label=follow+@programmer_fa)](https://twitter.com/programmer_fa) 4 | 5 |

6 | 7 | A simple bot that brings Persian / English tweets about programming to your feed. 8 | Feel free to add your desired words. 9 | 10 | > **Note** 11 | > This bot is being re-written. The current work is being done on `v2` branch which is going to be merged with `master` once is completed. I suggest you to send your PRs to `v2` branch for now. 12 | 13 | ### Requirements to run the bot 14 | 15 | 1. A developer account (Grab one from here [Twitter](https://developer.twitter.com/)) 16 | 2. pm2 module (run `npm i -g pm2`) 17 | 3. Node.js and npm 18 | 19 | ### Project setup 20 | 21 | 0. **Clone this repo:**\ 22 | Run `git clone https://github.com/amirhoseinsalimi/programmer-fa` 23 | 24 | 1. **Install dependencies:**\ 25 | Run `npm i` 26 | 27 | 2. **Create an .env file**\ 28 | Copy the content of `.env.example` into `.env` and fill it with your credentials 29 | 30 | 3. **Run migrations**\ 31 | Run the command `npm run migrate:latest` to create the tables 32 | 33 | 4. **Run the bot**\ 34 | For development with hot reload: `npm run dev`\ 35 | For Run `npm start` 36 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /docs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirhoseinsalimi/programmer-fa/bca9a08b2b2a6c805c7bc021a421e77a61aa78fe/docs/.gitkeep -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Hello World 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "programmer-fa", 3 | "version": "2.0.0-alpha.2", 4 | "description": "A bot that brings tweets about programming to your feed", 5 | "main": "dist/app.js", 6 | "engines": { 7 | "node": ">=14.0.0" 8 | }, 9 | "scripts": { 10 | "start": "npm run build && cross-env NODE_ENV=production pm2 start dist/bot/app.js --name programmer-fa --time", 11 | "restart": "npm run build && cross-env NODE_ENV=production pm2 restart programmer-fa --time", 12 | "stop": "pm2 stop programmer-fa", 13 | "build": "tsc && npm run copy-files", 14 | "dev": "tsc && cross-env NODE_ENV=development nodemon --watch 'src/bot/**/*.ts' --exec 'ts-node' src/bot/app.ts", 15 | "sort": "npm run build && node dist/cli/sort-words.js && git add --all ./src/data/ && git commit -m \"Update words!\"", 16 | "test": "tsc --outDir dist .mocharc.ts && cross-env NODE_ENV=testing DEBUG_MODE=true DB_ENABLE=false mocha --config dist/.mocharc.js --exit -b -r ts-node/register 'test/**/*.ts'", 17 | "copy-files": "mkdir -p dist/data && cp src/data/* dist/data/ && cp .env dist", 18 | "migrate:latest": "npm run build && knex migrate:latest --knexfile dist/knexfile.js --migrations-directory dist/database/migrations", 19 | "migrate:rollback": "npm run build && knex migrate:rollback --knexfile dist/knexfile.js --migrations-directory dist/database/migrations", 20 | "lint": "eslint . --ext .js,.ts", 21 | "prepare": "husky install" 22 | }, 23 | "author": "Amir Hosein Salimi", 24 | "license": "MIT", 25 | "dependencies": { 26 | "color-it": "^1.2.12", 27 | "console-table-printer": "^2.10.0", 28 | "dotenv": "^8.2.0", 29 | "knex": "^2.4.0", 30 | "luxon": "^1.28.1", 31 | "mysql": "^2.18.1", 32 | "needle": "^2.9.1", 33 | "pm2": "^5.2.0", 34 | "twit": "^2.2.11" 35 | }, 36 | "devDependencies": { 37 | "@commitlint/cli": "^17.4.2", 38 | "@commitlint/config-conventional": "^17.4.2", 39 | "@istanbuljs/nyc-config-typescript": "^1.0.1", 40 | "@types/chai": "^4.2.15", 41 | "@types/chai-http": "^4.2.0", 42 | "@types/dotenv": "^8.2.0", 43 | "@types/express": "^4.17.11", 44 | "@types/faker": "^5.5.8", 45 | "@types/luxon": "^1.26.5", 46 | "@types/mocha": "^8.2.1", 47 | "@types/mysql": "^2.15.18", 48 | "@types/needle": "^2.5.3", 49 | "@types/node": "^13.13.46", 50 | "@types/supertest": "^2.0.10", 51 | "@types/twit": "^2.2.28", 52 | "@typescript-eslint/eslint-plugin": "^5.47.0", 53 | "@typescript-eslint/parser": "^5.47.0", 54 | "chai": "^4.3.4", 55 | "chai-http": "^4.3.0", 56 | "cross-env": "^7.0.3", 57 | "eslint": "^8.30.0", 58 | "eslint-config-airbnb": "^19.0.4", 59 | "eslint-config-prettier": "^8.5.0", 60 | "eslint-import-resolver-typescript": "^3.5.2", 61 | "eslint-plugin-chai-friendly": "^0.7.2", 62 | "eslint-plugin-import": "^2.26.0", 63 | "eslint-plugin-json": "^3.1.0", 64 | "eslint-plugin-jsx-a11y": "^6.6.1", 65 | "eslint-plugin-mocha": "^10.1.0", 66 | "eslint-plugin-prettier": "^4.2.1", 67 | "eslint-plugin-react": "^7.31.11", 68 | "express": "^4.17.3", 69 | "faker": "^5.4.0", 70 | "husky": "^8.0.3", 71 | "lint-staged": "^13.1.0", 72 | "mocha": "^10.0.0", 73 | "nodemon": "^2.0.20", 74 | "nyc": "^15.1.0", 75 | "prettier": "^2.8.0", 76 | "source-map-support": "^0.5.20", 77 | "supertest": "^6.1.6", 78 | "ts-node": "^8.10.2", 79 | "typescript": "^4.4.2" 80 | }, 81 | "repository": { 82 | "type": "git", 83 | "url": "https://github.com/amirhoseinsalimi/programmer-fa" 84 | }, 85 | "keywords": [ 86 | "bot", 87 | "twitter" 88 | ] 89 | } 90 | -------------------------------------------------------------------------------- /src/bot/app.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { T, Twit } from '../twit'; 3 | 4 | import { 5 | logError, 6 | logSuccess, 7 | prettyPrintInTable, 8 | printWelcomeBanner, 9 | writeToFile, 10 | } from './logger'; 11 | 12 | import { 13 | t, 14 | store, 15 | hasURL, 16 | retweet, 17 | isRetweet, 18 | favourite, 19 | removeURLs, 20 | isTweetAcceptable, 21 | getTweetFullText, 22 | isDebugModeEnabled, 23 | fillArrayWithWords, 24 | loadJSONFileContent, 25 | isRetweetedByMyself, 26 | removeSuspiciousWords, 27 | removeRetweetNotation, 28 | } from '../utils'; 29 | 30 | class Emitter extends EventEmitter {} 31 | 32 | const emitter: Emitter = new Emitter(); 33 | 34 | emitter.on('bot-error', (err: Error, tweet: any, tweetId: string) => { 35 | logError(t('anErrorHasBeenThrown'), err.message || err); 36 | if (tweetId !== '0') logError(t('errorOnHandlingTweetId', { tweetId })); 37 | if (tweetId !== '0' && tweet) logError(JSON.stringify(tweet)); 38 | }); 39 | 40 | /* Deal w/ uncaught errors and unhandled promises */ 41 | process 42 | .on('uncaughtException', (err: Error) => { 43 | logError(JSON.stringify(err, null, 2)); 44 | logError(`${new Date().toUTCString()} "uncaughtException": ${err.message}`); 45 | logError(err.stack); 46 | process.exit(1); 47 | }) 48 | .on('unhandledRejection', (reason, p: Promise) => { 49 | logError(t('unhandledRejectionAtPromise'), p); 50 | logError(reason); 51 | }); 52 | 53 | printWelcomeBanner(); 54 | 55 | const wordsToFollowDB: string[] | Error = loadJSONFileContent( 56 | `${__dirname}/../data/words-to-follow.json`, 57 | ); 58 | const wordsNotToFollowDB: string[] | Error = loadJSONFileContent( 59 | `${__dirname}/../data/words-not-to-follow.json`, 60 | ); 61 | 62 | if (wordsToFollowDB instanceof Error || wordsNotToFollowDB instanceof Error) { 63 | emitter.emit('bot-error', t('filesCouldNotLoad')); 64 | process.exit(1); 65 | } 66 | 67 | let interestingWords: string[] = []; 68 | 69 | interestingWords = fillArrayWithWords(interestingWords, wordsToFollowDB); 70 | 71 | const params: Twit.Params = { 72 | // track these words 73 | track: interestingWords, 74 | }; 75 | 76 | // eslint-disable-next-line import/prefer-default-export 77 | export const stream: Twit.Stream = T.stream('statuses/filter', params); 78 | 79 | export const onTweet = async (tweet: any): Promise => { 80 | if (!isTweetAcceptable(tweet)) { 81 | return '0'; 82 | } 83 | 84 | tweet.$tweetText = removeRetweetNotation(getTweetFullText(tweet)); 85 | 86 | tweet.$retweetText = ''; 87 | 88 | if (isRetweet(tweet)) { 89 | tweet.$retweetText = removeRetweetNotation( 90 | getTweetFullText(tweet.retweeted_status), 91 | ); 92 | } 93 | 94 | const tweetTextWithoutURLs: string = removeURLs(tweet.$tweetText); 95 | const reTweetTextWithoutURLs: string = removeURLs(tweet.$retweetText); 96 | 97 | const tweetTextWithoutSuspiciousWords: string = removeSuspiciousWords( 98 | tweetTextWithoutURLs, 99 | ); 100 | const retweetTextWithoutSuspiciousWords: string = removeSuspiciousWords( 101 | reTweetTextWithoutURLs, 102 | ); 103 | 104 | const tweetIncludesInterestingWords: boolean = interestingWords.some( 105 | (word: string) => ( 106 | tweetTextWithoutSuspiciousWords.search( 107 | new RegExp(word.toLowerCase()), 108 | ) > -1 109 | ), 110 | ); 111 | 112 | const tweetIncludesBlackListedWords: boolean = wordsNotToFollowDB.some( 113 | (word: string) => ( 114 | tweetTextWithoutSuspiciousWords.search( 115 | new RegExp(word.toLowerCase()), 116 | ) > -1 117 | ), 118 | ); 119 | 120 | const retweetIncludesBlackListedWords: boolean = wordsNotToFollowDB.some( 121 | (blackListedWord: string) => ( 122 | retweetTextWithoutSuspiciousWords.search( 123 | new RegExp(blackListedWord.toLowerCase()), 124 | ) > -1 125 | ), 126 | ); 127 | 128 | const tweetId: string = tweetIncludesInterestingWords 129 | && !tweetIncludesBlackListedWords 130 | && !retweetIncludesBlackListedWords 131 | && !hasURL(tweet) 132 | && !isRetweetedByMyself(tweet) ? tweet.id_str : '0'; 133 | 134 | if (tweetId === '0') { 135 | return '0'; 136 | } 137 | 138 | try { 139 | if (isDebugModeEnabled()) { 140 | await writeToFile(tweet.$tweetText); 141 | prettyPrintInTable(tweet); 142 | } else { 143 | logSuccess((await retweet(tweetId)).message); 144 | logSuccess((await favourite(tweetId)).message); 145 | } 146 | 147 | logSuccess((await store(tweet)).message); 148 | } catch (e) { 149 | emitter.emit('bot-error', e, tweetId, tweet); 150 | } 151 | 152 | return tweetId; 153 | }; 154 | 155 | stream.on('tweet', onTweet); 156 | 157 | stream.on('error', (err: Error) => { 158 | emitter.emit('bot-error', err); 159 | }); 160 | -------------------------------------------------------------------------------- /src/bot/logger.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { printTable } from 'console-table-printer'; 3 | import { t, isDebugModeEnabled } from '../utils'; 4 | 5 | const colorIt = require('color-it'); 6 | 7 | export const logWarning = (...args: string[]): void => { 8 | if (isDebugModeEnabled()) { 9 | const l: number = args.length; 10 | 11 | for (let i = 0; i < l; i += 1) { 12 | console.log(`${colorIt(args[i]).orange()}`); 13 | } 14 | } 15 | }; 16 | 17 | export const logError = (...args: any): void => { 18 | if (isDebugModeEnabled()) { 19 | const l: number = args.length; 20 | 21 | for (let i = 0; i < l; i += 1) { 22 | console.error(`${colorIt(args[i]).red()}`); 23 | } 24 | } 25 | }; 26 | 27 | export const logInfo = (...args: any): void => { 28 | if (isDebugModeEnabled()) { 29 | const l: number = args.length; 30 | 31 | for (let i = 0; i < l; i += 1) { 32 | console.log(`${colorIt(args[i]).belizeHole()}`); 33 | } 34 | } 35 | }; 36 | 37 | export const logSuccess = (...args: any): void => { 38 | if (isDebugModeEnabled()) { 39 | const l: number = args.length; 40 | 41 | for (let i = 0; i < l; i += 1) { 42 | console.log(`${colorIt(args[i]).green()}`); 43 | } 44 | } 45 | }; 46 | 47 | export const writeToFile = async (text: string | Buffer): Promise => { 48 | if (isDebugModeEnabled()) { 49 | fs.access(`${process.cwd()}/logs`, (err) => { 50 | if (err && err.code === 'ENOENT') { 51 | fs.mkdir(`${process.cwd()}/logs`, (e) => { 52 | if (e) { 53 | logError(t('couldNotCreateDir', { dirName: 'logs' })); 54 | } 55 | }); 56 | } else { 57 | const formattedText = ` 58 | \n======================================= 59 | \n${text} 60 | \n======================================= 61 | `; 62 | 63 | const d: Date = new Date(); 64 | const fileName = `${d.getFullYear()}-${d.getMonth() + 65 | 1}-${d.getDate()} - H${d.getHours()}.log`; 66 | 67 | fs.appendFile( 68 | `logs/${fileName}`, 69 | formattedText, 70 | typeof text === 'string' ? 'utf8' : '', 71 | (error) => { 72 | if (error) logError(error); 73 | }, 74 | ); 75 | } 76 | }); 77 | } 78 | }; 79 | 80 | export const prettyPrintInTable = (tweet: any): void => { 81 | if ( 82 | process.env.NODE_ENV !== 'testing' && 83 | process.env.NODE_ENV !== 'staging' 84 | ) { 85 | const t = [ 86 | { 87 | id: tweet.id, 88 | user_id: tweet.user.id, 89 | text: tweet.$tweetText, 90 | }, 91 | ]; 92 | 93 | printTable(t); 94 | } 95 | }; 96 | 97 | export const printWelcomeBanner = (): void => { 98 | fs.readFile(`${process.cwd()}/.banner`, 'utf8', (err, banner: string) => { 99 | if (err) { 100 | logError(err); 101 | return; 102 | } 103 | 104 | logInfo(banner); 105 | 106 | logSuccess(t('botHasBeenStarted')); 107 | 108 | if (isDebugModeEnabled()) { 109 | logInfo(t('developmentEnvNotice')); 110 | } 111 | }); 112 | }; 113 | -------------------------------------------------------------------------------- /src/cli/sort-words.ts: -------------------------------------------------------------------------------- 1 | import { readdir, readFile, writeFile } from 'fs'; 2 | import { promisify } from 'util'; 3 | import * as path from 'path'; 4 | import { asyncForEach } from '../utils'; 5 | 6 | path.resolve('./'); 7 | 8 | const readdirPromisified = promisify(readdir); 9 | const readFilePromisified = promisify(readFile); 10 | const writeFilePromisified = promisify(writeFile); 11 | 12 | (async (): Promise => { 13 | const WORDS_PATH = './src/data/'; 14 | 15 | try { 16 | const files = await readdirPromisified(WORDS_PATH); 17 | 18 | await asyncForEach(files, async (file: string) => { 19 | const currentFile = `${WORDS_PATH}/${file}`; 20 | 21 | const currentFileContent = await readFilePromisified(currentFile); 22 | 23 | const currentFileContentArray: string[] = JSON.parse( 24 | currentFileContent.toString(), 25 | ); 26 | 27 | const currentFileContentUnique = [...new Set(currentFileContentArray)]; 28 | 29 | const currentFileContentLowerCase = currentFileContentUnique.map( 30 | (v: string) => v.toLowerCase(), 31 | ); 32 | 33 | currentFileContentLowerCase.sort(); 34 | 35 | await writeFilePromisified( 36 | currentFile, 37 | JSON.stringify(currentFileContentLowerCase, null, 2), 38 | ); 39 | }); 40 | } catch (e) { 41 | console.log(e); 42 | process.exit(1); 43 | } 44 | })(); 45 | -------------------------------------------------------------------------------- /src/data/accounts-not-to-follow.json: -------------------------------------------------------------------------------- 1 | [ 2 | "1001446850342346753", 3 | "1140548684817534976", 4 | "1145851871665541122", 5 | "1151749004352507904", 6 | "1175094341444018176", 7 | "1177127248119422976", 8 | "1178799044484878338", 9 | "1180187868444139523", 10 | "1180471496281673728", 11 | "1194335350425931776", 12 | "1219183392945770496", 13 | "1238519476389363716", 14 | "1244061777366863872", 15 | "1245630302166249474", 16 | "1248093960536338435", 17 | "1254895401980833801", 18 | "1255904987097509889", 19 | "1256610291414155265", 20 | "1283765590289780743", 21 | "1308400132363255810", 22 | "1308658356161318918", 23 | "1333015716908429313", 24 | "1336978629977366528", 25 | "1355580438174179332", 26 | "1364315590446489602", 27 | "1425140576865955845", 28 | "2555978102", 29 | "2667172855", 30 | "2783431375", 31 | "310871673", 32 | "32442100", 33 | "3307469111", 34 | "786489780091498496", 35 | "787555147778064384", 36 | "849551683667591168", 37 | "856691635", 38 | "911314294096171008", 39 | "95501767", 40 | "960097155603103744", 41 | "977815143420743680" 42 | ] -------------------------------------------------------------------------------- /src/data/words-not-to-follow.json: -------------------------------------------------------------------------------- 1 | [ 2 | ".aspx", 3 | ".html", 4 | ".php", 5 | "bot off", 6 | "bts", 7 | "exo", 8 | "k-pop", 9 | "kpop", 10 | "no bot", 11 | "no retweet", 12 | "pgtweet", 13 | "programmer fa off", 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 | ] -------------------------------------------------------------------------------- /src/data/words-to-follow.json: -------------------------------------------------------------------------------- 1 | [ 2 | "adonis.js", 3 | "adonisjs", 4 | "angular", 5 | "angular js", 6 | "angular.js", 7 | "angularjs", 8 | "asp.net", 9 | "back end", 10 | "back-end", 11 | "backend", 12 | "bootstrap", 13 | "coroutine", 14 | "coroutines", 15 | "css", 16 | "delphi", 17 | "deno", 18 | "deno.js", 19 | "denojs", 20 | "developer", 21 | "devops", 22 | "django", 23 | "docker", 24 | "dotnet", 25 | "elastic", 26 | "elastcsearch", 27 | "elm", 28 | "erlang", 29 | "express", 30 | "express js", 31 | "express.js", 32 | "expressjs", 33 | "flask", 34 | "flutter", 35 | "front end", 36 | "front-end", 37 | "frontend", 38 | "git", 39 | "github", 40 | "gitlab", 41 | "go", 42 | "go lang", 43 | "golang", 44 | "html", 45 | "ionic", 46 | "java", 47 | "javascript", 48 | "jquery", 49 | "js", 50 | "k3s", 51 | "k8s", 52 | "kotlin", 53 | "kubernetes", 54 | "laravel", 55 | "linux", 56 | "livewire", 57 | "mocha", 58 | "mongo", 59 | "mongodb", 60 | "mssql", 61 | "mysql", 62 | "nest.js", 63 | "nestjs", 64 | "next js", 65 | "next.js", 66 | "neo4j", 67 | "node", 68 | "node js", 69 | "node.js", 70 | "nodejs", 71 | "nuxt js", 72 | "nuxt.js", 73 | "objective c", 74 | "open source", 75 | "php", 76 | "programmer", 77 | "programming", 78 | "postgresql", 79 | "postgres", 80 | "python", 81 | "rails", 82 | "react", 83 | "react js", 84 | "react native", 85 | "react.js", 86 | "react.native", 87 | "reactjs", 88 | "redis", 89 | "ruby", 90 | "rust", 91 | "sass", 92 | "stack overflow", 93 | "stackoverflow", 94 | "stencil", 95 | "svelte", 96 | "svelte.js", 97 | "sveltejs", 98 | "swagger", 99 | "swift", 100 | "sql", 101 | "sql server", 102 | "sre", 103 | "symfony", 104 | "sys admin", 105 | "sysadmin", 106 | "ubuntu", 107 | "ui", 108 | "vue", 109 | "vue js", 110 | "vue.js", 111 | "vuejs", 112 | "web", 113 | "آیونیک", 114 | "ارلنگ", 115 | "اسولت", 116 | "انگولار", 117 | "انگولار جی اس", 118 | "اوبونتو", 119 | "اوپن سورس", 120 | "اپن سورس", 121 | "اکسپرس", 122 | "اکسپرس جی اس", 123 | "برنامه نویس", 124 | "برنامه‌نویس", 125 | "برنامه‌نویس‌ها", 126 | "برنامه‌نویس‌های", 127 | "برنامه نویسی", 128 | "برنامه‌نویسی", 129 | "بوت استرپ", 130 | "بوت‌استرپ", 131 | "بک اند", 132 | "بک‌اند", 133 | "پروگرمینگ", 134 | "جاوا", 135 | "جاوا اسکریپت", 136 | "جاوااسکریپت", 137 | "جاوا‌اسکریپت", 138 | "جی اس", 139 | "جی کوئری", 140 | "جی‌اس", 141 | "جی‌کوئری", 142 | "دات نت", 143 | "دات‌نت", 144 | "دلفی", 145 | "دواپس", 146 | "دولوپر", 147 | "دیتابیس", 148 | "دیزاین پترن", 149 | "دیزاینر", 150 | "دیزاین‌پترن", 151 | "ردیس", 152 | "روبی", 153 | "ری اکت", 154 | "ری اکت نیتیو", 155 | "ری‌اکت", 156 | "ری‌اکت نیتیو", 157 | "سوئیفت", 158 | "سوییفت", 159 | "سی شارپ", 160 | "سی پلاس پلاس", 161 | "سیمفونی", 162 | "سیس ادمین", 163 | "سی‌شارپ", 164 | "سی‌پلاس‌پلاس", 165 | "فانکشنال پروگرمینگ", 166 | "فرانت", 167 | "فرانت اند", 168 | "فرانت‌اند", 169 | "فلاتر", 170 | "لاراول", 171 | "لینوکس", 172 | "مونگو", 173 | "ناکست", 174 | "نود جی اس", 175 | "وب", 176 | "ویو جی اس", 177 | "پایتون", 178 | "پروگرمر", 179 | "پی اچ پی", 180 | "پی‌اچ‌پی", 181 | "کاتلین", 182 | "کوبرنتیز", 183 | "کوروتین", 184 | "کوروتین ها", 185 | "گولنگ", 186 | "گیتلب", 187 | "گیتهاب", 188 | "گیت‌لب", 189 | "گیت‌هاب" 190 | ] 191 | -------------------------------------------------------------------------------- /src/data/words-with-suspicion.json: -------------------------------------------------------------------------------- 1 | [ 2 | "css", 3 | "django", 4 | "express", 5 | "go", 6 | "go lang", 7 | "html", 8 | "linux", 9 | "node", 10 | "python", 11 | "react", 12 | "swift", 13 | "ui", 14 | "web", 15 | "اسولت", 16 | "اکسپرس", 17 | "جی اس", 18 | "جی‌اس", 19 | "دات نت", 20 | "دات‌نت", 21 | "دیتابیس", 22 | "دیزاینر", 23 | "روبی", 24 | "ری اکت", 25 | "ری‌اکت", 26 | "سوئیفت", 27 | "سوییفت", 28 | "فرانت", 29 | "لینوکس", 30 | "ناکست", 31 | "وب", 32 | "پایتون" 33 | ] -------------------------------------------------------------------------------- /src/database/migrations/20200409211407_create_table_users.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import * as Knex from 'knex'; 4 | import { CreateTableBuilder } from 'knex'; 5 | 6 | const TABLE_NAME = 'users'; 7 | 8 | export async function up({ schema }: Knex): Promise { 9 | const tableExists = await schema.hasTable(TABLE_NAME); 10 | 11 | if (tableExists) { 12 | return; 13 | } 14 | 15 | return schema.createTable(TABLE_NAME, (table: CreateTableBuilder) => { 16 | table.increments().primary(); 17 | table.string('user_id').notNullable().unique(); 18 | table.string('name').notNullable(); 19 | table.string('screen_name').notNullable(); 20 | table.timestamps(true, true); 21 | }); 22 | } 23 | 24 | export async function down({ schema }: Knex): Promise { 25 | const tableExists = await schema.hasTable(TABLE_NAME); 26 | 27 | if (!tableExists) { 28 | return; 29 | } 30 | 31 | return schema.dropTableIfExists(TABLE_NAME); 32 | } 33 | 34 | exports.config = { transaction: false }; 35 | -------------------------------------------------------------------------------- /src/database/migrations/20200410163135_create_table_tweets.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import * as Knex from 'knex'; 4 | import { CreateTableBuilder } from 'knex'; 5 | 6 | const TABLE_NAME = 'tweets'; 7 | 8 | export async function up({ schema }: Knex): Promise { 9 | const tableExists = await schema.hasTable(TABLE_NAME); 10 | 11 | if (tableExists) { 12 | return; 13 | } 14 | 15 | return schema.createTable(TABLE_NAME, (table: CreateTableBuilder) => { 16 | table.increments().primary(); 17 | table.string('tweet_id', 50).notNullable().unique(); 18 | table.text('text').notNullable(); 19 | table.string('source').nullable(); 20 | table.boolean('is_retweet').defaultTo(false); 21 | table.boolean('in_reply_to_status_id').nullable(); 22 | table.boolean('in_reply_to_user_id').nullable(); 23 | 24 | table.string('user_id'); 25 | table.timestamps(true, true); 26 | }); 27 | } 28 | 29 | export async function down({ schema }: Knex): Promise { 30 | const tableExists = await schema.hasTable(TABLE_NAME); 31 | 32 | if (!tableExists) { 33 | return; 34 | } 35 | 36 | return schema.dropTableIfExists(TABLE_NAME); 37 | } 38 | 39 | exports.config = { transaction: false }; 40 | -------------------------------------------------------------------------------- /src/database/migrations/20200410173130_create_table_medias.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import * as Knex from 'knex'; 4 | import { CreateTableBuilder } from 'knex'; 5 | 6 | const TABLE_NAME = 'medias'; 7 | 8 | export async function up({ schema }: Knex): Promise { 9 | const tableExists = await schema.hasTable(TABLE_NAME); 10 | 11 | if (tableExists) { 12 | return; 13 | } 14 | 15 | return schema.createTable(TABLE_NAME, (table: CreateTableBuilder) => { 16 | table.increments().primary(); 17 | table.string('media_url').notNullable(); 18 | 19 | table.integer('tweet_id').notNullable(); 20 | table.timestamps(true, true); 21 | }); 22 | } 23 | 24 | export async function down({ schema }: Knex): Promise { 25 | const tableExists = await schema.hasTable(TABLE_NAME); 26 | 27 | if (!tableExists) { 28 | return; 29 | } 30 | 31 | return schema.dropTableIfExists(TABLE_NAME); 32 | } 33 | 34 | exports.config = { transaction: false }; 35 | -------------------------------------------------------------------------------- /src/database/migrations/20200512190140_create_table_words.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import * as Knex from 'knex'; 4 | import { CreateTableBuilder } from 'knex'; 5 | 6 | const TABLE_NAME = 'words'; 7 | 8 | export async function up({ schema }: Knex): Promise { 9 | const tableExists = await schema.hasTable(TABLE_NAME); 10 | 11 | if (tableExists) { 12 | return; 13 | } 14 | 15 | return schema.createTable(TABLE_NAME, (table: CreateTableBuilder) => { 16 | table.increments().primary(); 17 | table.integer('word').notNullable().unique(); 18 | 19 | table.timestamps(true, true); 20 | }); 21 | } 22 | 23 | export async function down({ schema }: Knex): Promise { 24 | const tableExists = await schema.hasTable(TABLE_NAME); 25 | 26 | if (!tableExists) { 27 | return; 28 | } 29 | 30 | return schema.dropTableIfExists(TABLE_NAME); 31 | } 32 | 33 | exports.config = { transaction: false }; 34 | -------------------------------------------------------------------------------- /src/database/migrations/20201229094527_fix_database_collation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import * as Knex from 'knex'; 4 | 5 | const TABLE_NAME = 'users'; 6 | 7 | export async function up({ schema }: Knex): Promise { 8 | const tableExists = await schema.hasTable(TABLE_NAME); 9 | 10 | if (!tableExists) { 11 | return; 12 | } 13 | 14 | return schema.raw( 15 | 'ALTER DATABASE `programmer_fa` CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;', 16 | ); 17 | } 18 | 19 | export async function down({ schema }: Knex): Promise { 20 | const tableExists = await schema.hasTable(TABLE_NAME); 21 | 22 | if (!tableExists) { 23 | return; 24 | } 25 | 26 | return schema.raw( 27 | 'ALTER DATABASE `programmer_fa` CHARACTER SET = utf8 COLLATE = utf8_unicode_ci;', 28 | ); 29 | } 30 | 31 | exports.config = { transaction: false }; 32 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | const result = dotenv.config(); 4 | 5 | if (result.error) { 6 | throw result.error; 7 | } 8 | 9 | const { parsed: envs } = result; 10 | 11 | export default envs; 12 | -------------------------------------------------------------------------------- /src/knex-export.ts: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | 3 | import envs from './env'; 4 | 5 | const environment = envs.NODE_ENV || 'development'; 6 | const config = require('./knexfile')[environment]; 7 | 8 | export default Knex(config); 9 | -------------------------------------------------------------------------------- /src/knexfile.ts: -------------------------------------------------------------------------------- 1 | /* Knex.js configuration 2 | See http://knexjs.org/ for documents 3 | * */ 4 | import envs from './env'; 5 | 6 | module.exports = { 7 | development: { 8 | client: envs.DB_DRIVER || 'mysql', 9 | connection: { 10 | database: envs.DB_NAME, 11 | user: envs.DB_USERNAME, 12 | password: envs.DB_PASSWORD, 13 | }, 14 | pool: { 15 | min: 2, 16 | max: 10, 17 | }, 18 | migrations: { 19 | tableName: 'knex_migrations', 20 | directory: `${__dirname}/database/migrations`, 21 | }, 22 | seeds: { 23 | directory: `${__dirname}/database/seeds`, 24 | }, 25 | }, 26 | 27 | production: { 28 | client: envs.DB_DRIVER || 'mysql', 29 | connection: { 30 | database: envs.DB_NAME, 31 | user: envs.DB_USERNAME, 32 | password: envs.DB_PASSWORD, 33 | }, 34 | pool: { 35 | min: 2, 36 | max: 10, 37 | }, 38 | migrations: { 39 | tableName: 'knex_migrations', 40 | directory: `${__dirname}/database/migrations`, 41 | }, 42 | seeds: { 43 | directory: `${__dirname}/database/seeds`, 44 | }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /src/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": "Programmer Farsi", 3 | "unhandledRejectionAtPromise": "Unhandled rejection at Promise", 4 | "anErrorHasBeenThrown": "An error has been thrown", 5 | "errorOnHandlingTweetId": "Error on handling tweet with ID {{tweetId}}", 6 | "couldNotCreateDir": "Couldn't create {{dirName}} dir. Exiting...", 7 | "developmentEnvNotice": "The bot has been started in development environment, so it does retweet tweets, but instead stores them in the database and logs the text of the tweets in a file. To change this behavior set `NODE_ENV=production` in the .env file", 8 | "botHasBeenStarted": "Bot has been started...", 9 | "filesCouldNotLoad": "Files couldn't load", 10 | "fileDoesNotIncludeAnArray": "File doesn't include an array", 11 | "databaseStorageIsDisabled": "Database storage is disabled", 12 | "tweetStoredInTheDatabase": "Tweet stored in the database", 13 | "tweetIsAlreadyInTheDatabase": "Tweet is already in the database" 14 | } 15 | -------------------------------------------------------------------------------- /src/twit.ts: -------------------------------------------------------------------------------- 1 | import * as Twit from 'twit'; 2 | 3 | import envs from './env'; 4 | 5 | const T: Twit = new Twit({ 6 | consumer_key: envs.CONSUMER_KEY, 7 | consumer_secret: envs.CONSUMER_SECRET, 8 | access_token: envs.ACCESS_TOKEN, 9 | access_token_secret: envs.ACCESS_TOKEN_SECRET, 10 | timeout_ms: 60 * 1000, 11 | strictSSL: envs.STRICT_SSL !== 'false', 12 | }); 13 | 14 | export { T, Twit }; 15 | -------------------------------------------------------------------------------- /src/types/general.d.ts: -------------------------------------------------------------------------------- 1 | import en from '../translations/en.json'; 2 | 3 | export type I18nKeys = keyof typeof en; 4 | 5 | export interface Message { 6 | message: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import envs from '../env'; 2 | import { DateTime, Duration } from 'luxon'; 3 | 4 | export const getDiffBetweenDateTimeAndNowInDays = (date: DateTime): Duration => DateTime.now().diff(date, 'days'); 5 | 6 | export const parseTwitterDateToLuxon = (date: string): DateTime => DateTime.fromFormat(date, 'ccc LLL dd HH:mm:ss ZZZ yyyy'); 7 | 8 | export const hasUserRegisteredRecently = (tweet: any): boolean => { 9 | const originalUser: any = tweet.user; 10 | const retweeterUser: any = tweet.retweeted_status; 11 | 12 | const originalUserRegisterDate: DateTime = parseTwitterDateToLuxon( 13 | originalUser.created_at, 14 | ); 15 | 16 | let retweeterUserRegisterDateDiff: number; 17 | 18 | const dayToBlockNewUsers: number = +envs.IGNORE_USERS_NEWER_THAN; 19 | 20 | const originalUserRegisterDateDiff = getDiffBetweenDateTimeAndNowInDays( 21 | originalUserRegisterDate, 22 | ).days; 23 | 24 | if (retweeterUser) { 25 | const retweeterUserRegisterDate: DateTime = parseTwitterDateToLuxon( 26 | tweet.retweeted_status.user.created_at, 27 | ); 28 | 29 | retweeterUserRegisterDateDiff = getDiffBetweenDateTimeAndNowInDays( 30 | retweeterUserRegisterDate, 31 | ).days; 32 | } 33 | 34 | return ( 35 | dayToBlockNewUsers > originalUserRegisterDateDiff 36 | || dayToBlockNewUsers > retweeterUserRegisterDateDiff 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import type { I18nKeys } from '../types/general'; 2 | import * as en from '../translations/en.json'; 3 | 4 | const templateMatcher = /{{\s?([^{}\s]*)\s?}}/g; 5 | 6 | export const t = (key: I18nKeys, args?: Record) => { 7 | return en[key].replace(templateMatcher, (substring, value) => { 8 | value = args[value]; 9 | return value; 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './i18n'; 2 | export * from './misc'; 3 | export * from './date'; 4 | export * from './tweet'; 5 | export * from './string'; 6 | -------------------------------------------------------------------------------- /src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import envs from '../env'; 3 | import knex from '../knex-export'; 4 | import { makeHashtag } from './string'; 5 | import { isRetweet } from './tweet'; 6 | import { t } from './i18n'; 7 | import { Message } from '../types/general'; 8 | 9 | const blackListedAccounts: string[] = require('../data/accounts-not-to-follow.json'); 10 | 11 | export const fillArrayWithWords = ( 12 | arrayToFill: string[], 13 | arrayOfWords: string[], 14 | ): string[] => { 15 | arrayOfWords.forEach((word: string) => arrayToFill.push(word)); 16 | 17 | arrayOfWords.forEach((word: string) => { 18 | const w = makeHashtag(word); 19 | 20 | arrayToFill.push(w); 21 | }); 22 | 23 | return [...new Set(arrayToFill)]; 24 | }; 25 | 26 | export const isFileJSON = (fileName: string): boolean => 27 | /\.(json)$/i.test(fileName); 28 | 29 | export const loadJSONFileContent = (filePath: string): string[] | Error => { 30 | let fileContent: string; 31 | 32 | if (!isFileJSON(filePath)) { 33 | return new Error('File is not JSON'); 34 | } 35 | 36 | try { 37 | fileContent = readFileSync(filePath, 'utf8'); 38 | } catch (e) { 39 | return new Error(e); 40 | } 41 | 42 | fileContent = JSON.parse(fileContent); 43 | 44 | return Array.isArray(fileContent) 45 | ? fileContent 46 | : new Error(t('fileDoesNotIncludeAnArray')); 47 | }; 48 | 49 | export const isBlackListed = (tweet: any): boolean => { 50 | const originalUserId: string = tweet.user.id_str; 51 | const retweeterUserId: string = tweet.retweet_status?.user?.id_str; 52 | 53 | return ( 54 | blackListedAccounts.includes(retweeterUserId) || 55 | blackListedAccounts.includes(originalUserId) 56 | ); 57 | }; 58 | 59 | // TODO: Split this into multiple functions 60 | export const store = async (tweet: any): Promise => { 61 | if (envs.DB_ENABLE === 'false') { 62 | return { 63 | message: t('databaseStorageIsDisabled'), 64 | }; 65 | } 66 | const { 67 | in_reply_to_status_id, 68 | in_reply_to_user_id, 69 | source, 70 | user, 71 | id_str, 72 | $tweetText, 73 | } = tweet; 74 | 75 | const { id_str: userIdStr, screen_name, name } = user; 76 | 77 | try { 78 | const userId = await knex 79 | .select('user_id') 80 | .from('users') 81 | .where('user_id', userIdStr); 82 | 83 | if (userId.length) { 84 | await knex('users').where('user_id', userIdStr).update({ 85 | user_id: userIdStr, 86 | screen_name, 87 | name, 88 | }); 89 | } else { 90 | await knex('users').insert({ 91 | user_id: userIdStr, 92 | screen_name, 93 | name, 94 | }); 95 | } 96 | } catch (e) { 97 | return new Error(e); 98 | } 99 | 100 | try { 101 | const tweetId = await knex 102 | .select('tweet_id') 103 | .from('tweets') 104 | .where('tweet_id', id_str); 105 | 106 | if (!tweetId.length) { 107 | await knex('tweets').insert({ 108 | tweet_id: id_str, 109 | text: $tweetText, 110 | source, 111 | is_retweet: isRetweet(tweet), 112 | in_reply_to_status_id, 113 | in_reply_to_user_id, 114 | user_id: user.id_str, 115 | }); 116 | 117 | return { message: t('tweetStoredInTheDatabase') }; 118 | } 119 | 120 | return { message: t('tweetIsAlreadyInTheDatabase') }; 121 | } catch (e) { 122 | return new Error(e); 123 | } 124 | }; 125 | 126 | export const isDebugModeEnabled = (): boolean => envs.DEBUG_MODE === 'true'; 127 | 128 | export const asyncForEach = async ( 129 | array: T[], 130 | callback: (value: T, index: number, arrayItself: T[]) => void, 131 | ): Promise => { 132 | for (let index = 0; index < array.length; index += 1) { 133 | // eslint-disable-next-line 134 | await callback(array[index], index, array); 135 | } 136 | }; 137 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | import { isTweetExtended } from './tweet'; 2 | 3 | const suspiciousWords: string[] = require('../data/words-with-suspicion.json'); 4 | 5 | export const makeHashtag = (string: string): string => { 6 | let s: string; 7 | 8 | s = string.replace(/[ ‌\-.]/gim, '_'); 9 | 10 | s = s.replace(/_{2,}/, '_'); 11 | 12 | s = `#${s}`; 13 | 14 | return s; 15 | }; 16 | 17 | export const getTweetLength = (tweetText: string): number => tweetText.length; 18 | 19 | export const getTweetFullText = (tweet: any): string => 20 | // All tweets have a `text` property, but the ones having 140 or more 21 | // characters have `extended_tweet` property set to `true` and an extra 22 | // `extended_tweet` property containing the actual tweet's text under 23 | // `full_text`. For tweets which are not truncated the former `text` is 24 | // enough. 25 | (isTweetExtended(tweet) ? tweet.extended_tweet.full_text : tweet.text); 26 | 27 | export const removeURLs = (text: string): string => { 28 | const urlRegex = /((http(s?)?):\/\/)?([wW]{3}\.)?[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,}(\.[a-zA-Z]{2,})?/gim; 29 | let numberOfURLs = (text.match(urlRegex) || []).length; 30 | 31 | let lText: string = text.toLowerCase(); 32 | 33 | while (numberOfURLs) { 34 | lText = lText.replace(urlRegex, ''); 35 | numberOfURLs -= 1; 36 | } 37 | 38 | return lText.replace(/ +/g, ' ').trim(); 39 | }; 40 | 41 | export const hasURL = (tweet: any): boolean => { 42 | const urlRegex = /((http(s?)?):\/\/)?([wW]{3}\.)?[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,}(\.[a-zA-Z]{2,})?/gim; 43 | 44 | return urlRegex.test(tweet.$tweetText) || tweet.entities?.urls?.length > 0; 45 | }; 46 | 47 | export const getNumberOfHashtags = (str: string): number => { 48 | if (str.length === 0) { 49 | return 0; 50 | } 51 | 52 | const matches: RegExpMatchArray = str.match(/#\S/gim); 53 | 54 | return matches && matches.length ? matches.length : 0; 55 | }; 56 | 57 | export const removeRetweetNotation = (tweetText: string): string => 58 | tweetText.replace(/(RT @.*?:)/gim, '').trim(); 59 | 60 | export const hasFiveHashtagsOrMore = (tweet: any): boolean => 61 | getNumberOfHashtags(getTweetFullText(tweet)) >= 5 || 62 | tweet.entities.hashtags.length >= 5; 63 | 64 | export const hasSuspiciousURLs = (tweet: any): boolean => { 65 | const fileExtensionRegExp = /(\.apsx|\.php|\.html)/; 66 | 67 | return ( 68 | fileExtensionRegExp.test(tweet.$tweetText) || 69 | fileExtensionRegExp.test(tweet.$retweetText) || 70 | tweet.entities?.urls?.some((urlEntity: string) => 71 | fileExtensionRegExp.test(urlEntity), 72 | ) 73 | ); 74 | }; 75 | 76 | export const removeSuspiciousWords = (text: string): string => { 77 | let lText: string = text.toLowerCase(); 78 | 79 | suspiciousWords.forEach((word: string) => { 80 | const lWord: string = word.toLowerCase(); 81 | 82 | if (text.search(new RegExp(lWord)) > -1) { 83 | lText = lText.replace(new RegExp(lWord, 'g'), ''); 84 | } 85 | }); 86 | 87 | return lText.replace(/ +/g, ' '); 88 | }; 89 | -------------------------------------------------------------------------------- /src/utils/tweet.ts: -------------------------------------------------------------------------------- 1 | import { T } from '../twit'; 2 | import { getTweetLength, hasFiveHashtagsOrMore } from './string'; 3 | import { hasUserRegisteredRecently } from './date'; 4 | import { isBlackListed } from './misc'; 5 | import { Message } from '../types/general'; 6 | 7 | export const isRetweet = (tweet: any): boolean => 8 | Object.prototype.hasOwnProperty.call(tweet, 'retweeted_status'); 9 | 10 | export const isRetweetedByMyself = (tweet: any): boolean => tweet.retweeted; 11 | 12 | export const isTweetAReply = (tweet: any): boolean => 13 | // Polyfill to check whether a tweet is a reply or not 14 | tweet.in_reply_to_status_id || tweet.in_reply_to_user_id || isRetweet(tweet) 15 | ? tweet.retweeted_status || tweet.in_reply_to_status_id 16 | : false; 17 | 18 | export const isTweetFarsi = (tweet: any): boolean => tweet.lang === 'fa'; 19 | 20 | export const isTweetAcceptable = (tweet: any): boolean => { 21 | if (!isTweetFarsi(tweet)) { 22 | return false; 23 | } 24 | 25 | if (isTweetAReply(tweet)) { 26 | return false; 27 | } 28 | 29 | if (hasFiveHashtagsOrMore(tweet)) { 30 | return false; 31 | } 32 | 33 | if (isBlackListed(tweet)) { 34 | return false; 35 | } 36 | 37 | if (getTweetLength(tweet.text) <= 10) { 38 | return false; 39 | } 40 | 41 | if (hasUserRegisteredRecently(tweet)) { 42 | return false; 43 | } 44 | 45 | return true; 46 | }; 47 | 48 | export const favourite = async (id: string): Promise => { 49 | let response: Message | Error; 50 | 51 | try { 52 | T.post('/favorites/create', { id }, (err: Error) => { 53 | if (err) { 54 | throw err; 55 | } 56 | 57 | response = { message: 'Tweet favourited successfully' }; 58 | }); 59 | } catch (e) { 60 | response = e; 61 | } 62 | 63 | return response; 64 | }; 65 | 66 | export const isTweetExtended = (tweet: any): boolean => 67 | tweet.truncated === true; 68 | 69 | export const retweet = async (id: string): Promise => { 70 | let response: Message | Error; 71 | 72 | try { 73 | T.post('statuses/retweet/:id', { id }, (err: Error) => { 74 | if (err) { 75 | throw err; 76 | } 77 | 78 | response = { message: 'Tweet retweeted successfully' }; 79 | }); 80 | } catch (e) { 81 | response = e; 82 | } 83 | 84 | return response; 85 | }; 86 | 87 | export const getTweetHashtags = (tweet: any): string[] => 88 | tweet.entities.hashtags; 89 | -------------------------------------------------------------------------------- /test/0 - express-server/routes.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as fs from 'fs'; 3 | 4 | const router = express.Router(); 5 | 6 | const tweets = { 7 | tweets: fs.readFileSync(`${__dirname}/tweets.json`, 'utf8'), 8 | }; 9 | 10 | router.get('/tweets', async (req, res) => { 11 | res.json(tweets); 12 | }); 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /test/0 - express-server/server.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import routes from './routes'; 3 | 4 | // eslint-disable-next-line 5 | export const createServer = () => { 6 | const app = express(); 7 | 8 | app.use('/', routes); 9 | 10 | return app; 11 | }; 12 | -------------------------------------------------------------------------------- /test/1 - unit/date-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { parseTwitterDateToLuxon } from '../../src/utils'; 3 | 4 | const chai = require('chai'); 5 | 6 | const { expect } = chai; 7 | 8 | describe('Date Utils', () => { 9 | it('should return a `DateTime` object from a twitter data string', () => { 10 | const testCase = 'Wed Dec 23 13:28:54 +0000 2020'; 11 | 12 | expect(parseTwitterDateToLuxon(testCase).isValid).to.equal(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/1 - unit/loadFiles.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { loadJSONFileContent } from '../../src/utils'; 3 | 4 | const chai = require('chai'); 5 | 6 | const { expect } = chai; 7 | 8 | describe('Load files', () => { 9 | const BASE_DATA_DIR = `${__dirname}/../../src/data`; 10 | const WORDS_TO_FOLLOW_FILE_PATH = 'words-to-follow.json'; 11 | const WORDS_NOT_TO_FOLLOW_FILE_PATH = 'words-not-to-follow.json'; 12 | const WORDS_WITH_SUSPICION_FILE_PATH = 'words-with-suspicion.json'; 13 | const ACCOUNTS_NOT_TO_FOLLOW_FILE_PATH = 'accounts-not-to-follow.json'; 14 | 15 | let wordsToFollow: string[] | Error; 16 | let wordsNotToFollow: string[] | Error; 17 | let suspiciousWords: string[] | Error; 18 | let blackListedAccountIDs: string[] | Error; 19 | 20 | it(`should load ${WORDS_TO_FOLLOW_FILE_PATH} content and it should contain array of strings`, (done) => { 21 | wordsToFollow = loadJSONFileContent( 22 | `${BASE_DATA_DIR}/${WORDS_TO_FOLLOW_FILE_PATH}`, 23 | ); 24 | 25 | if (wordsToFollow instanceof Error) { 26 | done(wordsToFollow); 27 | } else { 28 | expect(wordsToFollow) 29 | .to.be.an('array') 30 | .that.does.include('node js'); 31 | 32 | done(); 33 | } 34 | }); 35 | 36 | it(`should load ${WORDS_NOT_TO_FOLLOW_FILE_PATH} content and it should contain an array of strings`, (done) => { 37 | wordsNotToFollow = loadJSONFileContent( 38 | `${BASE_DATA_DIR}/${WORDS_NOT_TO_FOLLOW_FILE_PATH}`, 39 | ); 40 | 41 | if (wordsNotToFollow instanceof Error) { 42 | done(wordsNotToFollow); 43 | } else { 44 | expect(wordsNotToFollow) 45 | .to.be.an('array') 46 | .that.does.include('استوری'); 47 | 48 | done(); 49 | } 50 | }); 51 | 52 | it(`should load ${WORDS_WITH_SUSPICION_FILE_PATH} content and it should contain an array of strings`, (done) => { 53 | suspiciousWords = loadJSONFileContent( 54 | `${BASE_DATA_DIR}/${WORDS_WITH_SUSPICION_FILE_PATH}`, 55 | ); 56 | 57 | if (suspiciousWords instanceof Error) { 58 | done(suspiciousWords); 59 | } else { 60 | expect(suspiciousWords) 61 | .to.be.an('array') 62 | .that.does.include('django'); 63 | 64 | done(); 65 | } 66 | }); 67 | 68 | it(`should load ${ACCOUNTS_NOT_TO_FOLLOW_FILE_PATH} content and it should contain an array of numbers`, (done) => { 69 | blackListedAccountIDs = loadJSONFileContent( 70 | `${BASE_DATA_DIR}/${ACCOUNTS_NOT_TO_FOLLOW_FILE_PATH}`, 71 | ); 72 | 73 | if (blackListedAccountIDs instanceof Error) { 74 | done(blackListedAccountIDs); 75 | } else { 76 | const allValuesAreNumbers = blackListedAccountIDs.every( 77 | (userId: string) => typeof +userId === 'number', 78 | ); 79 | 80 | expect(blackListedAccountIDs).to.be.an('array'); 81 | expect(allValuesAreNumbers).to.be.true; 82 | 83 | done(); 84 | } 85 | }); 86 | 87 | it('should verify that suspicious words are included in words to follow', (done) => { 88 | expect(wordsToFollow).to.include.members(suspiciousWords); 89 | 90 | done(); 91 | }); 92 | 93 | it('should verify that suspicious words are not included in words not to follow', (done) => { 94 | expect(wordsNotToFollow).to.not.include.members(suspiciousWords); 95 | 96 | done(); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/1 - unit/string-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { 3 | removeURLs, 4 | makeHashtag, 5 | getTweetLength, 6 | getNumberOfHashtags, 7 | removeSuspiciousWords, 8 | parseTwitterDateToLuxon, 9 | } from '../../src/utils'; 10 | 11 | const chai = require('chai'); 12 | 13 | const { expect } = chai; 14 | 15 | describe('String Utils', () => { 16 | it('should properly count the characters of a tweet', (done) => { 17 | const testCase = 'سلام این یک متن جاوا اسکریپتی می‌باشد. و این کلمه هم دارای خط-تیره است'; 18 | 19 | expect(getTweetLength(testCase)).to.equal(70); 20 | 21 | done(); 22 | }); 23 | 24 | it('should convert a string to hashtag', (done) => { 25 | const testCase = 'سلام این یک متن جاوا اسکریپتی می‌باشد. و این کلمه هم دارای خط-تیره است'; 26 | 27 | expect(makeHashtag(testCase)).to.equal('#سلام_این_یک_متن_جاوا_اسکریپتی_می_باشد_و_این_کلمه_هم_دارای_خط_تیره_است'); 28 | 29 | done(); 30 | }); 31 | 32 | it('should remove all URLs from a string', ((done) => { 33 | const testCase = 'سلام این یک متن است که شامل چندین URL هست که باید حذف شوند: https://google.com http://www.google.com یکی دیگه: http://google.com/ اینم آخری: google.com'; 34 | 35 | expect(removeURLs(testCase)) 36 | .to.equal('سلام این یک متن است که شامل چندین url هست که باید حذف شوند: یکی دیگه: / اینم آخری:'); 37 | 38 | done(); 39 | })); 40 | 41 | it('should remove suspicious words from a string', ((done) => { 42 | const testCase = 'سلام این یک متن است که دارای کلمات جنگو و روبی و پایتون و چند تای دیگر است که این اسامی باید حذف شوند.'; 43 | 44 | expect(removeSuspiciousWords(testCase)).to.equal('سلام این یک متن است که دارای کلمات جنگو و و و چند تای دیگر است که این اسامی باید حذف شوند.'); 45 | 46 | done(); 47 | })); 48 | 49 | it('should properly count the number of hashtags', ((done) => { 50 | const testCase = 'سلام این یک متن جاوا #اسکریپتی می‌باشد. و این #جمله هم دارای تعدادی، #هشتگ است'; 51 | 52 | expect(getNumberOfHashtags(testCase)).to.equal(3); 53 | 54 | done(); 55 | })); 56 | }); 57 | -------------------------------------------------------------------------------- /test/2 - integration/integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, before } from 'mocha'; 2 | import * as supertest from 'supertest'; 3 | import { 4 | makeHashtag, 5 | getTweetFullText, 6 | fillArrayWithWords, 7 | loadJSONFileContent, 8 | getNumberOfHashtags, 9 | removeRetweetNotation, 10 | } from '../../src/utils'; 11 | import { onTweet } from '../../src/bot/app'; 12 | import { createServer } from '../0 - express-server/server'; 13 | 14 | const chai = require('chai'); 15 | const chaiHttp = require('chai-http'); 16 | 17 | chai.use(chaiHttp); 18 | 19 | const { expect } = chai; 20 | 21 | const app = createServer(); 22 | 23 | describe('Integration Tests', () => { 24 | const BASE_DATA_DIR = `${__dirname}/../../src/data`; 25 | const WORDS_TO_FOLLOW_FILE_PATH = 'words-to-follow.json'; 26 | const WORDS_NOT_TO_FOLLOW_FILE_PATH = 'words-not-to-follow.json'; 27 | 28 | let tweets: any[]; 29 | 30 | before(async () => { 31 | await supertest(app) 32 | .get('/tweets') 33 | .expect(200) 34 | .then(async (response) => { 35 | tweets = JSON.parse(JSON.parse(response.text).tweets); 36 | }); 37 | }); 38 | 39 | it('should return an array including "words to follow", both in plain form and in hashtag form', (done) => { 40 | const wordsToFollow = loadJSONFileContent( 41 | `${BASE_DATA_DIR}/${WORDS_TO_FOLLOW_FILE_PATH}`, 42 | ) as string[]; 43 | let interestingWords: string[] = []; 44 | 45 | interestingWords = fillArrayWithWords( 46 | interestingWords, 47 | wordsToFollow as string[], 48 | ); 49 | 50 | const allExpectedValuesAreInInterestedWordsArray = wordsToFollow.every( 51 | (word: string) => 52 | interestingWords.includes(word) && 53 | interestingWords.includes(makeHashtag(word)), 54 | ); 55 | 56 | expect(allExpectedValuesAreInInterestedWordsArray).to.be.true; 57 | 58 | done(); 59 | }); 60 | 61 | it('should return an array including "words not to follow", both in plain form and in hashtag form', (done) => { 62 | const wordsNotToFollow = loadJSONFileContent( 63 | `${BASE_DATA_DIR}/${WORDS_NOT_TO_FOLLOW_FILE_PATH}`, 64 | ) as string[]; 65 | let blacklistedWords: string[] = []; 66 | 67 | blacklistedWords = fillArrayWithWords( 68 | blacklistedWords, 69 | wordsNotToFollow as string[], 70 | ); 71 | 72 | const allExpectedValuesAreInBlackListedWordsArray = wordsNotToFollow.every( 73 | (word: string) => 74 | blacklistedWords.includes(word) && 75 | blacklistedWords.includes(makeHashtag(word)), 76 | ); 77 | 78 | expect(allExpectedValuesAreInBlackListedWordsArray).to.be.true; 79 | 80 | done(); 81 | }); 82 | 83 | it('should properly count number of hashtags', (done) => { 84 | const hashtagsGetsCountedProperly = tweets.every( 85 | // TODO: Make a type for this 86 | (tweet: { text: string; numberOfHashtags: number }) => 87 | tweet.numberOfHashtags === 88 | getNumberOfHashtags(removeRetweetNotation(getTweetFullText(tweet))), 89 | ); 90 | 91 | expect(hashtagsGetsCountedProperly).to.be.true; 92 | 93 | done(); 94 | }); 95 | 96 | it('should return the `id` of valid tweets or "0" for invalid tweets', (done) => { 97 | tweets.forEach(async (tweet: any) => { 98 | if (tweet.tweetIsValid) { 99 | expect(await onTweet(tweet)).to.be.not.equal('0'); 100 | } else { 101 | expect(await onTweet(tweet)).to.be.equal('0'); 102 | } 103 | }); 104 | 105 | done(); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | --require source-map-support/register 3 | --recursive 4 | 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./**/*.ts" 4 | ], 5 | "compilerOptions": { 6 | "noImplicitAny": true, 7 | "target": "es5", 8 | "outDir": "dist", 9 | "downlevelIteration": true, 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "moduleResolution": "Node", 13 | "sourceMap": false, 14 | "typeRoots": ["./src/types/**/*.d.ts"], 15 | "declaration": true 16 | }, 17 | "exclude": [ 18 | "./test/", 19 | "./node_modules/", 20 | "./dist/" 21 | ] 22 | } 23 | --------------------------------------------------------------------------------