├── .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 | [](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 |
--------------------------------------------------------------------------------