├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .gitbook.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── Dockerfile ├── LEGAL.md ├── LICENSE ├── README.md ├── config ├── bot-sites.example.json ├── config.example.json └── debug.example.json ├── lang ├── lang.common.json ├── lang.en-GB.json ├── lang.en-US.json └── logs.json ├── misc └── Discord Bot Cluster API.postman_collection.json ├── package-lock.json ├── package.json ├── process.json ├── src ├── buttons │ ├── button.ts │ └── index.ts ├── commands │ ├── args.ts │ ├── chat │ │ ├── dev-command.ts │ │ ├── help-command.ts │ │ ├── index.ts │ │ ├── info-command.ts │ │ └── test-command.ts │ ├── command.ts │ ├── index.ts │ ├── message │ │ ├── index.ts │ │ └── view-date-sent.ts │ ├── metadata.ts │ └── user │ │ ├── index.ts │ │ └── view-date-joined.ts ├── constants │ ├── discord-limits.ts │ └── index.ts ├── controllers │ ├── controller.ts │ ├── guilds-controller.ts │ ├── index.ts │ ├── root-controller.ts │ └── shards-controller.ts ├── enums │ ├── dev-command-name.ts │ ├── help-option.ts │ ├── index.ts │ └── info-option.ts ├── events │ ├── button-handler.ts │ ├── command-handler.ts │ ├── event-handler.ts │ ├── guild-join-handler.ts │ ├── guild-leave-handler.ts │ ├── index.ts │ ├── message-handler.ts │ ├── reaction-handler.ts │ └── trigger-handler.ts ├── extensions │ ├── custom-client.ts │ └── index.ts ├── jobs │ ├── index.ts │ ├── job.ts │ └── update-server-count-job.ts ├── middleware │ ├── check-auth.ts │ ├── handle-error.ts │ ├── index.ts │ └── map-class.ts ├── models │ ├── api.ts │ ├── bot.ts │ ├── cluster-api │ │ ├── guilds.ts │ │ ├── index.ts │ │ └── shards.ts │ ├── config-models.ts │ ├── enum-helpers │ │ ├── index.ts │ │ ├── language.ts │ │ └── permission.ts │ ├── internal-models.ts │ ├── manager.ts │ └── master-api │ │ ├── clusters.ts │ │ └── index.ts ├── reactions │ ├── index.ts │ └── reaction.ts ├── services │ ├── command-registration-service.ts │ ├── event-data-service.ts │ ├── http-service.ts │ ├── index.ts │ ├── job-service.ts │ ├── lang.ts │ ├── logger.ts │ └── master-api-service.ts ├── start-bot.ts ├── start-manager.ts ├── triggers │ ├── index.ts │ └── trigger.ts └── utils │ ├── client-utils.ts │ ├── command-utils.ts │ ├── format-utils.ts │ ├── index.ts │ ├── interaction-utils.ts │ ├── math-utils.ts │ ├── message-utils.ts │ ├── partial-utils.ts │ ├── permission-utils.ts │ ├── random-utils.ts │ ├── regex-utils.ts │ ├── shard-utils.ts │ ├── string-utils.ts │ └── thread-utils.ts ├── tests ├── helpers │ └── discord-mocks.ts └── utils │ ├── command-utils.test.ts │ ├── format-utils.test.ts │ ├── math-utils.test.ts │ ├── random-utils.test.ts │ ├── regex-utils.test.ts │ └── string-utils.test.ts ├── tsconfig.json ├── tsconfig.test.json └── vitest.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | /.cache 3 | /.git 4 | /dist 5 | /docs 6 | /misc 7 | /node_modules 8 | /temp 9 | 10 | # Files 11 | /npm-debug.log 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | /.cache 3 | /.git 4 | /dist 5 | /docs 6 | /misc 7 | /node_modules 8 | /temp 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "./tsconfig.json" 6 | }, 7 | "plugins": ["@typescript-eslint", "import", "unicorn"], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 12 | "plugin:import/recommended", 13 | "plugin:import/typescript" 14 | ], 15 | "rules": { 16 | "@typescript-eslint/explicit-function-return-type": [ 17 | "error", 18 | { 19 | "allowExpressions": true 20 | } 21 | ], 22 | "@typescript-eslint/no-explicit-any": "off", 23 | "@typescript-eslint/no-floating-promises": "off", 24 | "@typescript-eslint/no-inferrable-types": [ 25 | "error", 26 | { 27 | "ignoreParameters": true 28 | } 29 | ], 30 | "@typescript-eslint/no-misused-promises": "off", 31 | "@typescript-eslint/no-unsafe-argument": "off", 32 | "@typescript-eslint/no-unsafe-assignment": "off", 33 | "@typescript-eslint/no-unsafe-call": "off", 34 | "@typescript-eslint/no-unsafe-enum-comparison": "off", 35 | "@typescript-eslint/no-unsafe-member-access": "off", 36 | "@typescript-eslint/no-unsafe-return": "off", 37 | "@typescript-eslint/no-unused-vars": [ 38 | "error", 39 | { 40 | "argsIgnorePattern": "^_", 41 | "caughtErrorsIgnorePattern": "^_", 42 | "varsIgnorePattern": "^_" 43 | } 44 | ], 45 | "@typescript-eslint/no-var-requires": "off", 46 | "@typescript-eslint/only-throw-error": "off", 47 | "@typescript-eslint/require-await": "off", 48 | "@typescript-eslint/restrict-template-expressions": "off", 49 | "@typescript-eslint/return-await": ["error", "always"], 50 | "@typescript-eslint/typedef": [ 51 | "error", 52 | { 53 | "parameter": true, 54 | "propertyDeclaration": true 55 | } 56 | ], 57 | "import/extensions": ["error", "ignorePackages"], 58 | "import/no-extraneous-dependencies": "error", 59 | "import/no-unresolved": "off", 60 | "import/no-useless-path-segments": "error", 61 | "import/order": [ 62 | "error", 63 | { 64 | "alphabetize": { 65 | "caseInsensitive": true, 66 | "order": "asc" 67 | }, 68 | "groups": [ 69 | ["builtin", "external", "object", "type"], 70 | ["internal", "parent", "sibling", "index"] 71 | ], 72 | "newlines-between": "always" 73 | } 74 | ], 75 | "no-return-await": "off", 76 | "no-unused-vars": "off", 77 | "prefer-const": "off", 78 | "quotes": [ 79 | "error", 80 | "single", 81 | { 82 | "allowTemplateLiterals": true 83 | } 84 | ], 85 | "sort-imports": [ 86 | "error", 87 | { 88 | "allowSeparatedGroups": true, 89 | "ignoreCase": true, 90 | "ignoreDeclarationSort": true, 91 | "ignoreMemberSort": false, 92 | "memberSyntaxSortOrder": ["none", "all", "multiple", "single"] 93 | } 94 | ], 95 | "unicorn/prefer-node-protocol": "error" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /.gitbook.yaml: -------------------------------------------------------------------------------- 1 | root: ./docs/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ===================================================== 2 | # Custom 3 | # ===================================================== 4 | /dist 5 | /temp 6 | /**/config/**/*.json 7 | !/**/config/**/*.example.json 8 | 9 | # ===================================================== 10 | # Node.js 11 | # ===================================================== 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | .pnpm-debug.log* 20 | 21 | # Diagnostic reports (https://nodejs.org/api/report.html) 22 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 23 | 24 | # Runtime data 25 | pids 26 | *.pid 27 | *.seed 28 | *.pid.lock 29 | 30 | # Directory for instrumented libs generated by jscoverage/JSCover 31 | lib-cov 32 | 33 | # Coverage directory used by tools like istanbul 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | .nyc_output 39 | 40 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 41 | .grunt 42 | 43 | # Bower dependency directory (https://bower.io/) 44 | bower_components 45 | 46 | # node-waf configuration 47 | .lock-wscript 48 | 49 | # Compiled binary addons (https://nodejs.org/api/addons.html) 50 | build/Release 51 | 52 | # Dependency directories 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | web_modules/ 58 | 59 | # TypeScript cache 60 | *.tsbuildinfo 61 | 62 | # Optional npm cache directory 63 | .npm 64 | 65 | # Optional eslint cache 66 | .eslintcache 67 | 68 | # Microbundle cache 69 | .rpt2_cache/ 70 | .rts2_cache_cjs/ 71 | .rts2_cache_es/ 72 | .rts2_cache_umd/ 73 | 74 | # Optional REPL history 75 | .node_repl_history 76 | 77 | # Output of 'npm pack' 78 | *.tgz 79 | 80 | # Yarn Integrity file 81 | .yarn-integrity 82 | 83 | # dotenv environment variables file 84 | .env 85 | .env.test 86 | .env.production 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and not Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # Serverless directories 110 | .serverless/ 111 | 112 | # FuseBox cache 113 | .fusebox/ 114 | 115 | # DynamoDB Local files 116 | .dynamodb/ 117 | 118 | # TernJS port file 119 | .tern-port 120 | 121 | # Stores VSCode versions used for testing VSCode extensions 122 | .vscode-test 123 | 124 | # yarn v2 125 | .yarn/cache 126 | .yarn/unplugged 127 | .yarn/build-state.yml 128 | .yarn/install-state.gz 129 | .pnp.* -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | /.cache 3 | /.git 4 | /dist 5 | /docs 6 | /misc 7 | /node_modules 8 | /temp 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "arrowParens": "avoid", 11 | "endOfLine": "auto" 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "streetsidesoftware.code-spell-checker" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "start:bot", 6 | "type": "node", 7 | "request": "launch", 8 | "protocol": "inspector", 9 | "preLaunchTask": "build", 10 | "cwd": "${workspaceFolder}", 11 | "runtimeExecutable": "node", 12 | "args": ["--enable-source-maps", "${workspaceFolder}/dist/start-bot.js"], 13 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 14 | "outputCapture": "std", 15 | "internalConsoleOptions": "openOnSessionStart", 16 | "skipFiles": ["/**"], 17 | "restart": false 18 | }, 19 | { 20 | "name": "start:manager", 21 | "type": "node", 22 | "request": "launch", 23 | "protocol": "inspector", 24 | "preLaunchTask": "build", 25 | "cwd": "${workspaceFolder}", 26 | "runtimeExecutable": "node", 27 | "args": ["--enable-source-maps", "${workspaceFolder}/dist/start-manager.js"], 28 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 29 | "outputCapture": "std", 30 | "internalConsoleOptions": "openOnSessionStart", 31 | "skipFiles": ["/**"], 32 | "restart": false 33 | }, 34 | { 35 | "name": "commands:view", 36 | "type": "node", 37 | "request": "launch", 38 | "protocol": "inspector", 39 | "preLaunchTask": "build", 40 | "cwd": "${workspaceFolder}", 41 | "runtimeExecutable": "node", 42 | "args": [ 43 | "--enable-source-maps", 44 | "${workspaceFolder}/dist/start-bot.js", 45 | "commands", 46 | "view" 47 | ], 48 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 49 | "outputCapture": "std", 50 | "internalConsoleOptions": "openOnSessionStart", 51 | "skipFiles": ["/**"], 52 | "restart": false 53 | }, 54 | { 55 | "name": "commands:register", 56 | "type": "node", 57 | "request": "launch", 58 | "protocol": "inspector", 59 | "preLaunchTask": "build", 60 | "cwd": "${workspaceFolder}", 61 | "runtimeExecutable": "node", 62 | "args": [ 63 | "--enable-source-maps", 64 | "${workspaceFolder}/dist/start-bot.js", 65 | "commands", 66 | "register" 67 | ], 68 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 69 | "outputCapture": "std", 70 | "internalConsoleOptions": "openOnSessionStart", 71 | "skipFiles": ["/**"], 72 | "restart": false 73 | }, 74 | { 75 | "name": "commands:rename", 76 | "type": "node", 77 | "request": "launch", 78 | "protocol": "inspector", 79 | "preLaunchTask": "build", 80 | "cwd": "${workspaceFolder}", 81 | "runtimeExecutable": "node", 82 | "args": [ 83 | "--enable-source-maps", 84 | "${workspaceFolder}/dist/start-bot.js", 85 | "commands", 86 | "rename", 87 | "old_name", 88 | "new_name" 89 | ], 90 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 91 | "outputCapture": "std", 92 | "internalConsoleOptions": "openOnSessionStart", 93 | "skipFiles": ["/**"], 94 | "restart": false 95 | }, 96 | { 97 | "name": "commands:delete", 98 | "type": "node", 99 | "request": "launch", 100 | "protocol": "inspector", 101 | "preLaunchTask": "build", 102 | "cwd": "${workspaceFolder}", 103 | "runtimeExecutable": "node", 104 | "args": [ 105 | "--enable-source-maps", 106 | "${workspaceFolder}/dist/start-bot.js", 107 | "commands", 108 | "delete", 109 | "command_name" 110 | ], 111 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 112 | "outputCapture": "std", 113 | "internalConsoleOptions": "openOnSessionStart", 114 | "skipFiles": ["/**"], 115 | "restart": false 116 | }, 117 | { 118 | "name": "commands:clear", 119 | "type": "node", 120 | "request": "launch", 121 | "protocol": "inspector", 122 | "preLaunchTask": "build", 123 | "cwd": "${workspaceFolder}", 124 | "runtimeExecutable": "node", 125 | "args": [ 126 | "--enable-source-maps", 127 | "${workspaceFolder}/dist/start-bot.js", 128 | "commands", 129 | "clear" 130 | ], 131 | "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], 132 | "outputCapture": "std", 133 | "internalConsoleOptions": "openOnSessionStart", 134 | "skipFiles": ["/**"], 135 | "restart": false 136 | } 137 | ] 138 | } 139 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[json]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true 5 | }, 6 | "[markdown]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "editor.formatOnSave": true 9 | }, 10 | "[typescript]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode", 12 | "editor.formatOnSave": true 13 | }, 14 | "cSpell.enabled": true, 15 | "cSpell.words": [ 16 | "autocompletes", 17 | "autocompleting", 18 | "bot's", 19 | "cmds", 20 | "cooldown", 21 | "cooldowns", 22 | "datas", 23 | "descs", 24 | "discordbotlist", 25 | "discordjs", 26 | "discordlabs", 27 | "discordlist", 28 | "disforge", 29 | "filesize", 30 | "luxon", 31 | "millis", 32 | "Novak", 33 | "ondiscord", 34 | "parens", 35 | "pino", 36 | "regexes", 37 | "respawn", 38 | "respawned", 39 | "restjson", 40 | "unescapes", 41 | "varchar" 42 | ], 43 | "typescript.preferences.importModuleSpecifierEnding": "js" 44 | } 45 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "type": "shell", 7 | "command": "${workspaceFolder}\\node_modules\\.bin\\tsc", 8 | "args": ["--project", "${workspaceFolder}\\tsconfig.json"] 9 | } 10 | ], 11 | "windows": { 12 | "options": { 13 | "shell": { 14 | "executable": "cmd.exe", 15 | "args": ["/d", "/c"] 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | # Create app directory 4 | WORKDIR /app 5 | 6 | # Copy package.json and package-lock.json 7 | COPY package*.json ./ 8 | 9 | # Install packages 10 | RUN npm install 11 | 12 | # Copy the app code 13 | COPY . . 14 | 15 | # Build the project 16 | RUN npm run build 17 | 18 | # Expose ports 19 | EXPOSE 3001 20 | 21 | # Run the application 22 | CMD [ "node", "dist/start-manager.js" ] 23 | -------------------------------------------------------------------------------- /LEGAL.md: -------------------------------------------------------------------------------- 1 | # Terms of Service 2 | 3 | ## Usage Agreement 4 | 5 | By inviting the bot or using its features, you are agreeing to the below mentioned Terms of Service and Privacy Policy. 6 | 7 | You acknowledge that you have the privilege to use the bot freely on any Discord server you share with it, that you can invite it to any server that you have "Manage Server" rights for and that this privilege might get revoked for you, if you're subject of breaking the terms and/or policy of this bot, or the Terms of Service, Privacy Policy and/or Community Guidelines of Discord Inc. 8 | 9 | Through inviting or interacting with the bot it may collect specific data as described in its [Privacy Policy](#privacy-policy). The intended usage of this data is for core functionalities of the bot such as command handling, server settings, and user settings. 10 | 11 | ## Intended Age 12 | 13 | The bot may not be used by individuals under the minimal age described in Discord's Terms of Service. 14 | 15 | Do not provide any age-restricted content (as defined in Discord's safety policies) to the bot. Age-restricted content includes but is not limited to content and discussion related to: 16 | 17 | - Sexually explicit material such as pornography or sexually explicit text 18 | - Violent content 19 | - Illegal, dangerous, and regulated goods such as firearms, tactical gear, alcohol, or drug use 20 | - Gambling-adjacent or addictive behavior 21 | 22 | Content submitted to the bot through the use of commands arguments, text inputs, image inputs, or otherwise must adhere to the above conditions. Violating these conditions may result in your account being reported to Discord Inc for further action. 23 | 24 | ## Affiliation 25 | 26 | The bot is not affiliated with, supported by, or made by Discord Inc. 27 | 28 | Any direct connection to Discord or any of its trademark objects is purely coincidental. We do not claim to have the copyright ownership of any of Discord's assets, trademarks or other intellectual property. 29 | 30 | ## Liability 31 | 32 | The owner(s) of the bot may not be made liable for individuals breaking these Terms at any given time. We have faith in the end users being truthful about their information and not misusing this bot or the services of Discord Inc in a malicious way. 33 | 34 | We reserve the right to update these terms at our own discretion, giving you a 1-week (7 days) period to opt out of these terms if you're not agreeing with the new changes. 35 | 36 | You may opt out by removing the bot from any server you have the rights for. 37 | 38 | ## Contact 39 | 40 | People may get in contact through the official support server of the bot. 41 | 42 | Other ways of support may be provided but aren't guaranteed. 43 | 44 | # Privacy Policy 45 | 46 | ## Usage of Data 47 | 48 | The bot may use stored data, as defined below, for different features including but not limited to: 49 | 50 | - Command handling 51 | - Providing server and user preferences 52 | 53 | The bot may share non-sensitive data with 3rd party sites or services, including but not limited to: 54 | 55 | - Aggregate/statistical data (ex: total number of server or users) 56 | - Discord generated IDs needed to tie 3rd party data to Discord or user-provided data 57 | 58 | Personally identifiable (other than IDs) or sensitive information will not be shared with 3rd party sites or services. 59 | 60 | ## Updating Data 61 | 62 | The bot's data may be updated when using specific commands. 63 | 64 | Updating data can require the input of an end user, and data that can be seen as sensitive, such as content of a message, may need to be stored when using certain commands. 65 | 66 | ## Temporarily Stored Data 67 | 68 | The bot may keep stored data in an internal caching mechanic for a certain amount of time. After this time period, the cached information will be dropped and only be re-added when required. 69 | 70 | Data may be dropped from cache pre-maturely through actions such as removing the bot from the server. 71 | 72 | ## Removal of Data 73 | 74 | Manual removal of the data can be requested through the official support server. Discord IDs such as user, guild, role, etc. may be stored even after the removal of other data in order to properly identify bot specific statistics since those IDs are public and non-sensitive. 75 | 76 | For security reasons we will ask you to provide us with proof of ownership to the data you wish to be removed. Only a server owner may request manual removal of server data. 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kevin Novak 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 | # Discord Bot TypeScript Template 2 | 3 | [![discord.js](https://img.shields.io/github/package-json/dependency-version/KevinNovak/Discord-Bot-TypeScript-Template/discord.js)](https://discord.js.org/) 4 | [![License](https://img.shields.io/badge/license-MIT-blue)](https://opensource.org/licenses/MIT) 5 | [![Stars](https://img.shields.io/github/stars/KevinNovak/Discord-Bot-TypeScript-Template.svg)](https://github.com/KevinNovak/Discord-Bot-TypeScript-Template/stargazers) 6 | [![Pull Requests](https://img.shields.io/badge/Pull%20Requests-Welcome!-brightgreen)](https://github.com/KevinNovak/Discord-Bot-TypeScript-Template/pulls) 7 | 8 | **Discord bot** - A discord.js bot template written with TypeScript. 9 | 10 | ## Introduction 11 | 12 | This template was created to give developers a starting point for new Discord bots, so that much of the initial setup can be avoided and developers can instead focus on meaningful bot features. Developers can simply copy this repo, follow the [setup instructions](#setup) below, and have a working bot with many [boilerplate features](#features) already included! 13 | 14 | For help using this template, feel free to [join our support server](https://discord.gg/c9kQktCbsE)! 15 | 16 | [![Discord Shield](https://discord.com/api/guilds/660711235766976553/widget.png?style=shield)](https://discord.gg/c9kQktCbsE) 17 | 18 | ## Features 19 | 20 | ### Built-In Bot Features: 21 | 22 | - Basic command structure. 23 | - Rate limits and command cooldowns. 24 | - Welcome message when joining a server. 25 | - Shows server count in bot status. 26 | - Posts server count to popular bot list websites. 27 | - Support for multiple languages. 28 | 29 | ### Developer Friendly: 30 | 31 | - Written with TypeScript. 32 | - Uses the [discord.js](https://discord.js.org/) framework. 33 | - Built-in debugging setup for VSCode. 34 | - Written with [ESM](https://nodejs.org/api/esm.html#introduction) for future compatibility with packages. 35 | - Support for running with the [PM2](https://pm2.keymetrics.io/) process manger. 36 | - Support for running with [Docker](https://www.docker.com/). 37 | 38 | ### Scales as Your Bot Grows: 39 | 40 | - Supports [sharding](https://discordjs.guide/sharding/) which is required when your bot is in 2500+ servers. 41 | - Supports [clustering](https://github.com/KevinNovak/Discord-Bot-TypeScript-Template-Master-Api) which allows you to run your bot on multiple machines. 42 | 43 | ## Commands 44 | 45 | This bot has a few example commands which can be modified as needed. 46 | 47 | ### Help Command 48 | 49 | A `/help` command to get help on different areas of the bot or to contact support: 50 | 51 | ![](https://i.imgur.com/UUA4WzL.png) 52 | 53 | ![](https://i.imgur.com/YtDdmTe.png) 54 | 55 | ![](https://i.imgur.com/JXMisap.png) 56 | 57 | ### Info Command 58 | 59 | A `/info` command to get information about the bot or links to different resources. 60 | 61 | ![](https://i.imgur.com/0kKOaWM.png) 62 | 63 | ### Test Command 64 | 65 | A generic command, `/test`, which can be copied to create additional commands. 66 | 67 | ![](https://i.imgur.com/lqjkNKM.png) 68 | 69 | ### Dev Command 70 | 71 | A `/dev` command which can only be run by the bot developer. Shows developer information, but can be extended to perform developer-only actions. 72 | 73 | ![](https://i.imgur.com/2o1vEno.png) 74 | 75 | ### Welcome Message 76 | 77 | A welcome message is sent to the server and owner when the bot is added. 78 | 79 | ![](https://i.imgur.com/QBw8H8v.png) 80 | 81 | ## Setup 82 | 83 | 1. Copy example config files. 84 | - Navigate to the `config` folder of this project. 85 | - Copy all files ending in `.example.json` and remove the `.example` from the copied file names. 86 | - Ex: `config.example.json` should be copied and renamed as `config.json`. 87 | 2. Obtain a bot token. 88 | - You'll need to create a new bot in your [Discord Developer Portal](https://discord.com/developers/applications/). 89 | - See [here](https://www.writebots.com/discord-bot-token/) for detailed instructions. 90 | - At the end you should have a **bot token**. 91 | 3. Modify the config file. 92 | - Open the `config/config.json` file. 93 | - You'll need to edit the following values: 94 | - `client.id` - Your discord bot's [user ID](https://techswift.org/2020/04/22/how-to-find-your-user-id-on-discord/). 95 | - `client.token` - Your discord bot's token. 96 | 4. Install packages. 97 | - Navigate into the downloaded source files and type `npm install`. 98 | 5. Register commands. 99 | - In order to use slash commands, they first [have to be registered](https://discordjs.guide/creating-your-bot/command-deployment.html). 100 | - Type `npm run commands:register` to register the bot's commands. 101 | - Run this script any time you change a command name, structure, or add/remove commands. 102 | - This is so Discord knows what your commands look like. 103 | - It may take up to an hour for command changes to appear. 104 | 105 | ## Start Scripts 106 | 107 | You can run the bot in multiple modes: 108 | 109 | 1. Normal Mode 110 | - Type `npm start`. 111 | - Starts a single instance of the bot. 112 | 2. Manager Mode 113 | - Type `npm run start:manager`. 114 | - Starts a shard manager which will spawn multiple bot shards. 115 | 3. PM2 Mode 116 | - Type `npm run start:pm2`. 117 | - Similar to Manager Mode but uses [PM2](https://pm2.keymetrics.io/) to manage processes. 118 | 119 | ## Bots Using This Template 120 | 121 | A list of Discord bots using this template. 122 | 123 | | Bot | Servers | 124 | | ---------------------------------------------------------------------- | ------------------------------------------------------------- | 125 | | [Birthday Bot](https://top.gg/bot/656621136808902656) | ![](https://top.gg/api/widget/servers/656621136808902656.svg) | 126 | | [QOTD Bot](https://top.gg/bot/713586207119900693) | ![](https://top.gg/api/widget/servers/713586207119900693.svg) | 127 | | [Friend Time](https://top.gg/bot/471091072546766849) | ![](https://top.gg/api/widget/servers/471091072546766849.svg) | 128 | | [Bento](https://top.gg/bot/787041583580184609) | ![](https://top.gg/api/widget/servers/787041583580184609.svg) | 129 | | [NFT-Info](https://top.gg/bot/902249456072818708) | ![](https://top.gg/api/widget/servers/902249456072818708.svg) | 130 | | [Skylink-IF](https://top.gg/bot/929527099922993162) | ![](https://top.gg/api/widget/servers/929527099922993162.svg) | 131 | | [Topcoder TC-101](https://github.com/topcoder-platform/tc-discord-bot) | | 132 | 133 | Don't see your bot listed? [Contact us](https://discord.gg/c9kQktCbsE) to have your bot added! 134 | -------------------------------------------------------------------------------- /config/bot-sites.example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "top.gg", 4 | "enabled": false, 5 | "url": "https://top.gg/api/bots//stats", 6 | "authorization": "", 7 | "body": "{\"server_count\":{{SERVER_COUNT}}}" 8 | }, 9 | { 10 | "name": "bots.ondiscord.xyz", 11 | "enabled": false, 12 | "url": "https://bots.ondiscord.xyz/bot-api/bots//guilds", 13 | "authorization": "", 14 | "body": "{\"guildCount\":{{SERVER_COUNT}}}" 15 | }, 16 | { 17 | "name": "discord.bots.gg", 18 | "enabled": false, 19 | "url": "https://discord.bots.gg/api/v1/bots//stats", 20 | "authorization": "", 21 | "body": "{\"guildCount\":{{SERVER_COUNT}}}" 22 | }, 23 | { 24 | "name": "discordbotlist.com", 25 | "enabled": false, 26 | "url": "https://discordbotlist.com/api/bots//stats", 27 | "authorization": "Bot ", 28 | "body": "{\"guilds\":{{SERVER_COUNT}}}" 29 | }, 30 | { 31 | "name": "discords.com", 32 | "enabled": false, 33 | "url": "https://discords.com/bots/api/bot//setservers", 34 | "authorization": "", 35 | "body": "{\"server_count\":{{SERVER_COUNT}}}" 36 | }, 37 | { 38 | "name": "disforge.com", 39 | "enabled": false, 40 | "url": "https://disforge.com/api/botstats/", 41 | "authorization": "", 42 | "body": "{\"servers\":{{SERVER_COUNT}}}" 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /config/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "developers": [""], 3 | "client": { 4 | "id": "", 5 | "token": "", 6 | "intents": [ 7 | "Guilds", 8 | "GuildMessages", 9 | "GuildMessageReactions", 10 | "DirectMessages", 11 | "DirectMessageReactions" 12 | ], 13 | "partials": ["Message", "Channel", "Reaction"], 14 | "caches": { 15 | "AutoModerationRuleManager": 0, 16 | "BaseGuildEmojiManager": 0, 17 | "GuildEmojiManager": 0, 18 | "GuildBanManager": 0, 19 | "GuildInviteManager": 0, 20 | "GuildScheduledEventManager": 0, 21 | "GuildStickerManager": 0, 22 | "MessageManager": 0, 23 | "PresenceManager": 0, 24 | "StageInstanceManager": 0, 25 | "ThreadManager": 0, 26 | "ThreadMemberManager": 0, 27 | "VoiceStateManager": 0 28 | } 29 | }, 30 | "api": { 31 | "port": 3001, 32 | "secret": "00000000-0000-0000-0000-000000000000" 33 | }, 34 | "sharding": { 35 | "spawnDelay": 5, 36 | "spawnTimeout": 300, 37 | "serversPerShard": 1000 38 | }, 39 | "clustering": { 40 | "enabled": false, 41 | "shardCount": 16, 42 | "callbackUrl": "http://localhost:3001/", 43 | "masterApi": { 44 | "url": "http://localhost:5000/", 45 | "token": "00000000-0000-0000-0000-000000000000" 46 | } 47 | }, 48 | "jobs": { 49 | "updateServerCount": { 50 | "schedule": "0 */10 * * * *", 51 | "log": false, 52 | "runOnce": false, 53 | "initialDelaySecs": 0 54 | } 55 | }, 56 | "rateLimiting": { 57 | "commands": { 58 | "amount": 10, 59 | "interval": 30 60 | }, 61 | "buttons": { 62 | "amount": 10, 63 | "interval": 30 64 | }, 65 | "triggers": { 66 | "amount": 10, 67 | "interval": 30 68 | }, 69 | "reactions": { 70 | "amount": 10, 71 | "interval": 30 72 | } 73 | }, 74 | "logging": { 75 | "pretty": true, 76 | "rateLimit": { 77 | "minTimeout": 30 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /config/debug.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "override": { 3 | "shardMode": { 4 | "enabled": false, 5 | "value": "worker" 6 | } 7 | }, 8 | "dummyMode": { 9 | "enabled": false, 10 | "whitelist": ["212772875793334272", "478288246858711040"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lang/lang.common.json: -------------------------------------------------------------------------------- 1 | { 2 | "bot": { 3 | "name": "My Bot", 4 | "author": "My Name" 5 | }, 6 | "emojis": { 7 | "yes": "✅", 8 | "no": "❌", 9 | "enabled": "🟢", 10 | "disabled": "🔴", 11 | "info": "ℹ️", 12 | "warning": "⚠️", 13 | "previous": "◀️", 14 | "next": "▶️", 15 | "first": "⏪", 16 | "last": "⏩", 17 | "refresh": "🔄" 18 | }, 19 | "colors": { 20 | "default": "#0099ff", 21 | "success": "#00ff83", 22 | "warning": "#ffcc66", 23 | "error": "#ff4a4a" 24 | }, 25 | "links": { 26 | "author": "https://github.com/", 27 | "docs": "https://top.gg/", 28 | "donate": "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=EW389DYYSS4FC", 29 | "invite": "https://discord.com/", 30 | "source": "https://github.com/", 31 | "stream": "https://www.twitch.tv/novakevin", 32 | "support": "https://support.discord.com/", 33 | "template": "https://github.com/KevinNovak/Discord-Bot-TypeScript-Template", 34 | "terms": "https://github.com/KevinNovak/Discord-Bot-TypeScript-Template/blob/master/LEGAL.md#terms-of-service", 35 | "vote": "https://top.gg/" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lang/logs.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "appStarted": "Application started.", 4 | "apiStarted": "API started on port {PORT}.", 5 | "commandActionView": "\nLocal and remote:\n {LOCAL_AND_REMOTE_LIST}\nLocal only:\n {LOCAL_ONLY_LIST}\nRemote only:\n {REMOTE_ONLY_LIST}", 6 | "commandActionCreating": "Creating commands: {COMMAND_LIST}", 7 | "commandActionCreated": "Commands created.", 8 | "commandActionUpdating": "Updating commands: {COMMAND_LIST}", 9 | "commandActionUpdated": "Commands updated.", 10 | "commandActionRenaming": "Renaming command: '{OLD_COMMAND_NAME}' --> '{NEW_COMMAND_NAME}'", 11 | "commandActionRenamed": "Command renamed.", 12 | "commandActionDeleting": "Deleting command: '{COMMAND_NAME}'", 13 | "commandActionDeleted": "Command deleted.", 14 | "commandActionClearing": "Deleting all commands: {COMMAND_LIST}", 15 | "commandActionCleared": "Commands deleted.", 16 | "managerSpawningShards": "Spawning {SHARD_COUNT} shards: [{SHARD_LIST}].", 17 | "managerLaunchedShard": "Launched Shard {SHARD_ID}.", 18 | "managerAllShardsSpawned": "All shards have been spawned.", 19 | "clientLogin": "Client logged in as '{USER_TAG}'.", 20 | "clientReady": "Client is ready!", 21 | "jobScheduled": "Scheduled job '{JOB}' for '{SCHEDULE}'.", 22 | "jobRun": "Running job '{JOB}'.", 23 | "jobCompleted": "Job '{JOB}' completed.", 24 | "updatedServerCount": "Updated server count. Connected to {SERVER_COUNT} total servers.", 25 | "updatedServerCountSite": "Updated server count on '{BOT_SITE}'.", 26 | "guildJoined": "Guild '{GUILD_NAME}' ({GUILD_ID}) joined.", 27 | "guildLeft": "Guild '{GUILD_NAME}' ({GUILD_ID}) left." 28 | }, 29 | "warn": { 30 | "managerNoShards": "No shards to spawn." 31 | }, 32 | "error": { 33 | "unspecified": "An unspecified error occurred.", 34 | "unhandledRejection": "An unhandled promise rejection occurred.", 35 | "retrieveShards": "An error occurred while retrieving which shards to spawn.", 36 | "managerSpawningShards": "An error occurred while spawning shards.", 37 | "managerShardInfo": "An error occurred while retrieving shard info.", 38 | "commandAction": "An error occurred while running a command action.", 39 | "commandActionNotFound": "Could not find a command with the name '{COMMAND_NAME}'.", 40 | "commandActionRenameMissingArg": "Please supply the current command name and new command name.", 41 | "commandActionDeleteMissingArg": "Please supply a command name to delete.", 42 | "clientLogin": "An error occurred while the client attempted to login.", 43 | "job": "An error occurred while running the '{JOB}' job.", 44 | "updatedServerCountSite": "An error occurred while updating the server count on '{BOT_SITE}'.", 45 | "guildJoin": "An error occurred while processing a guild join.", 46 | "guildLeave": "An error occurred while processing a guild leave.", 47 | "message": "An error occurred while processing a message.", 48 | "reaction": "An error occurred while processing a reaction.", 49 | "command": "An error occurred while processing a command interaction.", 50 | "button": "An error occurred while processing a button interaction.", 51 | "commandNotFound": "[{INTERACTION_ID}] A command with the name '{COMMAND_NAME}' could not be found.", 52 | "autocompleteNotFound": "[{INTERACTION_ID}] An autocomplete method for the '{COMMAND_NAME}' command could not be found.", 53 | "commandGuild": "[{INTERACTION_ID}] An error occurred while executing the '{COMMAND_NAME}' command for user '{USER_TAG}' ({USER_ID}) in channel '{CHANNEL_NAME}' ({CHANNEL_ID}) in guild '{GUILD_NAME}' ({GUILD_ID}).", 54 | "autocompleteGuild": "[{INTERACTION_ID}] An error occurred while autocompleting the '{OPTION_NAME}' option for the '{COMMAND_NAME}' command for user '{USER_TAG}' ({USER_ID}) in channel '{CHANNEL_NAME}' ({CHANNEL_ID}) in guild '{GUILD_NAME}' ({GUILD_ID}).", 55 | "commandOther": "[{INTERACTION_ID}] An error occurred while executing the '{COMMAND_NAME}' command for user '{USER_TAG}' ({USER_ID}).", 56 | "autocompleteOther": "[{INTERACTION_ID}] An error occurred while autocompleting the '{OPTION_NAME}' option for the '{COMMAND_NAME}' command for user '{USER_TAG}' ({USER_ID}).", 57 | "apiRequest": "An error occurred while processing a '{HTTP_METHOD}' request to '{URL}'.", 58 | "apiRateLimit": "A rate limit was hit while making a request." 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /misc/Discord Bot Cluster API.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "d37e9bae-0a24-4940-af63-2716ab3bb660", 4 | "name": "Discord Bot Cluster API", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Shards", 10 | "item": [ 11 | { 12 | "name": "Get Shards", 13 | "request": { 14 | "method": "GET", 15 | "header": [], 16 | "url": { 17 | "raw": "{{BASE_URL}}/shards", 18 | "host": [ 19 | "{{BASE_URL}}" 20 | ], 21 | "path": [ 22 | "shards" 23 | ] 24 | } 25 | }, 26 | "response": [] 27 | }, 28 | { 29 | "name": "Set Shard Presences", 30 | "request": { 31 | "method": "PUT", 32 | "header": [], 33 | "body": { 34 | "mode": "raw", 35 | "raw": "{\r\n \"type\": \"STREAMING\",\r\n \"name\": \"to 1,000,000 servers\",\r\n \"url\": \"https://www.twitch.tv/novakevin\"\r\n}", 36 | "options": { 37 | "raw": { 38 | "language": "json" 39 | } 40 | } 41 | }, 42 | "url": { 43 | "raw": "{{BASE_URL}}/shards/presence", 44 | "host": [ 45 | "{{BASE_URL}}" 46 | ], 47 | "path": [ 48 | "shards", 49 | "presence" 50 | ] 51 | } 52 | }, 53 | "response": [] 54 | } 55 | ] 56 | }, 57 | { 58 | "name": "Guilds", 59 | "item": [ 60 | { 61 | "name": "Get Guilds", 62 | "request": { 63 | "method": "GET", 64 | "header": [], 65 | "url": { 66 | "raw": "{{BASE_URL}}/guilds", 67 | "host": [ 68 | "{{BASE_URL}}" 69 | ], 70 | "path": [ 71 | "guilds" 72 | ] 73 | } 74 | }, 75 | "response": [] 76 | } 77 | ] 78 | }, 79 | { 80 | "name": "Get Root", 81 | "request": { 82 | "method": "GET", 83 | "header": [], 84 | "url": { 85 | "raw": "{{BASE_URL}}", 86 | "host": [ 87 | "{{BASE_URL}}" 88 | ] 89 | } 90 | }, 91 | "response": [] 92 | } 93 | ], 94 | "auth": { 95 | "type": "apikey", 96 | "apikey": [ 97 | { 98 | "key": "key", 99 | "value": "Authorization", 100 | "type": "string" 101 | }, 102 | { 103 | "key": "value", 104 | "value": "00000000-0000-0000-0000-000000000000", 105 | "type": "string" 106 | } 107 | ] 108 | }, 109 | "event": [ 110 | { 111 | "listen": "prerequest", 112 | "script": { 113 | "type": "text/javascript", 114 | "exec": [ 115 | "" 116 | ] 117 | } 118 | }, 119 | { 120 | "listen": "test", 121 | "script": { 122 | "type": "text/javascript", 123 | "exec": [ 124 | "" 125 | ] 126 | } 127 | } 128 | ], 129 | "variable": [ 130 | { 131 | "key": "BASE_URL", 132 | "value": "localhost:3001" 133 | } 134 | ] 135 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-bot", 3 | "version": "1.0.0", 4 | "author": "Kevin Novak", 5 | "description": "A discord.js bot template written with TypeScript", 6 | "license": "MIT", 7 | "private": true, 8 | "engines": { 9 | "node": ">=18.0.0" 10 | }, 11 | "type": "module", 12 | "exports": [ 13 | "./dist/start-bot.js", 14 | "./dist/start-manager.js" 15 | ], 16 | "scripts": { 17 | "lint": "eslint . --cache --ext .js,.jsx,.ts,.tsx", 18 | "lint:fix": "eslint . --fix --cache --ext .js,.jsx,.ts,.tsx", 19 | "format": "prettier --check .", 20 | "format:fix": "prettier --write .", 21 | "clean": "git clean -xdf --exclude=\"/config/**/*\"", 22 | "clean:dry": "git clean -xdf --exclude=\"/config/**/*\" --dry-run", 23 | "build": "tsc --project tsconfig.json", 24 | "commands:view": "npm run build && node --enable-source-maps dist/start-bot.js commands view", 25 | "commands:register": "npm run build && node --enable-source-maps dist/start-bot.js commands register", 26 | "commands:rename": "npm run build && node --enable-source-maps dist/start-bot.js commands rename", 27 | "commands:delete": "npm run build && node --enable-source-maps dist/start-bot.js commands delete", 28 | "commands:clear": "npm run build && node --enable-source-maps dist/start-bot.js commands clear", 29 | "start": "npm run start:bot", 30 | "start:bot": "npm run build && node --enable-source-maps dist/start-bot.js", 31 | "start:manager": "npm run build && node --enable-source-maps dist/start-manager.js", 32 | "start:pm2": "npm run build && npm run pm2:start", 33 | "pm2:start": "pm2 start process.json", 34 | "pm2:stop": "pm2 stop process.json", 35 | "pm2:delete": "pm2 delete process.json", 36 | "test": "vitest run", 37 | "test:watch": "vitest", 38 | "test:coverage": "vitest run --coverage" 39 | }, 40 | "dependencies": { 41 | "@discordjs/rest": "^2.4.3", 42 | "class-transformer": "0.5.1", 43 | "class-validator": "0.14.1", 44 | "cron-parser": "^4.9.0", 45 | "discord.js": "^14.18.0", 46 | "discord.js-rate-limiter": "1.3.2", 47 | "express": "4.21.2", 48 | "express-promise-router": "4.1.1", 49 | "filesize": "10.1.6", 50 | "linguini": "1.3.1", 51 | "luxon": "3.5.0", 52 | "node-fetch": "3.3.2", 53 | "node-schedule": "2.1.1", 54 | "pino": "9.6.0", 55 | "pino-pretty": "13.0.0", 56 | "pm2": "^5.4.3", 57 | "reflect-metadata": "^0.2.2", 58 | "remove-markdown": "0.6.0" 59 | }, 60 | "devDependencies": { 61 | "@types/express": "4.17.21", 62 | "@types/luxon": "3.4.2", 63 | "@types/node": "^22.13.4", 64 | "@types/node-schedule": "2.1.7", 65 | "@types/remove-markdown": "0.3.4", 66 | "@typescript-eslint/eslint-plugin": "^8.24.1", 67 | "@typescript-eslint/parser": "^8.24.1", 68 | "@vitest/coverage-v8": "^3.0.8", 69 | "eslint": "^9.20.1", 70 | "eslint-plugin-import": "^2.31.0", 71 | "eslint-plugin-unicorn": "^57.0.0", 72 | "prettier": "^3.5.1", 73 | "typescript": "^5.7.3", 74 | "vitest": "^3.0.8" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /process.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "my-bot", 5 | "script": "dist/start-manager.js", 6 | "node_args": ["--enable-source-maps"], 7 | "restart_delay": 10000 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/buttons/button.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from 'discord.js'; 2 | 3 | import { EventData } from '../models/internal-models.js'; 4 | 5 | export interface Button { 6 | ids: string[]; 7 | deferType: ButtonDeferType; 8 | requireGuild: boolean; 9 | requireEmbedAuthorTag: boolean; 10 | execute(intr: ButtonInteraction, data: EventData): Promise; 11 | } 12 | 13 | export enum ButtonDeferType { 14 | REPLY = 'REPLY', 15 | UPDATE = 'UPDATE', 16 | NONE = 'NONE', 17 | } 18 | -------------------------------------------------------------------------------- /src/buttons/index.ts: -------------------------------------------------------------------------------- 1 | export { Button, ButtonDeferType } from './button.js'; 2 | -------------------------------------------------------------------------------- /src/commands/args.ts: -------------------------------------------------------------------------------- 1 | import { APIApplicationCommandBasicOption, ApplicationCommandOptionType } from 'discord.js'; 2 | 3 | import { DevCommandName, HelpOption, InfoOption } from '../enums/index.js'; 4 | import { Language } from '../models/enum-helpers/index.js'; 5 | import { Lang } from '../services/index.js'; 6 | 7 | export class Args { 8 | public static readonly DEV_COMMAND: APIApplicationCommandBasicOption = { 9 | name: Lang.getRef('arguments.command', Language.Default), 10 | name_localizations: Lang.getRefLocalizationMap('arguments.command'), 11 | description: Lang.getRef('argDescs.devCommand', Language.Default), 12 | description_localizations: Lang.getRefLocalizationMap('argDescs.devCommand'), 13 | type: ApplicationCommandOptionType.String, 14 | choices: [ 15 | { 16 | name: Lang.getRef('devCommandNames.info', Language.Default), 17 | name_localizations: Lang.getRefLocalizationMap('devCommandNames.info'), 18 | value: DevCommandName.INFO, 19 | }, 20 | ], 21 | }; 22 | public static readonly HELP_OPTION: APIApplicationCommandBasicOption = { 23 | name: Lang.getRef('arguments.option', Language.Default), 24 | name_localizations: Lang.getRefLocalizationMap('arguments.option'), 25 | description: Lang.getRef('argDescs.helpOption', Language.Default), 26 | description_localizations: Lang.getRefLocalizationMap('argDescs.helpOption'), 27 | type: ApplicationCommandOptionType.String, 28 | choices: [ 29 | { 30 | name: Lang.getRef('helpOptionDescs.contactSupport', Language.Default), 31 | name_localizations: Lang.getRefLocalizationMap('helpOptionDescs.contactSupport'), 32 | value: HelpOption.CONTACT_SUPPORT, 33 | }, 34 | { 35 | name: Lang.getRef('helpOptionDescs.commands', Language.Default), 36 | name_localizations: Lang.getRefLocalizationMap('helpOptionDescs.commands'), 37 | value: HelpOption.COMMANDS, 38 | }, 39 | ], 40 | }; 41 | public static readonly INFO_OPTION: APIApplicationCommandBasicOption = { 42 | name: Lang.getRef('arguments.option', Language.Default), 43 | name_localizations: Lang.getRefLocalizationMap('arguments.option'), 44 | description: Lang.getRef('argDescs.helpOption', Language.Default), 45 | description_localizations: Lang.getRefLocalizationMap('argDescs.helpOption'), 46 | type: ApplicationCommandOptionType.String, 47 | choices: [ 48 | { 49 | name: Lang.getRef('infoOptions.about', Language.Default), 50 | name_localizations: Lang.getRefLocalizationMap('infoOptions.about'), 51 | value: InfoOption.ABOUT, 52 | }, 53 | { 54 | name: Lang.getRef('infoOptions.translate', Language.Default), 55 | name_localizations: Lang.getRefLocalizationMap('infoOptions.translate'), 56 | value: InfoOption.TRANSLATE, 57 | }, 58 | ], 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/chat/dev-command.ts: -------------------------------------------------------------------------------- 1 | import djs, { ChatInputCommandInteraction, PermissionsString } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | import os from 'node:os'; 4 | import typescript from 'typescript'; 5 | 6 | import { DevCommandName } from '../../enums/index.js'; 7 | import { Language } from '../../models/enum-helpers/index.js'; 8 | import { EventData } from '../../models/internal-models.js'; 9 | import { Lang } from '../../services/index.js'; 10 | import { FormatUtils, InteractionUtils, ShardUtils } from '../../utils/index.js'; 11 | import { Command, CommandDeferType } from '../index.js'; 12 | 13 | const require = createRequire(import.meta.url); 14 | let Config = require('../../../config/config.json'); 15 | let TsConfig = require('../../../tsconfig.json'); 16 | 17 | export class DevCommand implements Command { 18 | public names = [Lang.getRef('chatCommands.dev', Language.Default)]; 19 | public deferType = CommandDeferType.HIDDEN; 20 | public requireClientPerms: PermissionsString[] = []; 21 | public async execute(intr: ChatInputCommandInteraction, data: EventData): Promise { 22 | if (!Config.developers.includes(intr.user.id)) { 23 | await InteractionUtils.send(intr, Lang.getEmbed('validationEmbeds.devOnly', data.lang)); 24 | return; 25 | } 26 | 27 | let args = { 28 | command: intr.options.getString( 29 | Lang.getRef('arguments.command', Language.Default) 30 | ) as DevCommandName, 31 | }; 32 | 33 | switch (args.command) { 34 | case DevCommandName.INFO: { 35 | let shardCount = intr.client.shard?.count ?? 1; 36 | let serverCount: number; 37 | if (intr.client.shard) { 38 | try { 39 | serverCount = await ShardUtils.serverCount(intr.client.shard); 40 | } catch (error) { 41 | if (error.name.includes('ShardingInProcess')) { 42 | await InteractionUtils.send( 43 | intr, 44 | Lang.getEmbed('errorEmbeds.startupInProcess', data.lang) 45 | ); 46 | return; 47 | } else { 48 | throw error; 49 | } 50 | } 51 | } else { 52 | serverCount = intr.client.guilds.cache.size; 53 | } 54 | 55 | let memory = process.memoryUsage(); 56 | 57 | await InteractionUtils.send( 58 | intr, 59 | Lang.getEmbed('displayEmbeds.devInfo', data.lang, { 60 | NODE_VERSION: process.version, 61 | TS_VERSION: `v${typescript.version}`, 62 | ES_VERSION: TsConfig.compilerOptions.target, 63 | DJS_VERSION: `v${djs.version}`, 64 | SHARD_COUNT: shardCount.toLocaleString(data.lang), 65 | SERVER_COUNT: serverCount.toLocaleString(data.lang), 66 | SERVER_COUNT_PER_SHARD: Math.round(serverCount / shardCount).toLocaleString( 67 | data.lang 68 | ), 69 | RSS_SIZE: FormatUtils.fileSize(memory.rss), 70 | RSS_SIZE_PER_SERVER: 71 | serverCount > 0 72 | ? FormatUtils.fileSize(memory.rss / serverCount) 73 | : Lang.getRef('other.na', data.lang), 74 | HEAP_TOTAL_SIZE: FormatUtils.fileSize(memory.heapTotal), 75 | HEAP_TOTAL_SIZE_PER_SERVER: 76 | serverCount > 0 77 | ? FormatUtils.fileSize(memory.heapTotal / serverCount) 78 | : Lang.getRef('other.na', data.lang), 79 | HEAP_USED_SIZE: FormatUtils.fileSize(memory.heapUsed), 80 | HEAP_USED_SIZE_PER_SERVER: 81 | serverCount > 0 82 | ? FormatUtils.fileSize(memory.heapUsed / serverCount) 83 | : Lang.getRef('other.na', data.lang), 84 | HOSTNAME: os.hostname(), 85 | SHARD_ID: (intr.guild?.shardId ?? 0).toString(), 86 | SERVER_ID: intr.guild?.id ?? Lang.getRef('other.na', data.lang), 87 | BOT_ID: intr.client.user?.id, 88 | USER_ID: intr.user.id, 89 | }) 90 | ); 91 | break; 92 | } 93 | default: { 94 | return; 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/commands/chat/help-command.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder, PermissionsString } from 'discord.js'; 2 | 3 | import { HelpOption } from '../../enums/index.js'; 4 | import { Language } from '../../models/enum-helpers/index.js'; 5 | import { EventData } from '../../models/internal-models.js'; 6 | import { Lang } from '../../services/index.js'; 7 | import { ClientUtils, FormatUtils, InteractionUtils } from '../../utils/index.js'; 8 | import { Command, CommandDeferType } from '../index.js'; 9 | 10 | export class HelpCommand implements Command { 11 | public names = [Lang.getRef('chatCommands.help', Language.Default)]; 12 | public deferType = CommandDeferType.HIDDEN; 13 | public requireClientPerms: PermissionsString[] = []; 14 | public async execute(intr: ChatInputCommandInteraction, data: EventData): Promise { 15 | let args = { 16 | option: intr.options.getString( 17 | Lang.getRef('arguments.option', Language.Default) 18 | ) as HelpOption, 19 | }; 20 | 21 | let embed: EmbedBuilder; 22 | switch (args.option) { 23 | case HelpOption.CONTACT_SUPPORT: { 24 | embed = Lang.getEmbed('displayEmbeds.helpContactSupport', data.lang); 25 | break; 26 | } 27 | case HelpOption.COMMANDS: { 28 | embed = Lang.getEmbed('displayEmbeds.helpCommands', data.lang, { 29 | CMD_LINK_TEST: FormatUtils.commandMention( 30 | await ClientUtils.findAppCommand( 31 | intr.client, 32 | Lang.getRef('chatCommands.test', Language.Default) 33 | ) 34 | ), 35 | CMD_LINK_INFO: FormatUtils.commandMention( 36 | await ClientUtils.findAppCommand( 37 | intr.client, 38 | Lang.getRef('chatCommands.info', Language.Default) 39 | ) 40 | ), 41 | }); 42 | break; 43 | } 44 | default: { 45 | return; 46 | } 47 | } 48 | 49 | await InteractionUtils.send(intr, embed); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/commands/chat/index.ts: -------------------------------------------------------------------------------- 1 | export { DevCommand } from './dev-command.js'; 2 | export { HelpCommand } from './help-command.js'; 3 | export { InfoCommand } from './info-command.js'; 4 | export { TestCommand } from './test-command.js'; 5 | -------------------------------------------------------------------------------- /src/commands/chat/info-command.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder, PermissionsString } from 'discord.js'; 2 | 3 | import { InfoOption } from '../../enums/index.js'; 4 | import { Language } from '../../models/enum-helpers/index.js'; 5 | import { EventData } from '../../models/internal-models.js'; 6 | import { Lang } from '../../services/index.js'; 7 | import { InteractionUtils } from '../../utils/index.js'; 8 | import { Command, CommandDeferType } from '../index.js'; 9 | 10 | export class InfoCommand implements Command { 11 | public names = [Lang.getRef('chatCommands.info', Language.Default)]; 12 | public deferType = CommandDeferType.HIDDEN; 13 | public requireClientPerms: PermissionsString[] = []; 14 | 15 | public async execute(intr: ChatInputCommandInteraction, data: EventData): Promise { 16 | let args = { 17 | option: intr.options.getString( 18 | Lang.getRef('arguments.option', Language.Default) 19 | ) as InfoOption, 20 | }; 21 | 22 | let embed: EmbedBuilder; 23 | switch (args.option) { 24 | case InfoOption.ABOUT: { 25 | embed = Lang.getEmbed('displayEmbeds.about', data.lang); 26 | break; 27 | } 28 | case InfoOption.TRANSLATE: { 29 | embed = Lang.getEmbed('displayEmbeds.translate', data.lang); 30 | for (let langCode of Language.Enabled) { 31 | embed.addFields([ 32 | { 33 | name: Language.Data[langCode].nativeName, 34 | value: Lang.getRef('meta.translators', langCode), 35 | }, 36 | ]); 37 | } 38 | break; 39 | } 40 | default: { 41 | return; 42 | } 43 | } 44 | 45 | await InteractionUtils.send(intr, embed); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/chat/test-command.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, PermissionsString } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | 4 | import { Language } from '../../models/enum-helpers/index.js'; 5 | import { EventData } from '../../models/internal-models.js'; 6 | import { Lang } from '../../services/index.js'; 7 | import { InteractionUtils } from '../../utils/index.js'; 8 | import { Command, CommandDeferType } from '../index.js'; 9 | 10 | export class TestCommand implements Command { 11 | public names = [Lang.getRef('chatCommands.test', Language.Default)]; 12 | public cooldown = new RateLimiter(1, 5000); 13 | public deferType = CommandDeferType.HIDDEN; 14 | public requireClientPerms: PermissionsString[] = []; 15 | 16 | public async execute(intr: ChatInputCommandInteraction, data: EventData): Promise { 17 | await InteractionUtils.send(intr, Lang.getEmbed('displayEmbeds.test', data.lang)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/commands/command.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionChoiceData, 3 | AutocompleteFocusedOption, 4 | AutocompleteInteraction, 5 | CommandInteraction, 6 | PermissionsString, 7 | } from 'discord.js'; 8 | import { RateLimiter } from 'discord.js-rate-limiter'; 9 | 10 | import { EventData } from '../models/internal-models.js'; 11 | 12 | export interface Command { 13 | names: string[]; 14 | cooldown?: RateLimiter; 15 | deferType: CommandDeferType; 16 | requireClientPerms: PermissionsString[]; 17 | autocomplete?( 18 | intr: AutocompleteInteraction, 19 | option: AutocompleteFocusedOption 20 | ): Promise; 21 | execute(intr: CommandInteraction, data: EventData): Promise; 22 | } 23 | 24 | export enum CommandDeferType { 25 | PUBLIC = 'PUBLIC', 26 | HIDDEN = 'HIDDEN', 27 | NONE = 'NONE', 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export { Args } from './args.js'; 2 | export { Command, CommandDeferType } from './command.js'; 3 | export { ChatCommandMetadata, MessageCommandMetadata, UserCommandMetadata } from './metadata.js'; 4 | -------------------------------------------------------------------------------- /src/commands/message/index.ts: -------------------------------------------------------------------------------- 1 | export { ViewDateSent } from './view-date-sent.js'; 2 | -------------------------------------------------------------------------------- /src/commands/message/view-date-sent.ts: -------------------------------------------------------------------------------- 1 | import { MessageContextMenuCommandInteraction, PermissionsString } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | import { DateTime } from 'luxon'; 4 | 5 | import { Language } from '../../models/enum-helpers/index.js'; 6 | import { EventData } from '../../models/internal-models.js'; 7 | import { Lang } from '../../services/index.js'; 8 | import { InteractionUtils } from '../../utils/index.js'; 9 | import { Command, CommandDeferType } from '../index.js'; 10 | 11 | export class ViewDateSent implements Command { 12 | public names = [Lang.getRef('messageCommands.viewDateSent', Language.Default)]; 13 | public cooldown = new RateLimiter(1, 5000); 14 | public deferType = CommandDeferType.HIDDEN; 15 | public requireClientPerms: PermissionsString[] = []; 16 | 17 | public async execute( 18 | intr: MessageContextMenuCommandInteraction, 19 | data: EventData 20 | ): Promise { 21 | await InteractionUtils.send( 22 | intr, 23 | Lang.getEmbed('displayEmbeds.viewDateSent', data.lang, { 24 | DATE: DateTime.fromJSDate(intr.targetMessage.createdAt).toLocaleString( 25 | DateTime.DATE_HUGE 26 | ), 27 | }) 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/commands/metadata.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandType, 3 | PermissionFlagsBits, 4 | PermissionsBitField, 5 | RESTPostAPIChatInputApplicationCommandsJSONBody, 6 | RESTPostAPIContextMenuApplicationCommandsJSONBody, 7 | } from 'discord.js'; 8 | 9 | import { Args } from './index.js'; 10 | import { Language } from '../models/enum-helpers/index.js'; 11 | import { Lang } from '../services/index.js'; 12 | 13 | export const ChatCommandMetadata: { 14 | [command: string]: RESTPostAPIChatInputApplicationCommandsJSONBody; 15 | } = { 16 | DEV: { 17 | type: ApplicationCommandType.ChatInput, 18 | name: Lang.getRef('chatCommands.dev', Language.Default), 19 | name_localizations: Lang.getRefLocalizationMap('chatCommands.dev'), 20 | description: Lang.getRef('commandDescs.dev', Language.Default), 21 | description_localizations: Lang.getRefLocalizationMap('commandDescs.dev'), 22 | dm_permission: true, 23 | default_member_permissions: PermissionsBitField.resolve([ 24 | PermissionFlagsBits.Administrator, 25 | ]).toString(), 26 | options: [ 27 | { 28 | ...Args.DEV_COMMAND, 29 | required: true, 30 | }, 31 | ], 32 | }, 33 | HELP: { 34 | type: ApplicationCommandType.ChatInput, 35 | name: Lang.getRef('chatCommands.help', Language.Default), 36 | name_localizations: Lang.getRefLocalizationMap('chatCommands.help'), 37 | description: Lang.getRef('commandDescs.help', Language.Default), 38 | description_localizations: Lang.getRefLocalizationMap('commandDescs.help'), 39 | dm_permission: true, 40 | default_member_permissions: undefined, 41 | options: [ 42 | { 43 | ...Args.HELP_OPTION, 44 | required: true, 45 | }, 46 | ], 47 | }, 48 | INFO: { 49 | type: ApplicationCommandType.ChatInput, 50 | name: Lang.getRef('chatCommands.info', Language.Default), 51 | name_localizations: Lang.getRefLocalizationMap('chatCommands.info'), 52 | description: Lang.getRef('commandDescs.info', Language.Default), 53 | description_localizations: Lang.getRefLocalizationMap('commandDescs.info'), 54 | dm_permission: true, 55 | default_member_permissions: undefined, 56 | options: [ 57 | { 58 | ...Args.INFO_OPTION, 59 | required: true, 60 | }, 61 | ], 62 | }, 63 | TEST: { 64 | type: ApplicationCommandType.ChatInput, 65 | name: Lang.getRef('chatCommands.test', Language.Default), 66 | name_localizations: Lang.getRefLocalizationMap('chatCommands.test'), 67 | description: Lang.getRef('commandDescs.test', Language.Default), 68 | description_localizations: Lang.getRefLocalizationMap('commandDescs.test'), 69 | dm_permission: true, 70 | default_member_permissions: undefined, 71 | }, 72 | }; 73 | 74 | export const MessageCommandMetadata: { 75 | [command: string]: RESTPostAPIContextMenuApplicationCommandsJSONBody; 76 | } = { 77 | VIEW_DATE_SENT: { 78 | type: ApplicationCommandType.Message, 79 | name: Lang.getRef('messageCommands.viewDateSent', Language.Default), 80 | name_localizations: Lang.getRefLocalizationMap('messageCommands.viewDateSent'), 81 | default_member_permissions: undefined, 82 | dm_permission: true, 83 | }, 84 | }; 85 | 86 | export const UserCommandMetadata: { 87 | [command: string]: RESTPostAPIContextMenuApplicationCommandsJSONBody; 88 | } = { 89 | VIEW_DATE_JOINED: { 90 | type: ApplicationCommandType.User, 91 | name: Lang.getRef('userCommands.viewDateJoined', Language.Default), 92 | name_localizations: Lang.getRefLocalizationMap('userCommands.viewDateJoined'), 93 | default_member_permissions: undefined, 94 | dm_permission: true, 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /src/commands/user/index.ts: -------------------------------------------------------------------------------- 1 | export { ViewDateJoined } from './view-date-joined.js'; 2 | -------------------------------------------------------------------------------- /src/commands/user/view-date-joined.ts: -------------------------------------------------------------------------------- 1 | import { DMChannel, PermissionsString, UserContextMenuCommandInteraction } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | import { DateTime } from 'luxon'; 4 | 5 | import { Language } from '../../models/enum-helpers/index.js'; 6 | import { EventData } from '../../models/internal-models.js'; 7 | import { Lang } from '../../services/index.js'; 8 | import { InteractionUtils } from '../../utils/index.js'; 9 | import { Command, CommandDeferType } from '../index.js'; 10 | 11 | export class ViewDateJoined implements Command { 12 | public names = [Lang.getRef('userCommands.viewDateJoined', Language.Default)]; 13 | public cooldown = new RateLimiter(1, 5000); 14 | public deferType = CommandDeferType.HIDDEN; 15 | public requireClientPerms: PermissionsString[] = []; 16 | 17 | public async execute(intr: UserContextMenuCommandInteraction, data: EventData): Promise { 18 | let joinDate: Date; 19 | if (!(intr.channel instanceof DMChannel)) { 20 | let member = await intr.guild.members.fetch(intr.targetUser.id); 21 | joinDate = member.joinedAt; 22 | } else joinDate = intr.targetUser.createdAt; 23 | 24 | await InteractionUtils.send( 25 | intr, 26 | Lang.getEmbed('displayEmbeds.viewDateJoined', data.lang, { 27 | TARGET: intr.targetUser.toString(), 28 | DATE: DateTime.fromJSDate(joinDate).toLocaleString(DateTime.DATE_HUGE), 29 | }) 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/constants/discord-limits.ts: -------------------------------------------------------------------------------- 1 | export class DiscordLimits { 2 | public static readonly GUILDS_PER_SHARD = 2500; 3 | public static readonly CHANNELS_PER_GUILD = 500; 4 | public static readonly ROLES_PER_GUILD = 250; 5 | public static readonly PINS_PER_CHANNEL = 50; 6 | public static readonly ACTIVE_THREADS_PER_GUILD = 1000; 7 | public static readonly EMBEDS_PER_MESSAGE = 10; 8 | public static readonly FIELDS_PER_EMBED = 25; 9 | public static readonly CHOICES_PER_AUTOCOMPLETE = 25; 10 | public static readonly EMBED_COMBINED_LENGTH = 6000; 11 | public static readonly EMBED_TITLE_LENGTH = 256; 12 | public static readonly EMBED_DESCRIPTION_LENGTH = 4096; 13 | public static readonly EMBED_FIELD_NAME_LENGTH = 256; 14 | public static readonly EMBED_FOOTER_LENGTH = 2048; 15 | } 16 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export { DiscordLimits } from './discord-limits.js'; 2 | -------------------------------------------------------------------------------- /src/controllers/controller.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | export interface Controller { 4 | path: string; 5 | router: Router; 6 | authToken?: string; 7 | register(): void; 8 | } 9 | -------------------------------------------------------------------------------- /src/controllers/guilds-controller.ts: -------------------------------------------------------------------------------- 1 | import { ShardingManager } from 'discord.js'; 2 | import { Request, Response, Router } from 'express'; 3 | import router from 'express-promise-router'; 4 | import { createRequire } from 'node:module'; 5 | 6 | import { Controller } from './index.js'; 7 | import { GetGuildsResponse } from '../models/cluster-api/index.js'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let Config = require('../../config/config.json'); 11 | 12 | export class GuildsController implements Controller { 13 | public path = '/guilds'; 14 | public router: Router = router(); 15 | public authToken: string = Config.api.secret; 16 | 17 | constructor(private shardManager: ShardingManager) {} 18 | 19 | public register(): void { 20 | this.router.get('/', (req, res) => this.getGuilds(req, res)); 21 | } 22 | 23 | private async getGuilds(req: Request, res: Response): Promise { 24 | let guilds: string[] = [ 25 | ...new Set( 26 | ( 27 | await this.shardManager.broadcastEval(client => [...client.guilds.cache.keys()]) 28 | ).flat() 29 | ), 30 | ]; 31 | 32 | let resBody: GetGuildsResponse = { 33 | guilds, 34 | }; 35 | res.status(200).json(resBody); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export { Controller } from './controller.js'; 2 | export { GuildsController } from './guilds-controller.js'; 3 | export { ShardsController } from './shards-controller.js'; 4 | export { RootController } from './root-controller.js'; 5 | -------------------------------------------------------------------------------- /src/controllers/root-controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import router from 'express-promise-router'; 3 | 4 | import { Controller } from './index.js'; 5 | 6 | export class RootController implements Controller { 7 | public path = '/'; 8 | public router: Router = router(); 9 | 10 | public register(): void { 11 | this.router.get('/', (req, res) => this.get(req, res)); 12 | } 13 | 14 | private async get(req: Request, res: Response): Promise { 15 | res.status(200).json({ name: 'Discord Bot Cluster API', author: 'Kevin Novak' }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/controllers/shards-controller.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType, ShardingManager } from 'discord.js'; 2 | import { Request, Response, Router } from 'express'; 3 | import router from 'express-promise-router'; 4 | import { createRequire } from 'node:module'; 5 | 6 | import { Controller } from './index.js'; 7 | import { CustomClient } from '../extensions/index.js'; 8 | import { mapClass } from '../middleware/index.js'; 9 | import { 10 | GetShardsResponse, 11 | SetShardPresencesRequest, 12 | ShardInfo, 13 | ShardStats, 14 | } from '../models/cluster-api/index.js'; 15 | import { Logger } from '../services/index.js'; 16 | 17 | const require = createRequire(import.meta.url); 18 | let Config = require('../../config/config.json'); 19 | let Logs = require('../../lang/logs.json'); 20 | 21 | export class ShardsController implements Controller { 22 | public path = '/shards'; 23 | public router: Router = router(); 24 | public authToken: string = Config.api.secret; 25 | 26 | constructor(private shardManager: ShardingManager) {} 27 | 28 | public register(): void { 29 | this.router.get('/', (req, res) => this.getShards(req, res)); 30 | this.router.put('/presence', mapClass(SetShardPresencesRequest), (req, res) => 31 | this.setShardPresences(req, res) 32 | ); 33 | } 34 | 35 | private async getShards(req: Request, res: Response): Promise { 36 | let shardDatas = await Promise.all( 37 | this.shardManager.shards.map(async shard => { 38 | let shardInfo: ShardInfo = { 39 | id: shard.id, 40 | ready: shard.ready, 41 | error: false, 42 | }; 43 | 44 | try { 45 | let uptime = (await shard.fetchClientValue('uptime')) as number; 46 | shardInfo.uptimeSecs = Math.floor(uptime / 1000); 47 | } catch (error) { 48 | Logger.error(Logs.error.managerShardInfo, error); 49 | shardInfo.error = true; 50 | } 51 | 52 | return shardInfo; 53 | }) 54 | ); 55 | 56 | let stats: ShardStats = { 57 | shardCount: this.shardManager.shards.size, 58 | uptimeSecs: Math.floor(process.uptime()), 59 | }; 60 | 61 | let resBody: GetShardsResponse = { 62 | shards: shardDatas, 63 | stats, 64 | }; 65 | res.status(200).json(resBody); 66 | } 67 | 68 | private async setShardPresences(req: Request, res: Response): Promise { 69 | let reqBody: SetShardPresencesRequest = res.locals.input; 70 | 71 | await this.shardManager.broadcastEval( 72 | (client, context) => { 73 | let customClient = client as CustomClient; 74 | return customClient.setPresence(context.type, context.name, context.url); 75 | }, 76 | { context: { type: ActivityType[reqBody.type], name: reqBody.name, url: reqBody.url } } 77 | ); 78 | 79 | res.sendStatus(200); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/enums/dev-command-name.ts: -------------------------------------------------------------------------------- 1 | export enum DevCommandName { 2 | INFO = 'INFO', 3 | } 4 | -------------------------------------------------------------------------------- /src/enums/help-option.ts: -------------------------------------------------------------------------------- 1 | export enum HelpOption { 2 | CONTACT_SUPPORT = 'CONTACT_SUPPORT', 3 | COMMANDS = 'COMMANDS', 4 | } 5 | -------------------------------------------------------------------------------- /src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export { DevCommandName } from './dev-command-name.js'; 2 | export { HelpOption } from './help-option.js'; 3 | export { InfoOption } from './info-option.js'; 4 | -------------------------------------------------------------------------------- /src/enums/info-option.ts: -------------------------------------------------------------------------------- 1 | export enum InfoOption { 2 | ABOUT = 'ABOUT', 3 | TRANSLATE = 'TRANSLATE', 4 | } 5 | -------------------------------------------------------------------------------- /src/events/button-handler.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | import { createRequire } from 'node:module'; 4 | 5 | import { EventHandler } from './index.js'; 6 | import { Button, ButtonDeferType } from '../buttons/index.js'; 7 | import { EventDataService } from '../services/index.js'; 8 | import { InteractionUtils } from '../utils/index.js'; 9 | 10 | const require = createRequire(import.meta.url); 11 | let Config = require('../../config/config.json'); 12 | 13 | export class ButtonHandler implements EventHandler { 14 | private rateLimiter = new RateLimiter( 15 | Config.rateLimiting.buttons.amount, 16 | Config.rateLimiting.buttons.interval * 1000 17 | ); 18 | 19 | constructor( 20 | private buttons: Button[], 21 | private eventDataService: EventDataService 22 | ) {} 23 | 24 | public async process(intr: ButtonInteraction): Promise { 25 | // Don't respond to self, or other bots 26 | if (intr.user.id === intr.client.user?.id || intr.user.bot) { 27 | return; 28 | } 29 | 30 | // Check if user is rate limited 31 | let limited = this.rateLimiter.take(intr.user.id); 32 | if (limited) { 33 | return; 34 | } 35 | 36 | // Try to find the button the user wants 37 | let button = this.findButton(intr.customId); 38 | if (!button) { 39 | return; 40 | } 41 | 42 | if (button.requireGuild && !intr.guild) { 43 | return; 44 | } 45 | 46 | // Check if the embeds author equals the users tag 47 | if ( 48 | button.requireEmbedAuthorTag && 49 | intr.message.embeds[0]?.author?.name !== intr.user.tag 50 | ) { 51 | return; 52 | } 53 | 54 | // Defer interaction 55 | // NOTE: Anything after this point we should be responding to the interaction 56 | switch (button.deferType) { 57 | case ButtonDeferType.REPLY: { 58 | await InteractionUtils.deferReply(intr); 59 | break; 60 | } 61 | case ButtonDeferType.UPDATE: { 62 | await InteractionUtils.deferUpdate(intr); 63 | break; 64 | } 65 | } 66 | 67 | // Return if defer was unsuccessful 68 | if (button.deferType !== ButtonDeferType.NONE && !intr.deferred) { 69 | return; 70 | } 71 | 72 | // Get data from database 73 | let data = await this.eventDataService.create({ 74 | user: intr.user, 75 | channel: intr.channel, 76 | guild: intr.guild, 77 | }); 78 | 79 | // Execute the button 80 | await button.execute(intr, data); 81 | } 82 | 83 | private findButton(id: string): Button { 84 | return this.buttons.find(button => button.ids.includes(id)); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/events/command-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AutocompleteInteraction, 3 | ChatInputCommandInteraction, 4 | CommandInteraction, 5 | NewsChannel, 6 | TextChannel, 7 | ThreadChannel, 8 | } from 'discord.js'; 9 | import { RateLimiter } from 'discord.js-rate-limiter'; 10 | import { createRequire } from 'node:module'; 11 | 12 | import { EventHandler } from './index.js'; 13 | import { Command, CommandDeferType } from '../commands/index.js'; 14 | import { DiscordLimits } from '../constants/index.js'; 15 | import { EventData } from '../models/internal-models.js'; 16 | import { EventDataService, Lang, Logger } from '../services/index.js'; 17 | import { CommandUtils, InteractionUtils } from '../utils/index.js'; 18 | 19 | const require = createRequire(import.meta.url); 20 | let Config = require('../../config/config.json'); 21 | let Logs = require('../../lang/logs.json'); 22 | 23 | export class CommandHandler implements EventHandler { 24 | private rateLimiter = new RateLimiter( 25 | Config.rateLimiting.commands.amount, 26 | Config.rateLimiting.commands.interval * 1000 27 | ); 28 | 29 | constructor( 30 | public commands: Command[], 31 | private eventDataService: EventDataService 32 | ) {} 33 | 34 | public async process(intr: CommandInteraction | AutocompleteInteraction): Promise { 35 | // Don't respond to self, or other bots 36 | if (intr.user.id === intr.client.user?.id || intr.user.bot) { 37 | return; 38 | } 39 | 40 | let commandParts = 41 | intr instanceof ChatInputCommandInteraction || intr instanceof AutocompleteInteraction 42 | ? [ 43 | intr.commandName, 44 | intr.options.getSubcommandGroup(false), 45 | intr.options.getSubcommand(false), 46 | ].filter(Boolean) 47 | : [intr.commandName]; 48 | let commandName = commandParts.join(' '); 49 | 50 | // Try to find the command the user wants 51 | let command = CommandUtils.findCommand(this.commands, commandParts); 52 | if (!command) { 53 | Logger.error( 54 | Logs.error.commandNotFound 55 | .replaceAll('{INTERACTION_ID}', intr.id) 56 | .replaceAll('{COMMAND_NAME}', commandName) 57 | ); 58 | return; 59 | } 60 | 61 | if (intr instanceof AutocompleteInteraction) { 62 | if (!command.autocomplete) { 63 | Logger.error( 64 | Logs.error.autocompleteNotFound 65 | .replaceAll('{INTERACTION_ID}', intr.id) 66 | .replaceAll('{COMMAND_NAME}', commandName) 67 | ); 68 | return; 69 | } 70 | 71 | try { 72 | let option = intr.options.getFocused(true); 73 | let choices = await command.autocomplete(intr, option); 74 | await InteractionUtils.respond( 75 | intr, 76 | choices?.slice(0, DiscordLimits.CHOICES_PER_AUTOCOMPLETE) 77 | ); 78 | } catch (error) { 79 | Logger.error( 80 | intr.channel instanceof TextChannel || 81 | intr.channel instanceof NewsChannel || 82 | intr.channel instanceof ThreadChannel 83 | ? Logs.error.autocompleteGuild 84 | .replaceAll('{INTERACTION_ID}', intr.id) 85 | .replaceAll('{OPTION_NAME}', commandName) 86 | .replaceAll('{COMMAND_NAME}', commandName) 87 | .replaceAll('{USER_TAG}', intr.user.tag) 88 | .replaceAll('{USER_ID}', intr.user.id) 89 | .replaceAll('{CHANNEL_NAME}', intr.channel.name) 90 | .replaceAll('{CHANNEL_ID}', intr.channel.id) 91 | .replaceAll('{GUILD_NAME}', intr.guild?.name) 92 | .replaceAll('{GUILD_ID}', intr.guild?.id) 93 | : Logs.error.autocompleteOther 94 | .replaceAll('{INTERACTION_ID}', intr.id) 95 | .replaceAll('{OPTION_NAME}', commandName) 96 | .replaceAll('{COMMAND_NAME}', commandName) 97 | .replaceAll('{USER_TAG}', intr.user.tag) 98 | .replaceAll('{USER_ID}', intr.user.id), 99 | error 100 | ); 101 | } 102 | return; 103 | } 104 | 105 | // Check if user is rate limited 106 | let limited = this.rateLimiter.take(intr.user.id); 107 | if (limited) { 108 | return; 109 | } 110 | 111 | // Defer interaction 112 | // NOTE: Anything after this point we should be responding to the interaction 113 | switch (command.deferType) { 114 | case CommandDeferType.PUBLIC: { 115 | await InteractionUtils.deferReply(intr, false); 116 | break; 117 | } 118 | case CommandDeferType.HIDDEN: { 119 | await InteractionUtils.deferReply(intr, true); 120 | break; 121 | } 122 | } 123 | 124 | // Return if defer was unsuccessful 125 | if (command.deferType !== CommandDeferType.NONE && !intr.deferred) { 126 | return; 127 | } 128 | 129 | // Get data from database 130 | let data = await this.eventDataService.create({ 131 | user: intr.user, 132 | channel: intr.channel, 133 | guild: intr.guild, 134 | args: intr instanceof ChatInputCommandInteraction ? intr.options : undefined, 135 | }); 136 | 137 | try { 138 | // Check if interaction passes command checks 139 | let passesChecks = await CommandUtils.runChecks(command, intr, data); 140 | if (passesChecks) { 141 | // Execute the command 142 | await command.execute(intr, data); 143 | } 144 | } catch (error) { 145 | await this.sendError(intr, data); 146 | 147 | // Log command error 148 | Logger.error( 149 | intr.channel instanceof TextChannel || 150 | intr.channel instanceof NewsChannel || 151 | intr.channel instanceof ThreadChannel 152 | ? Logs.error.commandGuild 153 | .replaceAll('{INTERACTION_ID}', intr.id) 154 | .replaceAll('{COMMAND_NAME}', commandName) 155 | .replaceAll('{USER_TAG}', intr.user.tag) 156 | .replaceAll('{USER_ID}', intr.user.id) 157 | .replaceAll('{CHANNEL_NAME}', intr.channel.name) 158 | .replaceAll('{CHANNEL_ID}', intr.channel.id) 159 | .replaceAll('{GUILD_NAME}', intr.guild?.name) 160 | .replaceAll('{GUILD_ID}', intr.guild?.id) 161 | : Logs.error.commandOther 162 | .replaceAll('{INTERACTION_ID}', intr.id) 163 | .replaceAll('{COMMAND_NAME}', commandName) 164 | .replaceAll('{USER_TAG}', intr.user.tag) 165 | .replaceAll('{USER_ID}', intr.user.id), 166 | error 167 | ); 168 | } 169 | } 170 | 171 | private async sendError(intr: CommandInteraction, data: EventData): Promise { 172 | try { 173 | await InteractionUtils.send( 174 | intr, 175 | Lang.getEmbed('errorEmbeds.command', data.lang, { 176 | ERROR_CODE: intr.id, 177 | GUILD_ID: intr.guild?.id ?? Lang.getRef('other.na', data.lang), 178 | SHARD_ID: (intr.guild?.shardId ?? 0).toString(), 179 | }) 180 | ); 181 | } catch { 182 | // Ignore 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/events/event-handler.ts: -------------------------------------------------------------------------------- 1 | export interface EventHandler { 2 | process(...args: any[]): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/events/guild-join-handler.ts: -------------------------------------------------------------------------------- 1 | import { Guild } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { EventHandler } from './index.js'; 5 | import { Language } from '../models/enum-helpers/index.js'; 6 | import { EventDataService, Lang, Logger } from '../services/index.js'; 7 | import { ClientUtils, FormatUtils, MessageUtils } from '../utils/index.js'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let Logs = require('../../lang/logs.json'); 11 | 12 | export class GuildJoinHandler implements EventHandler { 13 | constructor(private eventDataService: EventDataService) {} 14 | 15 | public async process(guild: Guild): Promise { 16 | Logger.info( 17 | Logs.info.guildJoined 18 | .replaceAll('{GUILD_NAME}', guild.name) 19 | .replaceAll('{GUILD_ID}', guild.id) 20 | ); 21 | 22 | let owner = await guild.fetchOwner(); 23 | 24 | // Get data from database 25 | let data = await this.eventDataService.create({ 26 | user: owner?.user, 27 | guild, 28 | }); 29 | 30 | // Send welcome message to the server's notify channel 31 | let notifyChannel = await ClientUtils.findNotifyChannel(guild, data.langGuild); 32 | if (notifyChannel) { 33 | await MessageUtils.send( 34 | notifyChannel, 35 | Lang.getEmbed('displayEmbeds.welcome', data.langGuild, { 36 | CMD_LINK_HELP: FormatUtils.commandMention( 37 | await ClientUtils.findAppCommand( 38 | guild.client, 39 | Lang.getRef('chatCommands.help', Language.Default) 40 | ) 41 | ), 42 | }).setAuthor({ 43 | name: guild.name, 44 | iconURL: guild.iconURL(), 45 | }) 46 | ); 47 | } 48 | 49 | // Send welcome message to owner 50 | if (owner) { 51 | await MessageUtils.send( 52 | owner.user, 53 | Lang.getEmbed('displayEmbeds.welcome', data.lang, { 54 | CMD_LINK_HELP: FormatUtils.commandMention( 55 | await ClientUtils.findAppCommand( 56 | guild.client, 57 | Lang.getRef('chatCommands.help', Language.Default) 58 | ) 59 | ), 60 | }).setAuthor({ 61 | name: guild.name, 62 | iconURL: guild.iconURL(), 63 | }) 64 | ); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/events/guild-leave-handler.ts: -------------------------------------------------------------------------------- 1 | import { Guild } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { EventHandler } from './index.js'; 5 | import { Logger } from '../services/index.js'; 6 | 7 | const require = createRequire(import.meta.url); 8 | let Logs = require('../../lang/logs.json'); 9 | 10 | export class GuildLeaveHandler implements EventHandler { 11 | public async process(guild: Guild): Promise { 12 | Logger.info( 13 | Logs.info.guildLeft 14 | .replaceAll('{GUILD_NAME}', guild.name) 15 | .replaceAll('{GUILD_ID}', guild.id) 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/events/index.ts: -------------------------------------------------------------------------------- 1 | export { ButtonHandler } from './button-handler.js'; 2 | export { CommandHandler } from './command-handler.js'; 3 | export { EventHandler } from './event-handler.js'; 4 | export { GuildJoinHandler } from './guild-join-handler.js'; 5 | export { GuildLeaveHandler } from './guild-leave-handler.js'; 6 | export { ReactionHandler } from './reaction-handler.js'; 7 | export { MessageHandler } from './message-handler.js'; 8 | export { TriggerHandler } from './trigger-handler.js'; 9 | -------------------------------------------------------------------------------- /src/events/message-handler.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js'; 2 | 3 | import { EventHandler, TriggerHandler } from './index.js'; 4 | 5 | export class MessageHandler implements EventHandler { 6 | constructor(private triggerHandler: TriggerHandler) {} 7 | 8 | public async process(msg: Message): Promise { 9 | // Don't respond to system messages or self 10 | if (msg.system || msg.author.id === msg.client.user?.id) { 11 | return; 12 | } 13 | 14 | // Process trigger 15 | await this.triggerHandler.process(msg); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/events/reaction-handler.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageReaction, User } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | import { createRequire } from 'node:module'; 4 | 5 | import { EventHandler } from './index.js'; 6 | import { Reaction } from '../reactions/index.js'; 7 | import { EventDataService } from '../services/index.js'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let Config = require('../../config/config.json'); 11 | 12 | export class ReactionHandler implements EventHandler { 13 | private rateLimiter = new RateLimiter( 14 | Config.rateLimiting.reactions.amount, 15 | Config.rateLimiting.reactions.interval * 1000 16 | ); 17 | 18 | constructor( 19 | private reactions: Reaction[], 20 | private eventDataService: EventDataService 21 | ) {} 22 | 23 | public async process(msgReaction: MessageReaction, msg: Message, reactor: User): Promise { 24 | // Don't respond to self, or other bots 25 | if (reactor.id === msgReaction.client.user?.id || reactor.bot) { 26 | return; 27 | } 28 | 29 | // Check if user is rate limited 30 | let limited = this.rateLimiter.take(msg.author.id); 31 | if (limited) { 32 | return; 33 | } 34 | 35 | // Try to find the reaction the user wants 36 | let reaction = this.findReaction(msgReaction.emoji.name); 37 | if (!reaction) { 38 | return; 39 | } 40 | 41 | if (reaction.requireGuild && !msg.guild) { 42 | return; 43 | } 44 | 45 | if (reaction.requireSentByClient && msg.author.id !== msg.client.user?.id) { 46 | return; 47 | } 48 | 49 | // Check if the embeds author equals the reactors tag 50 | if (reaction.requireEmbedAuthorTag && msg.embeds[0]?.author?.name !== reactor.tag) { 51 | return; 52 | } 53 | 54 | // Get data from database 55 | let data = await this.eventDataService.create({ 56 | user: reactor, 57 | channel: msg.channel, 58 | guild: msg.guild, 59 | }); 60 | 61 | // Execute the reaction 62 | await reaction.execute(msgReaction, msg, reactor, data); 63 | } 64 | 65 | private findReaction(emoji: string): Reaction { 66 | return this.reactions.find(reaction => reaction.emoji === emoji); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/events/trigger-handler.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js'; 2 | import { RateLimiter } from 'discord.js-rate-limiter'; 3 | import { createRequire } from 'node:module'; 4 | 5 | import { EventDataService } from '../services/index.js'; 6 | import { Trigger } from '../triggers/index.js'; 7 | 8 | const require = createRequire(import.meta.url); 9 | let Config = require('../../config/config.json'); 10 | 11 | export class TriggerHandler { 12 | private rateLimiter = new RateLimiter( 13 | Config.rateLimiting.triggers.amount, 14 | Config.rateLimiting.triggers.interval * 1000 15 | ); 16 | 17 | constructor( 18 | private triggers: Trigger[], 19 | private eventDataService: EventDataService 20 | ) {} 21 | 22 | public async process(msg: Message): Promise { 23 | // Check if user is rate limited 24 | let limited = this.rateLimiter.take(msg.author.id); 25 | if (limited) { 26 | return; 27 | } 28 | 29 | // Find triggers caused by this message 30 | let triggers = this.triggers.filter(trigger => { 31 | if (trigger.requireGuild && !msg.guild) { 32 | return false; 33 | } 34 | 35 | if (!trigger.triggered(msg)) { 36 | return false; 37 | } 38 | 39 | return true; 40 | }); 41 | 42 | // If this message causes no triggers then return 43 | if (triggers.length === 0) { 44 | return; 45 | } 46 | 47 | // Get data from database 48 | let data = await this.eventDataService.create({ 49 | user: msg.author, 50 | channel: msg.channel, 51 | guild: msg.guild, 52 | }); 53 | 54 | // Execute triggers 55 | for (let trigger of triggers) { 56 | await trigger.execute(msg, data); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/extensions/custom-client.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType, Client, ClientOptions, Presence } from 'discord.js'; 2 | 3 | export class CustomClient extends Client { 4 | constructor(clientOptions: ClientOptions) { 5 | super(clientOptions); 6 | } 7 | 8 | public setPresence( 9 | type: Exclude, 10 | name: string, 11 | url: string 12 | ): Presence { 13 | return this.user?.setPresence({ 14 | activities: [ 15 | { 16 | type, 17 | name, 18 | url, 19 | }, 20 | ], 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/extensions/index.ts: -------------------------------------------------------------------------------- 1 | export { CustomClient } from './custom-client.js'; 2 | -------------------------------------------------------------------------------- /src/jobs/index.ts: -------------------------------------------------------------------------------- 1 | export { Job } from './job.js'; 2 | export { UpdateServerCountJob } from './update-server-count-job.js'; 3 | -------------------------------------------------------------------------------- /src/jobs/job.ts: -------------------------------------------------------------------------------- 1 | export abstract class Job { 2 | abstract name: string; 3 | abstract log: boolean; 4 | abstract schedule: string; 5 | runOnce = false; 6 | initialDelaySecs = 0; 7 | abstract run(): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/jobs/update-server-count-job.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType, ShardingManager } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { Job } from './index.js'; 5 | import { CustomClient } from '../extensions/index.js'; 6 | import { BotSite } from '../models/config-models.js'; 7 | import { HttpService, Lang, Logger } from '../services/index.js'; 8 | import { ShardUtils } from '../utils/index.js'; 9 | 10 | const require = createRequire(import.meta.url); 11 | let BotSites: BotSite[] = require('../../config/bot-sites.json'); 12 | let Config = require('../../config/config.json'); 13 | let Logs = require('../../lang/logs.json'); 14 | 15 | export class UpdateServerCountJob extends Job { 16 | public name = 'Update Server Count'; 17 | public schedule: string = Config.jobs.updateServerCount.schedule; 18 | public log: boolean = Config.jobs.updateServerCount.log; 19 | public runOnce: boolean = Config.jobs.updateServerCount.runOnce; 20 | public initialDelaySecs: number = Config.jobs.updateServerCount.initialDelaySecs; 21 | 22 | private botSites: BotSite[]; 23 | 24 | constructor( 25 | private shardManager: ShardingManager, 26 | private httpService: HttpService 27 | ) { 28 | super(); 29 | this.botSites = BotSites.filter(botSite => botSite.enabled); 30 | } 31 | 32 | public async run(): Promise { 33 | let serverCount = await ShardUtils.serverCount(this.shardManager); 34 | 35 | let type = ActivityType.Streaming; 36 | let name = `to ${serverCount.toLocaleString()} servers`; 37 | let url = Lang.getCom('links.stream'); 38 | 39 | await this.shardManager.broadcastEval( 40 | (client, context) => { 41 | let customClient = client as CustomClient; 42 | return customClient.setPresence(context.type, context.name, context.url); 43 | }, 44 | { context: { type, name, url } } 45 | ); 46 | 47 | Logger.info( 48 | Logs.info.updatedServerCount.replaceAll('{SERVER_COUNT}', serverCount.toLocaleString()) 49 | ); 50 | 51 | for (let botSite of this.botSites) { 52 | try { 53 | let body = JSON.parse( 54 | botSite.body.replaceAll('{{SERVER_COUNT}}', serverCount.toString()) 55 | ); 56 | let res = await this.httpService.post(botSite.url, botSite.authorization, body); 57 | 58 | if (!res.ok) { 59 | throw res; 60 | } 61 | } catch (error) { 62 | Logger.error( 63 | Logs.error.updatedServerCountSite.replaceAll('{BOT_SITE}', botSite.name), 64 | error 65 | ); 66 | continue; 67 | } 68 | 69 | Logger.info(Logs.info.updatedServerCountSite.replaceAll('{BOT_SITE}', botSite.name)); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/middleware/check-auth.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | 3 | export function checkAuth(token: string): RequestHandler { 4 | return (req, res, next) => { 5 | if (req.headers.authorization !== token) { 6 | res.sendStatus(401); 7 | return; 8 | } 9 | next(); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/middleware/handle-error.ts: -------------------------------------------------------------------------------- 1 | import { ErrorRequestHandler } from 'express'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { Logger } from '../services/index.js'; 5 | 6 | const require = createRequire(import.meta.url); 7 | let Logs = require('../../lang/logs.json'); 8 | 9 | export function handleError(): ErrorRequestHandler { 10 | return (error, req, res, _next) => { 11 | Logger.error( 12 | Logs.error.apiRequest 13 | .replaceAll('{HTTP_METHOD}', req.method) 14 | .replaceAll('{URL}', req.url), 15 | error 16 | ); 17 | res.status(500).json({ error: true, message: error.message }); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/middleware/index.ts: -------------------------------------------------------------------------------- 1 | export { checkAuth } from './check-auth.js'; 2 | export { handleError } from './handle-error.js'; 3 | export { mapClass } from './map-class.js'; 4 | -------------------------------------------------------------------------------- /src/middleware/map-class.ts: -------------------------------------------------------------------------------- 1 | import { ClassConstructor, plainToInstance } from 'class-transformer'; 2 | import { validate, ValidationError } from 'class-validator'; 3 | import { NextFunction, Request, RequestHandler, Response } from 'express'; 4 | 5 | export function mapClass(cls: ClassConstructor): RequestHandler { 6 | return async (req: Request, res: Response, next: NextFunction) => { 7 | // Map to class 8 | let obj: object = plainToInstance(cls, req.body); 9 | 10 | // Validate class 11 | let errors = await validate(obj, { 12 | skipMissingProperties: true, 13 | whitelist: true, 14 | forbidNonWhitelisted: false, 15 | forbidUnknownValues: true, 16 | }); 17 | if (errors.length > 0) { 18 | res.status(400).send({ error: true, errors: formatValidationErrors(errors) }); 19 | return; 20 | } 21 | 22 | // Set validated class to locals 23 | res.locals.input = obj; 24 | next(); 25 | }; 26 | } 27 | 28 | interface ValidationErrorLog { 29 | property: string; 30 | constraints?: { [type: string]: string }; 31 | children?: ValidationErrorLog[]; 32 | } 33 | 34 | function formatValidationErrors(errors: ValidationError[]): ValidationErrorLog[] { 35 | return errors.map(error => ({ 36 | property: error.property, 37 | constraints: error.constraints, 38 | children: error.children?.length > 0 ? formatValidationErrors(error.children) : undefined, 39 | })); 40 | } 41 | -------------------------------------------------------------------------------- /src/models/api.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from 'express'; 2 | import { createRequire } from 'node:module'; 3 | import util from 'node:util'; 4 | 5 | import { Controller } from '../controllers/index.js'; 6 | import { checkAuth, handleError } from '../middleware/index.js'; 7 | import { Logger } from '../services/index.js'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let Config = require('../../config/config.json'); 11 | let Logs = require('../../lang/logs.json'); 12 | 13 | export class Api { 14 | private app: Express; 15 | 16 | constructor(public controllers: Controller[]) { 17 | this.app = express(); 18 | this.app.use(express.json()); 19 | this.setupControllers(); 20 | this.app.use(handleError()); 21 | } 22 | 23 | public async start(): Promise { 24 | let listen = util.promisify(this.app.listen.bind(this.app)); 25 | await listen(Config.api.port); 26 | Logger.info(Logs.info.apiStarted.replaceAll('{PORT}', Config.api.port)); 27 | } 28 | 29 | private setupControllers(): void { 30 | for (let controller of this.controllers) { 31 | if (controller.authToken) { 32 | controller.router.use(checkAuth(controller.authToken)); 33 | } 34 | controller.register(); 35 | this.app.use(controller.path, controller.router); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/models/bot.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AutocompleteInteraction, 3 | ButtonInteraction, 4 | Client, 5 | CommandInteraction, 6 | Events, 7 | Guild, 8 | Interaction, 9 | Message, 10 | MessageReaction, 11 | PartialMessageReaction, 12 | PartialUser, 13 | RateLimitData, 14 | RESTEvents, 15 | User, 16 | } from 'discord.js'; 17 | import { createRequire } from 'node:module'; 18 | 19 | import { 20 | ButtonHandler, 21 | CommandHandler, 22 | GuildJoinHandler, 23 | GuildLeaveHandler, 24 | MessageHandler, 25 | ReactionHandler, 26 | } from '../events/index.js'; 27 | import { JobService, Logger } from '../services/index.js'; 28 | import { PartialUtils } from '../utils/index.js'; 29 | 30 | const require = createRequire(import.meta.url); 31 | let Config = require('../../config/config.json'); 32 | let Debug = require('../../config/debug.json'); 33 | let Logs = require('../../lang/logs.json'); 34 | 35 | export class Bot { 36 | private ready = false; 37 | 38 | constructor( 39 | private token: string, 40 | private client: Client, 41 | private guildJoinHandler: GuildJoinHandler, 42 | private guildLeaveHandler: GuildLeaveHandler, 43 | private messageHandler: MessageHandler, 44 | private commandHandler: CommandHandler, 45 | private buttonHandler: ButtonHandler, 46 | private reactionHandler: ReactionHandler, 47 | private jobService: JobService 48 | ) {} 49 | 50 | public async start(): Promise { 51 | this.registerListeners(); 52 | await this.login(this.token); 53 | } 54 | 55 | private registerListeners(): void { 56 | this.client.on(Events.ClientReady, () => this.onReady()); 57 | this.client.on(Events.ShardReady, (shardId: number, unavailableGuilds: Set) => 58 | this.onShardReady(shardId, unavailableGuilds) 59 | ); 60 | this.client.on(Events.GuildCreate, (guild: Guild) => this.onGuildJoin(guild)); 61 | this.client.on(Events.GuildDelete, (guild: Guild) => this.onGuildLeave(guild)); 62 | this.client.on(Events.MessageCreate, (msg: Message) => this.onMessage(msg)); 63 | this.client.on(Events.InteractionCreate, (intr: Interaction) => this.onInteraction(intr)); 64 | this.client.on( 65 | Events.MessageReactionAdd, 66 | (messageReaction: MessageReaction | PartialMessageReaction, user: User | PartialUser) => 67 | this.onReaction(messageReaction, user) 68 | ); 69 | this.client.rest.on(RESTEvents.RateLimited, (rateLimitData: RateLimitData) => 70 | this.onRateLimit(rateLimitData) 71 | ); 72 | } 73 | 74 | private async login(token: string): Promise { 75 | try { 76 | await this.client.login(token); 77 | } catch (error) { 78 | Logger.error(Logs.error.clientLogin, error); 79 | return; 80 | } 81 | } 82 | 83 | private async onReady(): Promise { 84 | let userTag = this.client.user?.tag; 85 | Logger.info(Logs.info.clientLogin.replaceAll('{USER_TAG}', userTag)); 86 | 87 | if (!Debug.dummyMode.enabled) { 88 | this.jobService.start(); 89 | } 90 | 91 | this.ready = true; 92 | Logger.info(Logs.info.clientReady); 93 | } 94 | 95 | private onShardReady(shardId: number, _unavailableGuilds: Set): void { 96 | Logger.setShardId(shardId); 97 | } 98 | 99 | private async onGuildJoin(guild: Guild): Promise { 100 | if (!this.ready || Debug.dummyMode.enabled) { 101 | return; 102 | } 103 | 104 | try { 105 | await this.guildJoinHandler.process(guild); 106 | } catch (error) { 107 | Logger.error(Logs.error.guildJoin, error); 108 | } 109 | } 110 | 111 | private async onGuildLeave(guild: Guild): Promise { 112 | if (!this.ready || Debug.dummyMode.enabled) { 113 | return; 114 | } 115 | 116 | try { 117 | await this.guildLeaveHandler.process(guild); 118 | } catch (error) { 119 | Logger.error(Logs.error.guildLeave, error); 120 | } 121 | } 122 | 123 | private async onMessage(msg: Message): Promise { 124 | if ( 125 | !this.ready || 126 | (Debug.dummyMode.enabled && !Debug.dummyMode.whitelist.includes(msg.author.id)) 127 | ) { 128 | return; 129 | } 130 | 131 | try { 132 | msg = await PartialUtils.fillMessage(msg); 133 | if (!msg) { 134 | return; 135 | } 136 | 137 | await this.messageHandler.process(msg); 138 | } catch (error) { 139 | Logger.error(Logs.error.message, error); 140 | } 141 | } 142 | 143 | private async onInteraction(intr: Interaction): Promise { 144 | if ( 145 | !this.ready || 146 | (Debug.dummyMode.enabled && !Debug.dummyMode.whitelist.includes(intr.user.id)) 147 | ) { 148 | return; 149 | } 150 | 151 | if (intr instanceof CommandInteraction || intr instanceof AutocompleteInteraction) { 152 | try { 153 | await this.commandHandler.process(intr); 154 | } catch (error) { 155 | Logger.error(Logs.error.command, error); 156 | } 157 | } else if (intr instanceof ButtonInteraction) { 158 | try { 159 | await this.buttonHandler.process(intr); 160 | } catch (error) { 161 | Logger.error(Logs.error.button, error); 162 | } 163 | } 164 | } 165 | 166 | private async onReaction( 167 | msgReaction: MessageReaction | PartialMessageReaction, 168 | reactor: User | PartialUser 169 | ): Promise { 170 | if ( 171 | !this.ready || 172 | (Debug.dummyMode.enabled && !Debug.dummyMode.whitelist.includes(reactor.id)) 173 | ) { 174 | return; 175 | } 176 | 177 | try { 178 | msgReaction = await PartialUtils.fillReaction(msgReaction); 179 | if (!msgReaction) { 180 | return; 181 | } 182 | 183 | reactor = await PartialUtils.fillUser(reactor); 184 | if (!reactor) { 185 | return; 186 | } 187 | 188 | await this.reactionHandler.process( 189 | msgReaction, 190 | msgReaction.message as Message, 191 | reactor 192 | ); 193 | } catch (error) { 194 | Logger.error(Logs.error.reaction, error); 195 | } 196 | } 197 | 198 | private async onRateLimit(rateLimitData: RateLimitData): Promise { 199 | if (rateLimitData.timeToReset >= Config.logging.rateLimit.minTimeout * 1000) { 200 | Logger.error(Logs.error.apiRateLimit, rateLimitData); 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/models/cluster-api/guilds.ts: -------------------------------------------------------------------------------- 1 | export interface GetGuildsResponse { 2 | guilds: string[]; 3 | } 4 | -------------------------------------------------------------------------------- /src/models/cluster-api/index.ts: -------------------------------------------------------------------------------- 1 | export { GetGuildsResponse } from './guilds.js'; 2 | export { GetShardsResponse, ShardInfo, ShardStats, SetShardPresencesRequest } from './shards.js'; 3 | -------------------------------------------------------------------------------- /src/models/cluster-api/shards.ts: -------------------------------------------------------------------------------- 1 | import { IsDefined, IsEnum, IsString, IsUrl, Length } from 'class-validator'; 2 | import { ActivityType } from 'discord.js'; 3 | 4 | export interface GetShardsResponse { 5 | shards: ShardInfo[]; 6 | stats: ShardStats; 7 | } 8 | 9 | export interface ShardStats { 10 | shardCount: number; 11 | uptimeSecs: number; 12 | } 13 | 14 | export interface ShardInfo { 15 | id: number; 16 | ready: boolean; 17 | error: boolean; 18 | uptimeSecs?: number; 19 | } 20 | 21 | export class SetShardPresencesRequest { 22 | @IsDefined() 23 | @IsEnum(ActivityType) 24 | type: string; 25 | 26 | @IsDefined() 27 | @IsString() 28 | @Length(1, 128) 29 | name: string; 30 | 31 | @IsDefined() 32 | @IsUrl() 33 | url: string; 34 | } 35 | -------------------------------------------------------------------------------- /src/models/config-models.ts: -------------------------------------------------------------------------------- 1 | export interface BotSite { 2 | name: string; 3 | enabled: boolean; 4 | url: string; 5 | authorization: string; 6 | body: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/models/enum-helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { Language } from './language.js'; 2 | export { Permission } from './permission.js'; 3 | -------------------------------------------------------------------------------- /src/models/enum-helpers/language.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from 'discord.js'; 2 | 3 | interface LanguageData { 4 | englishName: string; 5 | nativeName: string; 6 | } 7 | 8 | export class Language { 9 | public static Default = Locale.EnglishUS; 10 | public static Enabled: Locale[] = [Locale.EnglishUS, Locale.EnglishGB]; 11 | 12 | // See https://discord.com/developers/docs/reference#locales 13 | public static Data: { 14 | [key in Locale]: LanguageData; 15 | } = { 16 | bg: { englishName: 'Bulgarian', nativeName: 'български' }, 17 | cs: { englishName: 'Czech', nativeName: 'Čeština' }, 18 | da: { englishName: 'Danish', nativeName: 'Dansk' }, 19 | de: { englishName: 'German', nativeName: 'Deutsch' }, 20 | el: { englishName: 'Greek', nativeName: 'Ελληνικά' }, 21 | 'en-GB': { englishName: 'English, UK', nativeName: 'English, UK' }, 22 | 'en-US': { englishName: 'English, US', nativeName: 'English, US' }, 23 | 'es-419': { englishName: 'Spanish, LATAM', nativeName: 'Español, LATAM' }, 24 | 'es-ES': { englishName: 'Spanish', nativeName: 'Español' }, 25 | fi: { englishName: 'Finnish', nativeName: 'Suomi' }, 26 | fr: { englishName: 'French', nativeName: 'Français' }, 27 | hi: { englishName: 'Hindi', nativeName: 'हिन्दी' }, 28 | hr: { englishName: 'Croatian', nativeName: 'Hrvatski' }, 29 | hu: { englishName: 'Hungarian', nativeName: 'Magyar' }, 30 | id: { englishName: 'Indonesian', nativeName: 'Bahasa Indonesia' }, 31 | it: { englishName: 'Italian', nativeName: 'Italiano' }, 32 | ja: { englishName: 'Japanese', nativeName: '日本語' }, 33 | ko: { englishName: 'Korean', nativeName: '한국어' }, 34 | lt: { englishName: 'Lithuanian', nativeName: 'Lietuviškai' }, 35 | nl: { englishName: 'Dutch', nativeName: 'Nederlands' }, 36 | no: { englishName: 'Norwegian', nativeName: 'Norsk' }, 37 | pl: { englishName: 'Polish', nativeName: 'Polski' }, 38 | 'pt-BR': { englishName: 'Portuguese, Brazilian', nativeName: 'Português do Brasil' }, 39 | ro: { englishName: 'Romanian, Romania', nativeName: 'Română' }, 40 | ru: { englishName: 'Russian', nativeName: 'Pусский' }, 41 | 'sv-SE': { englishName: 'Swedish', nativeName: 'Svenska' }, 42 | th: { englishName: 'Thai', nativeName: 'ไทย' }, 43 | tr: { englishName: 'Turkish', nativeName: 'Türkçe' }, 44 | uk: { englishName: 'Ukrainian', nativeName: 'Українська' }, 45 | vi: { englishName: 'Vietnamese', nativeName: 'Tiếng Việt' }, 46 | 'zh-CN': { englishName: 'Chinese, China', nativeName: '中文' }, 47 | 'zh-TW': { englishName: 'Chinese, Taiwan', nativeName: '繁體中文' }, 48 | }; 49 | 50 | public static find(input: string, enabled: boolean): Locale { 51 | return this.findMultiple(input, enabled, 1)[0]; 52 | } 53 | 54 | public static findMultiple( 55 | input: string, 56 | enabled: boolean, 57 | limit: number = Number.MAX_VALUE 58 | ): Locale[] { 59 | let langCodes = enabled ? this.Enabled : Object.values(Locale).sort(); 60 | let search = input.toLowerCase(); 61 | let found = new Set(); 62 | // Exact match 63 | if (found.size < limit) 64 | langCodes 65 | .filter(langCode => langCode.toLowerCase() === search) 66 | .forEach(langCode => found.add(langCode)); 67 | if (found.size < limit) 68 | langCodes 69 | .filter(langCode => this.Data[langCode].nativeName.toLowerCase() === search) 70 | .forEach(langCode => found.add(langCode)); 71 | if (found.size < limit) 72 | langCodes 73 | .filter(langCode => this.Data[langCode].nativeName.toLowerCase() === search) 74 | .forEach(langCode => found.add(langCode)); 75 | if (found.size < limit) 76 | langCodes 77 | .filter(langCode => this.Data[langCode].englishName.toLowerCase() === search) 78 | .forEach(langCode => found.add(langCode)); 79 | // Starts with search term 80 | if (found.size < limit) 81 | langCodes 82 | .filter(langCode => langCode.toLowerCase().startsWith(search)) 83 | .forEach(langCode => found.add(langCode)); 84 | if (found.size < limit) 85 | langCodes 86 | .filter(langCode => this.Data[langCode].nativeName.toLowerCase().startsWith(search)) 87 | .forEach(langCode => found.add(langCode)); 88 | if (found.size < limit) 89 | langCodes 90 | .filter(langCode => 91 | this.Data[langCode].englishName.toLowerCase().startsWith(search) 92 | ) 93 | .forEach(langCode => found.add(langCode)); 94 | // Includes search term 95 | if (found.size < limit) 96 | langCodes 97 | .filter(langCode => langCode.toLowerCase().startsWith(search)) 98 | .forEach(langCode => found.add(langCode)); 99 | if (found.size < limit) 100 | langCodes 101 | .filter(langCode => this.Data[langCode].nativeName.toLowerCase().startsWith(search)) 102 | .forEach(langCode => found.add(langCode)); 103 | if (found.size < limit) 104 | langCodes 105 | .filter(langCode => 106 | this.Data[langCode].englishName.toLowerCase().startsWith(search) 107 | ) 108 | .forEach(langCode => found.add(langCode)); 109 | return [...found]; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/models/internal-models.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from 'discord.js'; 2 | 3 | // This class is used to store and pass data along in events 4 | export class EventData { 5 | // TODO: Add any data you want to store 6 | constructor( 7 | // Event language 8 | public lang: Locale, 9 | // Guild language 10 | public langGuild: Locale 11 | ) {} 12 | } 13 | -------------------------------------------------------------------------------- /src/models/manager.ts: -------------------------------------------------------------------------------- 1 | import { Shard, ShardingManager } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | 4 | import { JobService, Logger } from '../services/index.js'; 5 | 6 | const require = createRequire(import.meta.url); 7 | let Config = require('../../config/config.json'); 8 | let Debug = require('../../config/debug.json'); 9 | let Logs = require('../../lang/logs.json'); 10 | 11 | export class Manager { 12 | constructor( 13 | private shardManager: ShardingManager, 14 | private jobService: JobService 15 | ) {} 16 | 17 | public async start(): Promise { 18 | this.registerListeners(); 19 | 20 | let shardList = this.shardManager.shardList as number[]; 21 | 22 | try { 23 | Logger.info( 24 | Logs.info.managerSpawningShards 25 | .replaceAll('{SHARD_COUNT}', shardList.length.toLocaleString()) 26 | .replaceAll('{SHARD_LIST}', shardList.join(', ')) 27 | ); 28 | await this.shardManager.spawn({ 29 | amount: this.shardManager.totalShards, 30 | delay: Config.sharding.spawnDelay * 1000, 31 | timeout: Config.sharding.spawnTimeout * 1000, 32 | }); 33 | Logger.info(Logs.info.managerAllShardsSpawned); 34 | } catch (error) { 35 | Logger.error(Logs.error.managerSpawningShards, error); 36 | return; 37 | } 38 | 39 | if (Debug.dummyMode.enabled) { 40 | return; 41 | } 42 | 43 | this.jobService.start(); 44 | } 45 | 46 | private registerListeners(): void { 47 | this.shardManager.on('shardCreate', shard => this.onShardCreate(shard)); 48 | } 49 | 50 | private onShardCreate(shard: Shard): void { 51 | Logger.info(Logs.info.managerLaunchedShard.replaceAll('{SHARD_ID}', shard.id.toString())); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/models/master-api/clusters.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { 3 | IsDefined, 4 | IsInt, 5 | IsPositive, 6 | IsString, 7 | IsUrl, 8 | Length, 9 | ValidateNested, 10 | } from 'class-validator'; 11 | 12 | export class Callback { 13 | @IsDefined() 14 | @IsUrl({ require_tld: false }) 15 | url: string; 16 | 17 | @IsDefined() 18 | @IsString() 19 | @Length(5, 2000) 20 | token: string; 21 | } 22 | 23 | export class RegisterClusterRequest { 24 | @IsDefined() 25 | @IsInt() 26 | @IsPositive() 27 | shardCount: number; 28 | 29 | @IsDefined() 30 | @ValidateNested() 31 | @Type(() => Callback) 32 | callback: Callback; 33 | } 34 | 35 | export interface RegisterClusterResponse { 36 | id: string; 37 | } 38 | 39 | export interface LoginClusterResponse { 40 | shardList: number[]; 41 | totalShards: number; 42 | } 43 | -------------------------------------------------------------------------------- /src/models/master-api/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | RegisterClusterRequest, 3 | RegisterClusterResponse, 4 | LoginClusterResponse, 5 | } from './clusters.js'; 6 | -------------------------------------------------------------------------------- /src/reactions/index.ts: -------------------------------------------------------------------------------- 1 | export { Reaction } from './reaction.js'; 2 | -------------------------------------------------------------------------------- /src/reactions/reaction.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageReaction, User } from 'discord.js'; 2 | 3 | import { EventData } from '../models/internal-models.js'; 4 | 5 | export interface Reaction { 6 | emoji: string; 7 | requireGuild: boolean; 8 | requireSentByClient: boolean; 9 | requireEmbedAuthorTag: boolean; 10 | execute( 11 | msgReaction: MessageReaction, 12 | msg: Message, 13 | reactor: User, 14 | data: EventData 15 | ): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/services/command-registration-service.ts: -------------------------------------------------------------------------------- 1 | import { REST } from '@discordjs/rest'; 2 | import { 3 | APIApplicationCommand, 4 | RESTGetAPIApplicationCommandsResult, 5 | RESTPatchAPIApplicationCommandJSONBody, 6 | RESTPostAPIApplicationCommandsJSONBody, 7 | Routes, 8 | } from 'discord.js'; 9 | import { createRequire } from 'node:module'; 10 | 11 | import { Logger } from './logger.js'; 12 | 13 | const require = createRequire(import.meta.url); 14 | let Config = require('../../config/config.json'); 15 | let Logs = require('../../lang/logs.json'); 16 | 17 | export class CommandRegistrationService { 18 | constructor(private rest: REST) {} 19 | 20 | public async process( 21 | localCmds: RESTPostAPIApplicationCommandsJSONBody[], 22 | args: string[] 23 | ): Promise { 24 | let remoteCmds = (await this.rest.get( 25 | Routes.applicationCommands(Config.client.id) 26 | )) as RESTGetAPIApplicationCommandsResult; 27 | 28 | let localCmdsOnRemote = localCmds.filter(localCmd => 29 | remoteCmds.some(remoteCmd => remoteCmd.name === localCmd.name) 30 | ); 31 | let localCmdsOnly = localCmds.filter( 32 | localCmd => !remoteCmds.some(remoteCmd => remoteCmd.name === localCmd.name) 33 | ); 34 | let remoteCmdsOnly = remoteCmds.filter( 35 | remoteCmd => !localCmds.some(localCmd => localCmd.name === remoteCmd.name) 36 | ); 37 | 38 | switch (args[3]) { 39 | case 'view': { 40 | Logger.info( 41 | Logs.info.commandActionView 42 | .replaceAll( 43 | '{LOCAL_AND_REMOTE_LIST}', 44 | this.formatCommandList(localCmdsOnRemote) 45 | ) 46 | .replaceAll('{LOCAL_ONLY_LIST}', this.formatCommandList(localCmdsOnly)) 47 | .replaceAll('{REMOTE_ONLY_LIST}', this.formatCommandList(remoteCmdsOnly)) 48 | ); 49 | return; 50 | } 51 | case 'register': { 52 | if (localCmdsOnly.length > 0) { 53 | Logger.info( 54 | Logs.info.commandActionCreating.replaceAll( 55 | '{COMMAND_LIST}', 56 | this.formatCommandList(localCmdsOnly) 57 | ) 58 | ); 59 | for (let localCmd of localCmdsOnly) { 60 | await this.rest.post(Routes.applicationCommands(Config.client.id), { 61 | body: localCmd, 62 | }); 63 | } 64 | Logger.info(Logs.info.commandActionCreated); 65 | } 66 | 67 | if (localCmdsOnRemote.length > 0) { 68 | Logger.info( 69 | Logs.info.commandActionUpdating.replaceAll( 70 | '{COMMAND_LIST}', 71 | this.formatCommandList(localCmdsOnRemote) 72 | ) 73 | ); 74 | for (let localCmd of localCmdsOnRemote) { 75 | await this.rest.post(Routes.applicationCommands(Config.client.id), { 76 | body: localCmd, 77 | }); 78 | } 79 | Logger.info(Logs.info.commandActionUpdated); 80 | } 81 | 82 | return; 83 | } 84 | case 'rename': { 85 | let oldName = args[4]; 86 | let newName = args[5]; 87 | if (!(oldName && newName)) { 88 | Logger.error(Logs.error.commandActionRenameMissingArg); 89 | return; 90 | } 91 | 92 | let remoteCmd = remoteCmds.find(remoteCmd => remoteCmd.name == oldName); 93 | if (!remoteCmd) { 94 | Logger.error( 95 | Logs.error.commandActionNotFound.replaceAll('{COMMAND_NAME}', oldName) 96 | ); 97 | return; 98 | } 99 | 100 | Logger.info( 101 | Logs.info.commandActionRenaming 102 | .replaceAll('{OLD_COMMAND_NAME}', remoteCmd.name) 103 | .replaceAll('{NEW_COMMAND_NAME}', newName) 104 | ); 105 | let body: RESTPatchAPIApplicationCommandJSONBody = { 106 | name: newName, 107 | }; 108 | await this.rest.patch(Routes.applicationCommand(Config.client.id, remoteCmd.id), { 109 | body, 110 | }); 111 | Logger.info(Logs.info.commandActionRenamed); 112 | return; 113 | } 114 | case 'delete': { 115 | let name = args[4]; 116 | if (!name) { 117 | Logger.error(Logs.error.commandActionDeleteMissingArg); 118 | return; 119 | } 120 | 121 | let remoteCmd = remoteCmds.find(remoteCmd => remoteCmd.name == name); 122 | if (!remoteCmd) { 123 | Logger.error( 124 | Logs.error.commandActionNotFound.replaceAll('{COMMAND_NAME}', name) 125 | ); 126 | return; 127 | } 128 | 129 | Logger.info( 130 | Logs.info.commandActionDeleting.replaceAll('{COMMAND_NAME}', remoteCmd.name) 131 | ); 132 | await this.rest.delete(Routes.applicationCommand(Config.client.id, remoteCmd.id)); 133 | Logger.info(Logs.info.commandActionDeleted); 134 | return; 135 | } 136 | case 'clear': { 137 | Logger.info( 138 | Logs.info.commandActionClearing.replaceAll( 139 | '{COMMAND_LIST}', 140 | this.formatCommandList(remoteCmds) 141 | ) 142 | ); 143 | await this.rest.put(Routes.applicationCommands(Config.client.id), { body: [] }); 144 | Logger.info(Logs.info.commandActionCleared); 145 | return; 146 | } 147 | } 148 | } 149 | 150 | private formatCommandList( 151 | cmds: RESTPostAPIApplicationCommandsJSONBody[] | APIApplicationCommand[] 152 | ): string { 153 | return cmds.length > 0 154 | ? cmds.map((cmd: { name: string }) => `'${cmd.name}'`).join(', ') 155 | : 'N/A'; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/services/event-data-service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Channel, 3 | CommandInteractionOptionResolver, 4 | Guild, 5 | PartialDMChannel, 6 | User, 7 | } from 'discord.js'; 8 | 9 | import { Language } from '../models/enum-helpers/language.js'; 10 | import { EventData } from '../models/internal-models.js'; 11 | 12 | export class EventDataService { 13 | public async create( 14 | options: { 15 | user?: User; 16 | channel?: Channel | PartialDMChannel; 17 | guild?: Guild; 18 | args?: Omit; 19 | } = {} 20 | ): Promise { 21 | // TODO: Retrieve any data you want to pass along in events 22 | 23 | // Event language 24 | let lang = 25 | options.guild?.preferredLocale && 26 | Language.Enabled.includes(options.guild.preferredLocale) 27 | ? options.guild.preferredLocale 28 | : Language.Default; 29 | 30 | // Guild language 31 | let langGuild = 32 | options.guild?.preferredLocale && 33 | Language.Enabled.includes(options.guild.preferredLocale) 34 | ? options.guild.preferredLocale 35 | : Language.Default; 36 | 37 | return new EventData(lang, langGuild); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/services/http-service.ts: -------------------------------------------------------------------------------- 1 | import fetch, { Response } from 'node-fetch'; 2 | import { URL } from 'node:url'; 3 | 4 | export class HttpService { 5 | public async get(url: string | URL, authorization: string): Promise { 6 | return await fetch(url.toString(), { 7 | method: 'get', 8 | headers: { 9 | Authorization: authorization, 10 | Accept: 'application/json', 11 | }, 12 | }); 13 | } 14 | 15 | public async post(url: string | URL, authorization: string, body?: object): Promise { 16 | return await fetch(url.toString(), { 17 | method: 'post', 18 | headers: { 19 | Authorization: authorization, 20 | 'Content-Type': 'application/json', 21 | Accept: 'application/json', 22 | }, 23 | body: body ? JSON.stringify(body) : undefined, 24 | }); 25 | } 26 | 27 | public async put(url: string | URL, authorization: string, body?: object): Promise { 28 | return await fetch(url.toString(), { 29 | method: 'put', 30 | headers: { 31 | Authorization: authorization, 32 | 'Content-Type': 'application/json', 33 | Accept: 'application/json', 34 | }, 35 | body: body ? JSON.stringify(body) : undefined, 36 | }); 37 | } 38 | 39 | public async delete( 40 | url: string | URL, 41 | authorization: string, 42 | body?: object 43 | ): Promise { 44 | return await fetch(url.toString(), { 45 | method: 'delete', 46 | headers: { 47 | Authorization: authorization, 48 | 'Content-Type': 'application/json', 49 | Accept: 'application/json', 50 | }, 51 | body: body ? JSON.stringify(body) : undefined, 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export { CommandRegistrationService } from './command-registration-service.js'; 2 | export { EventDataService } from './event-data-service.js'; 3 | export { HttpService } from './http-service.js'; 4 | export { JobService } from './job-service.js'; 5 | export { Lang } from './lang.js'; 6 | export { Logger } from './logger.js'; 7 | export { MasterApiService } from './master-api-service.js'; 8 | -------------------------------------------------------------------------------- /src/services/job-service.ts: -------------------------------------------------------------------------------- 1 | import parser from 'cron-parser'; 2 | import { DateTime } from 'luxon'; 3 | import schedule from 'node-schedule'; 4 | import { createRequire } from 'node:module'; 5 | 6 | import { Logger } from './index.js'; 7 | import { Job } from '../jobs/index.js'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let Logs = require('../../lang/logs.json'); 11 | 12 | export class JobService { 13 | constructor(private jobs: Job[]) {} 14 | 15 | public start(): void { 16 | for (let job of this.jobs) { 17 | let jobSchedule = job.runOnce 18 | ? parser 19 | .parseExpression(job.schedule, { 20 | currentDate: DateTime.now() 21 | .plus({ seconds: job.initialDelaySecs }) 22 | .toJSDate(), 23 | }) 24 | .next() 25 | .toDate() 26 | : { 27 | start: DateTime.now().plus({ seconds: job.initialDelaySecs }).toJSDate(), 28 | rule: job.schedule, 29 | }; 30 | 31 | schedule.scheduleJob(jobSchedule, async () => { 32 | try { 33 | if (job.log) { 34 | Logger.info(Logs.info.jobRun.replaceAll('{JOB}', job.name)); 35 | } 36 | 37 | await job.run(); 38 | 39 | if (job.log) { 40 | Logger.info(Logs.info.jobCompleted.replaceAll('{JOB}', job.name)); 41 | } 42 | } catch (error) { 43 | Logger.error(Logs.error.job.replaceAll('{JOB}', job.name), error); 44 | } 45 | }); 46 | Logger.info( 47 | Logs.info.jobScheduled 48 | .replaceAll('{JOB}', job.name) 49 | .replaceAll('{SCHEDULE}', job.schedule) 50 | ); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/services/lang.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Locale, LocalizationMap, resolveColor } from 'discord.js'; 2 | import { Linguini, TypeMapper, TypeMappers, Utils } from 'linguini'; 3 | import path, { dirname } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | 6 | import { Language } from '../models/enum-helpers/index.js'; 7 | 8 | export class Lang { 9 | private static linguini = new Linguini( 10 | path.resolve(dirname(fileURLToPath(import.meta.url)), '../../lang'), 11 | 'lang' 12 | ); 13 | 14 | public static getEmbed( 15 | location: string, 16 | langCode: Locale, 17 | variables?: { [name: string]: string } 18 | ): EmbedBuilder { 19 | return ( 20 | this.linguini.get(location, langCode, this.embedTm, variables) ?? 21 | this.linguini.get(location, Language.Default, this.embedTm, variables) 22 | ); 23 | } 24 | 25 | public static getRegex(location: string, langCode: Locale): RegExp { 26 | return ( 27 | this.linguini.get(location, langCode, TypeMappers.RegExp) ?? 28 | this.linguini.get(location, Language.Default, TypeMappers.RegExp) 29 | ); 30 | } 31 | 32 | public static getRef( 33 | location: string, 34 | langCode: Locale, 35 | variables?: { [name: string]: string } 36 | ): string { 37 | return ( 38 | this.linguini.getRef(location, langCode, variables) ?? 39 | this.linguini.getRef(location, Language.Default, variables) 40 | ); 41 | } 42 | 43 | public static getRefLocalizationMap( 44 | location: string, 45 | variables?: { [name: string]: string } 46 | ): LocalizationMap { 47 | let obj = {}; 48 | for (let langCode of Language.Enabled) { 49 | obj[langCode] = this.getRef(location, langCode, variables); 50 | } 51 | return obj; 52 | } 53 | 54 | public static getCom(location: string, variables?: { [name: string]: string }): string { 55 | return this.linguini.getCom(location, variables); 56 | } 57 | 58 | private static embedTm: TypeMapper = (jsonValue: any) => { 59 | return new EmbedBuilder({ 60 | author: jsonValue.author, 61 | title: Utils.join(jsonValue.title, '\n'), 62 | url: jsonValue.url, 63 | thumbnail: { 64 | url: jsonValue.thumbnail, 65 | }, 66 | description: Utils.join(jsonValue.description, '\n'), 67 | fields: jsonValue.fields?.map(field => ({ 68 | name: Utils.join(field.name, '\n'), 69 | value: Utils.join(field.value, '\n'), 70 | inline: field.inline ? field.inline : false, 71 | })), 72 | image: { 73 | url: jsonValue.image, 74 | }, 75 | footer: { 76 | text: Utils.join(jsonValue.footer?.text, '\n'), 77 | iconURL: jsonValue.footer?.icon, 78 | }, 79 | timestamp: jsonValue.timestamp ? Date.now() : undefined, 80 | color: resolveColor(jsonValue.color ?? Lang.getCom('colors.default')), 81 | }); 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/services/logger.ts: -------------------------------------------------------------------------------- 1 | import { DiscordAPIError } from 'discord.js'; 2 | import { Response } from 'node-fetch'; 3 | import { createRequire } from 'node:module'; 4 | import pino from 'pino'; 5 | 6 | const require = createRequire(import.meta.url); 7 | let Config = require('../../config/config.json'); 8 | 9 | let logger = pino( 10 | { 11 | formatters: { 12 | level: label => { 13 | return { level: label }; 14 | }, 15 | }, 16 | }, 17 | Config.logging.pretty 18 | ? pino.transport({ 19 | target: 'pino-pretty', 20 | options: { 21 | colorize: true, 22 | ignore: 'pid,hostname', 23 | translateTime: 'yyyy-mm-dd HH:MM:ss.l', 24 | }, 25 | }) 26 | : undefined 27 | ); 28 | 29 | export class Logger { 30 | private static shardId: number; 31 | 32 | public static info(message: string, obj?: any): void { 33 | if (obj) { 34 | logger.info(obj, message); 35 | } else { 36 | logger.info(message); 37 | } 38 | } 39 | 40 | public static warn(message: string, obj?: any): void { 41 | if (obj) { 42 | logger.warn(obj, message); 43 | } else { 44 | logger.warn(message); 45 | } 46 | } 47 | 48 | public static async error(message: string, obj?: any): Promise { 49 | // Log just a message if no error object 50 | if (!obj) { 51 | logger.error(message); 52 | return; 53 | } 54 | 55 | // Otherwise log details about the error 56 | if (typeof obj === 'string') { 57 | logger 58 | .child({ 59 | message: obj, 60 | }) 61 | .error(message); 62 | } else if (obj instanceof Response) { 63 | let resText: string; 64 | try { 65 | resText = await obj.text(); 66 | } catch { 67 | // Ignore 68 | } 69 | logger 70 | .child({ 71 | path: obj.url, 72 | statusCode: obj.status, 73 | statusName: obj.statusText, 74 | headers: obj.headers.raw(), 75 | body: resText, 76 | }) 77 | .error(message); 78 | } else if (obj instanceof DiscordAPIError) { 79 | logger 80 | .child({ 81 | message: obj.message, 82 | code: obj.code, 83 | statusCode: obj.status, 84 | method: obj.method, 85 | url: obj.url, 86 | stack: obj.stack, 87 | }) 88 | .error(message); 89 | } else { 90 | logger.error(obj, message); 91 | } 92 | } 93 | 94 | public static setShardId(shardId: number): void { 95 | if (this.shardId !== shardId) { 96 | this.shardId = shardId; 97 | logger = logger.child({ shardId }); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/services/master-api-service.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'node:module'; 2 | import { URL } from 'node:url'; 3 | 4 | import { HttpService } from './index.js'; 5 | import { 6 | LoginClusterResponse, 7 | RegisterClusterRequest, 8 | RegisterClusterResponse, 9 | } from '../models/master-api/index.js'; 10 | 11 | const require = createRequire(import.meta.url); 12 | let Config = require('../../config/config.json'); 13 | 14 | export class MasterApiService { 15 | private clusterId: string; 16 | 17 | constructor(private httpService: HttpService) {} 18 | 19 | public async register(): Promise { 20 | let reqBody: RegisterClusterRequest = { 21 | shardCount: Config.clustering.shardCount, 22 | callback: { 23 | url: Config.clustering.callbackUrl, 24 | token: Config.api.secret, 25 | }, 26 | }; 27 | 28 | let res = await this.httpService.post( 29 | new URL('/clusters', Config.clustering.masterApi.url), 30 | Config.clustering.masterApi.token, 31 | reqBody 32 | ); 33 | 34 | if (!res.ok) { 35 | throw res; 36 | } 37 | 38 | let resBody = (await res.json()) as RegisterClusterResponse; 39 | this.clusterId = resBody.id; 40 | } 41 | 42 | public async login(): Promise { 43 | let res = await this.httpService.put( 44 | new URL(`/clusters/${this.clusterId}/login`, Config.clustering.masterApi.url), 45 | Config.clustering.masterApi.token 46 | ); 47 | 48 | if (!res.ok) { 49 | throw res; 50 | } 51 | 52 | return (await res.json()) as LoginClusterResponse; 53 | } 54 | 55 | public async ready(): Promise { 56 | let res = await this.httpService.put( 57 | new URL(`/clusters/${this.clusterId}/ready`, Config.clustering.masterApi.url), 58 | Config.clustering.masterApi.token 59 | ); 60 | 61 | if (!res.ok) { 62 | throw res; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/start-bot.ts: -------------------------------------------------------------------------------- 1 | import { REST } from '@discordjs/rest'; 2 | import { Options, Partials } from 'discord.js'; 3 | import { createRequire } from 'node:module'; 4 | 5 | import { Button } from './buttons/index.js'; 6 | import { DevCommand, HelpCommand, InfoCommand, TestCommand } from './commands/chat/index.js'; 7 | import { 8 | ChatCommandMetadata, 9 | Command, 10 | MessageCommandMetadata, 11 | UserCommandMetadata, 12 | } from './commands/index.js'; 13 | import { ViewDateSent } from './commands/message/index.js'; 14 | import { ViewDateJoined } from './commands/user/index.js'; 15 | import { 16 | ButtonHandler, 17 | CommandHandler, 18 | GuildJoinHandler, 19 | GuildLeaveHandler, 20 | MessageHandler, 21 | ReactionHandler, 22 | TriggerHandler, 23 | } from './events/index.js'; 24 | import { CustomClient } from './extensions/index.js'; 25 | import { Job } from './jobs/index.js'; 26 | import { Bot } from './models/bot.js'; 27 | import { Reaction } from './reactions/index.js'; 28 | import { 29 | CommandRegistrationService, 30 | EventDataService, 31 | JobService, 32 | Logger, 33 | } from './services/index.js'; 34 | import { Trigger } from './triggers/index.js'; 35 | 36 | const require = createRequire(import.meta.url); 37 | let Config = require('../config/config.json'); 38 | let Logs = require('../lang/logs.json'); 39 | 40 | async function start(): Promise { 41 | // Services 42 | let eventDataService = new EventDataService(); 43 | 44 | // Client 45 | let client = new CustomClient({ 46 | intents: Config.client.intents, 47 | partials: (Config.client.partials as string[]).map(partial => Partials[partial]), 48 | makeCache: Options.cacheWithLimits({ 49 | // Keep default caching behavior 50 | ...Options.DefaultMakeCacheSettings, 51 | // Override specific options from config 52 | ...Config.client.caches, 53 | }), 54 | }); 55 | 56 | // Commands 57 | let commands: Command[] = [ 58 | // Chat Commands 59 | new DevCommand(), 60 | new HelpCommand(), 61 | new InfoCommand(), 62 | new TestCommand(), 63 | 64 | // Message Context Commands 65 | new ViewDateSent(), 66 | 67 | // User Context Commands 68 | new ViewDateJoined(), 69 | 70 | // TODO: Add new commands here 71 | ]; 72 | 73 | // Buttons 74 | let buttons: Button[] = [ 75 | // TODO: Add new buttons here 76 | ]; 77 | 78 | // Reactions 79 | let reactions: Reaction[] = [ 80 | // TODO: Add new reactions here 81 | ]; 82 | 83 | // Triggers 84 | let triggers: Trigger[] = [ 85 | // TODO: Add new triggers here 86 | ]; 87 | 88 | // Event handlers 89 | let guildJoinHandler = new GuildJoinHandler(eventDataService); 90 | let guildLeaveHandler = new GuildLeaveHandler(); 91 | let commandHandler = new CommandHandler(commands, eventDataService); 92 | let buttonHandler = new ButtonHandler(buttons, eventDataService); 93 | let triggerHandler = new TriggerHandler(triggers, eventDataService); 94 | let messageHandler = new MessageHandler(triggerHandler); 95 | let reactionHandler = new ReactionHandler(reactions, eventDataService); 96 | 97 | // Jobs 98 | let jobs: Job[] = [ 99 | // TODO: Add new jobs here 100 | ]; 101 | 102 | // Bot 103 | let bot = new Bot( 104 | Config.client.token, 105 | client, 106 | guildJoinHandler, 107 | guildLeaveHandler, 108 | messageHandler, 109 | commandHandler, 110 | buttonHandler, 111 | reactionHandler, 112 | new JobService(jobs) 113 | ); 114 | 115 | // Register 116 | if (process.argv[2] == 'commands') { 117 | try { 118 | let rest = new REST({ version: '10' }).setToken(Config.client.token); 119 | let commandRegistrationService = new CommandRegistrationService(rest); 120 | let localCmds = [ 121 | ...Object.values(ChatCommandMetadata).sort((a, b) => (a.name > b.name ? 1 : -1)), 122 | ...Object.values(MessageCommandMetadata).sort((a, b) => (a.name > b.name ? 1 : -1)), 123 | ...Object.values(UserCommandMetadata).sort((a, b) => (a.name > b.name ? 1 : -1)), 124 | ]; 125 | await commandRegistrationService.process(localCmds, process.argv); 126 | } catch (error) { 127 | Logger.error(Logs.error.commandAction, error); 128 | } 129 | // Wait for any final logs to be written. 130 | await new Promise(resolve => setTimeout(resolve, 1000)); 131 | process.exit(); 132 | } 133 | 134 | await bot.start(); 135 | } 136 | 137 | process.on('unhandledRejection', (reason, _promise) => { 138 | Logger.error(Logs.error.unhandledRejection, reason); 139 | }); 140 | 141 | start().catch(error => { 142 | Logger.error(Logs.error.unspecified, error); 143 | }); 144 | -------------------------------------------------------------------------------- /src/start-manager.ts: -------------------------------------------------------------------------------- 1 | import { ShardingManager } from 'discord.js'; 2 | import { createRequire } from 'node:module'; 3 | import 'reflect-metadata'; 4 | 5 | import { GuildsController, RootController, ShardsController } from './controllers/index.js'; 6 | import { Job, UpdateServerCountJob } from './jobs/index.js'; 7 | import { Api } from './models/api.js'; 8 | import { Manager } from './models/manager.js'; 9 | import { HttpService, JobService, Logger, MasterApiService } from './services/index.js'; 10 | import { MathUtils, ShardUtils } from './utils/index.js'; 11 | 12 | const require = createRequire(import.meta.url); 13 | let Config = require('../config/config.json'); 14 | let Debug = require('../config/debug.json'); 15 | let Logs = require('../lang/logs.json'); 16 | 17 | async function start(): Promise { 18 | Logger.info(Logs.info.appStarted); 19 | 20 | // Dependencies 21 | let httpService = new HttpService(); 22 | let masterApiService = new MasterApiService(httpService); 23 | if (Config.clustering.enabled) { 24 | await masterApiService.register(); 25 | } 26 | 27 | // Sharding 28 | let shardList: number[]; 29 | let totalShards: number; 30 | try { 31 | if (Config.clustering.enabled) { 32 | let resBody = await masterApiService.login(); 33 | shardList = resBody.shardList; 34 | let requiredShards = await ShardUtils.requiredShardCount(Config.client.token); 35 | totalShards = Math.max(requiredShards, resBody.totalShards); 36 | } else { 37 | let recommendedShards = await ShardUtils.recommendedShardCount( 38 | Config.client.token, 39 | Config.sharding.serversPerShard 40 | ); 41 | shardList = MathUtils.range(0, recommendedShards); 42 | totalShards = recommendedShards; 43 | } 44 | } catch (error) { 45 | Logger.error(Logs.error.retrieveShards, error); 46 | return; 47 | } 48 | 49 | if (shardList.length === 0) { 50 | Logger.warn(Logs.warn.managerNoShards); 51 | return; 52 | } 53 | 54 | let shardManager = new ShardingManager('dist/start-bot.js', { 55 | token: Config.client.token, 56 | mode: Debug.override.shardMode.enabled ? Debug.override.shardMode.value : 'process', 57 | respawn: true, 58 | totalShards, 59 | shardList, 60 | }); 61 | 62 | // Jobs 63 | let jobs: Job[] = [ 64 | Config.clustering.enabled ? undefined : new UpdateServerCountJob(shardManager, httpService), 65 | // TODO: Add new jobs here 66 | ].filter(Boolean); 67 | 68 | let manager = new Manager(shardManager, new JobService(jobs)); 69 | 70 | // API 71 | let guildsController = new GuildsController(shardManager); 72 | let shardsController = new ShardsController(shardManager); 73 | let rootController = new RootController(); 74 | let api = new Api([guildsController, shardsController, rootController]); 75 | 76 | // Start 77 | await manager.start(); 78 | await api.start(); 79 | if (Config.clustering.enabled) { 80 | await masterApiService.ready(); 81 | } 82 | } 83 | 84 | process.on('unhandledRejection', (reason, _promise) => { 85 | Logger.error(Logs.error.unhandledRejection, reason); 86 | }); 87 | 88 | start().catch(error => { 89 | Logger.error(Logs.error.unspecified, error); 90 | }); 91 | -------------------------------------------------------------------------------- /src/triggers/index.ts: -------------------------------------------------------------------------------- 1 | export { Trigger } from './trigger.js'; 2 | -------------------------------------------------------------------------------- /src/triggers/trigger.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js'; 2 | 3 | import { EventData } from '../models/internal-models.js'; 4 | 5 | export interface Trigger { 6 | requireGuild: boolean; 7 | triggered(msg: Message): boolean; 8 | execute(msg: Message, data: EventData): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/command-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandInteraction, 3 | GuildChannel, 4 | MessageComponentInteraction, 5 | ModalSubmitInteraction, 6 | ThreadChannel, 7 | } from 'discord.js'; 8 | 9 | import { FormatUtils, InteractionUtils } from './index.js'; 10 | import { Command } from '../commands/index.js'; 11 | import { Permission } from '../models/enum-helpers/index.js'; 12 | import { EventData } from '../models/internal-models.js'; 13 | import { Lang } from '../services/index.js'; 14 | 15 | export class CommandUtils { 16 | public static findCommand(commands: Command[], commandParts: string[]): Command { 17 | let found = [...commands]; 18 | let closestMatch: Command; 19 | for (let [index, commandPart] of commandParts.entries()) { 20 | found = found.filter(command => command.names[index] === commandPart); 21 | if (found.length === 0) { 22 | return closestMatch; 23 | } 24 | 25 | if (found.length === 1) { 26 | return found[0]; 27 | } 28 | 29 | let exactMatch = found.find(command => command.names.length === index + 1); 30 | if (exactMatch) { 31 | closestMatch = exactMatch; 32 | } 33 | } 34 | return closestMatch; 35 | } 36 | 37 | public static async runChecks( 38 | command: Command, 39 | intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction, 40 | data: EventData 41 | ): Promise { 42 | if (command.cooldown) { 43 | let limited = command.cooldown.take(intr.user.id); 44 | if (limited) { 45 | await InteractionUtils.send( 46 | intr, 47 | Lang.getEmbed('validationEmbeds.cooldownHit', data.lang, { 48 | AMOUNT: command.cooldown.amount.toLocaleString(data.lang), 49 | INTERVAL: FormatUtils.duration(command.cooldown.interval, data.lang), 50 | }) 51 | ); 52 | return false; 53 | } 54 | } 55 | 56 | if ( 57 | (intr.channel instanceof GuildChannel || intr.channel instanceof ThreadChannel) && 58 | !intr.channel.permissionsFor(intr.client.user).has(command.requireClientPerms) 59 | ) { 60 | await InteractionUtils.send( 61 | intr, 62 | Lang.getEmbed('validationEmbeds.missingClientPerms', data.lang, { 63 | PERMISSIONS: command.requireClientPerms 64 | .map(perm => `**${Permission.Data[perm].displayName(data.lang)}**`) 65 | .join(', '), 66 | }) 67 | ); 68 | return false; 69 | } 70 | 71 | return true; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/utils/format-utils.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommand, Guild, Locale } from 'discord.js'; 2 | import { filesize } from 'filesize'; 3 | import { Duration } from 'luxon'; 4 | 5 | export class FormatUtils { 6 | public static roleMention(guild: Guild, discordId: string): string { 7 | if (discordId === '@here') { 8 | return discordId; 9 | } 10 | 11 | if (discordId === guild.id) { 12 | return '@everyone'; 13 | } 14 | 15 | return `<@&${discordId}>`; 16 | } 17 | 18 | public static channelMention(discordId: string): string { 19 | return `<#${discordId}>`; 20 | } 21 | 22 | public static userMention(discordId: string): string { 23 | return `<@!${discordId}>`; 24 | } 25 | 26 | // TODO: Replace with ApplicationCommand#toString() once discord.js #8818 is merged 27 | // https://github.com/discordjs/discord.js/pull/8818 28 | public static commandMention(command: ApplicationCommand, subParts: string[] = []): string { 29 | let name = [command.name, ...subParts].join(' '); 30 | return ``; 31 | } 32 | 33 | public static duration(milliseconds: number, langCode: Locale): string { 34 | return Duration.fromObject( 35 | Object.fromEntries( 36 | Object.entries( 37 | Duration.fromMillis(milliseconds, { locale: langCode }) 38 | .shiftTo( 39 | 'year', 40 | 'quarter', 41 | 'month', 42 | 'week', 43 | 'day', 44 | 'hour', 45 | 'minute', 46 | 'second' 47 | ) 48 | .toObject() 49 | ).filter(([_, value]) => !!value) // Remove units that are 0 50 | ) 51 | ).toHuman({ maximumFractionDigits: 0 }); 52 | } 53 | 54 | public static fileSize(bytes: number): string { 55 | return filesize(bytes, { output: 'string', pad: true, round: 2 }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { ClientUtils } from './client-utils.js'; 2 | export { CommandUtils } from './command-utils.js'; 3 | export { FormatUtils } from './format-utils.js'; 4 | export { InteractionUtils } from './interaction-utils.js'; 5 | export { MathUtils } from './math-utils.js'; 6 | export { MessageUtils } from './message-utils.js'; 7 | export { PartialUtils } from './partial-utils.js'; 8 | export { PermissionUtils } from './permission-utils.js'; 9 | export { RandomUtils } from './random-utils.js'; 10 | export { RegexUtils } from './regex-utils.js'; 11 | export { ShardUtils } from './shard-utils.js'; 12 | export { StringUtils } from './string-utils.js'; 13 | export { ThreadUtils } from './thread-utils.js'; 14 | -------------------------------------------------------------------------------- /src/utils/interaction-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandOptionChoiceData, 3 | AutocompleteInteraction, 4 | CommandInteraction, 5 | DiscordAPIError, 6 | RESTJSONErrorCodes as DiscordApiErrors, 7 | EmbedBuilder, 8 | InteractionReplyOptions, 9 | InteractionResponse, 10 | InteractionUpdateOptions, 11 | Message, 12 | MessageComponentInteraction, 13 | ModalSubmitInteraction, 14 | WebhookMessageEditOptions, 15 | } from 'discord.js'; 16 | 17 | const IGNORED_ERRORS = [ 18 | DiscordApiErrors.UnknownMessage, 19 | DiscordApiErrors.UnknownChannel, 20 | DiscordApiErrors.UnknownGuild, 21 | DiscordApiErrors.UnknownUser, 22 | DiscordApiErrors.UnknownInteraction, 23 | DiscordApiErrors.CannotSendMessagesToThisUser, // User blocked bot or DM disabled 24 | DiscordApiErrors.ReactionWasBlocked, // User blocked bot or DM disabled 25 | DiscordApiErrors.MaximumActiveThreads, 26 | ]; 27 | 28 | export class InteractionUtils { 29 | public static async deferReply( 30 | intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction, 31 | hidden: boolean = false 32 | ): Promise { 33 | try { 34 | return await intr.deferReply({ 35 | ephemeral: hidden, 36 | }); 37 | } catch (error) { 38 | if ( 39 | error instanceof DiscordAPIError && 40 | typeof error.code == 'number' && 41 | IGNORED_ERRORS.includes(error.code) 42 | ) { 43 | return; 44 | } else { 45 | throw error; 46 | } 47 | } 48 | } 49 | 50 | public static async deferUpdate( 51 | intr: MessageComponentInteraction | ModalSubmitInteraction 52 | ): Promise { 53 | try { 54 | return await intr.deferUpdate(); 55 | } catch (error) { 56 | if ( 57 | error instanceof DiscordAPIError && 58 | typeof error.code == 'number' && 59 | IGNORED_ERRORS.includes(error.code) 60 | ) { 61 | return; 62 | } else { 63 | throw error; 64 | } 65 | } 66 | } 67 | 68 | public static async send( 69 | intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction, 70 | content: string | EmbedBuilder | InteractionReplyOptions, 71 | hidden: boolean = false 72 | ): Promise { 73 | try { 74 | let options: InteractionReplyOptions = 75 | typeof content === 'string' 76 | ? { content } 77 | : content instanceof EmbedBuilder 78 | ? { embeds: [content] } 79 | : content; 80 | if (intr.deferred || intr.replied) { 81 | return await intr.followUp({ 82 | ...options, 83 | ephemeral: hidden, 84 | }); 85 | } else { 86 | return await intr.reply({ 87 | ...options, 88 | ephemeral: hidden, 89 | fetchReply: true, 90 | }); 91 | } 92 | } catch (error) { 93 | if ( 94 | error instanceof DiscordAPIError && 95 | typeof error.code == 'number' && 96 | IGNORED_ERRORS.includes(error.code) 97 | ) { 98 | return; 99 | } else { 100 | throw error; 101 | } 102 | } 103 | } 104 | 105 | public static async respond( 106 | intr: AutocompleteInteraction, 107 | choices: ApplicationCommandOptionChoiceData[] = [] 108 | ): Promise { 109 | try { 110 | return await intr.respond(choices); 111 | } catch (error) { 112 | if ( 113 | error instanceof DiscordAPIError && 114 | typeof error.code == 'number' && 115 | IGNORED_ERRORS.includes(error.code) 116 | ) { 117 | return; 118 | } else { 119 | throw error; 120 | } 121 | } 122 | } 123 | 124 | public static async editReply( 125 | intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction, 126 | content: string | EmbedBuilder | WebhookMessageEditOptions 127 | ): Promise { 128 | try { 129 | let options: WebhookMessageEditOptions = 130 | typeof content === 'string' 131 | ? { content } 132 | : content instanceof EmbedBuilder 133 | ? { embeds: [content] } 134 | : content; 135 | return await intr.editReply(options); 136 | } catch (error) { 137 | if ( 138 | error instanceof DiscordAPIError && 139 | typeof error.code == 'number' && 140 | IGNORED_ERRORS.includes(error.code) 141 | ) { 142 | return; 143 | } else { 144 | throw error; 145 | } 146 | } 147 | } 148 | 149 | public static async update( 150 | intr: MessageComponentInteraction, 151 | content: string | EmbedBuilder | InteractionUpdateOptions 152 | ): Promise { 153 | try { 154 | let options: InteractionUpdateOptions = 155 | typeof content === 'string' 156 | ? { content } 157 | : content instanceof EmbedBuilder 158 | ? { embeds: [content] } 159 | : content; 160 | return await intr.update({ 161 | ...options, 162 | fetchReply: true, 163 | }); 164 | } catch (error) { 165 | if ( 166 | error instanceof DiscordAPIError && 167 | typeof error.code == 'number' && 168 | IGNORED_ERRORS.includes(error.code) 169 | ) { 170 | return; 171 | } else { 172 | throw error; 173 | } 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/utils/math-utils.ts: -------------------------------------------------------------------------------- 1 | export class MathUtils { 2 | public static sum(numbers: number[]): number { 3 | return numbers.reduce((a, b) => a + b, 0); 4 | } 5 | 6 | public static clamp(input: number, min: number, max: number): number { 7 | return Math.min(Math.max(input, min), max); 8 | } 9 | 10 | public static range(start: number, size: number): number[] { 11 | return [...Array(size).keys()].map(i => i + start); 12 | } 13 | 14 | public static ceilToMultiple(input: number, multiple: number): number { 15 | return Math.ceil(input / multiple) * multiple; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/message-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseMessageOptions, 3 | DiscordAPIError, 4 | RESTJSONErrorCodes as DiscordApiErrors, 5 | EmbedBuilder, 6 | EmojiResolvable, 7 | Message, 8 | MessageEditOptions, 9 | MessageReaction, 10 | PartialGroupDMChannel, 11 | StartThreadOptions, 12 | TextBasedChannel, 13 | ThreadChannel, 14 | User, 15 | } from 'discord.js'; 16 | 17 | const IGNORED_ERRORS = [ 18 | DiscordApiErrors.UnknownMessage, 19 | DiscordApiErrors.UnknownChannel, 20 | DiscordApiErrors.UnknownGuild, 21 | DiscordApiErrors.UnknownUser, 22 | DiscordApiErrors.UnknownInteraction, 23 | DiscordApiErrors.CannotSendMessagesToThisUser, // User blocked bot or DM disabled 24 | DiscordApiErrors.ReactionWasBlocked, // User blocked bot or DM disabled 25 | DiscordApiErrors.MaximumActiveThreads, 26 | ]; 27 | 28 | export class MessageUtils { 29 | public static async send( 30 | target: User | TextBasedChannel, 31 | content: string | EmbedBuilder | BaseMessageOptions 32 | ): Promise { 33 | if (target instanceof PartialGroupDMChannel) return; 34 | try { 35 | let options: BaseMessageOptions = 36 | typeof content === 'string' 37 | ? { content } 38 | : content instanceof EmbedBuilder 39 | ? { embeds: [content] } 40 | : content; 41 | return await target.send(options); 42 | } catch (error) { 43 | if ( 44 | error instanceof DiscordAPIError && 45 | typeof error.code == 'number' && 46 | IGNORED_ERRORS.includes(error.code) 47 | ) { 48 | return; 49 | } else { 50 | throw error; 51 | } 52 | } 53 | } 54 | 55 | public static async reply( 56 | msg: Message, 57 | content: string | EmbedBuilder | BaseMessageOptions 58 | ): Promise { 59 | try { 60 | let options: BaseMessageOptions = 61 | typeof content === 'string' 62 | ? { content } 63 | : content instanceof EmbedBuilder 64 | ? { embeds: [content] } 65 | : content; 66 | return await msg.reply(options); 67 | } catch (error) { 68 | if ( 69 | error instanceof DiscordAPIError && 70 | typeof error.code == 'number' && 71 | IGNORED_ERRORS.includes(error.code) 72 | ) { 73 | return; 74 | } else { 75 | throw error; 76 | } 77 | } 78 | } 79 | 80 | public static async edit( 81 | msg: Message, 82 | content: string | EmbedBuilder | MessageEditOptions 83 | ): Promise { 84 | try { 85 | let options: MessageEditOptions = 86 | typeof content === 'string' 87 | ? { content } 88 | : content instanceof EmbedBuilder 89 | ? { embeds: [content] } 90 | : content; 91 | return await msg.edit(options); 92 | } catch (error) { 93 | if ( 94 | error instanceof DiscordAPIError && 95 | typeof error.code == 'number' && 96 | IGNORED_ERRORS.includes(error.code) 97 | ) { 98 | return; 99 | } else { 100 | throw error; 101 | } 102 | } 103 | } 104 | 105 | public static async react(msg: Message, emoji: EmojiResolvable): Promise { 106 | try { 107 | return await msg.react(emoji); 108 | } catch (error) { 109 | if ( 110 | error instanceof DiscordAPIError && 111 | typeof error.code == 'number' && 112 | IGNORED_ERRORS.includes(error.code) 113 | ) { 114 | return; 115 | } else { 116 | throw error; 117 | } 118 | } 119 | } 120 | 121 | public static async pin(msg: Message, pinned: boolean = true): Promise { 122 | try { 123 | return pinned ? await msg.pin() : await msg.unpin(); 124 | } catch (error) { 125 | if ( 126 | error instanceof DiscordAPIError && 127 | typeof error.code == 'number' && 128 | IGNORED_ERRORS.includes(error.code) 129 | ) { 130 | return; 131 | } else { 132 | throw error; 133 | } 134 | } 135 | } 136 | 137 | public static async startThread( 138 | msg: Message, 139 | options: StartThreadOptions 140 | ): Promise { 141 | try { 142 | return await msg.startThread(options); 143 | } catch (error) { 144 | if ( 145 | error instanceof DiscordAPIError && 146 | typeof error.code == 'number' && 147 | IGNORED_ERRORS.includes(error.code) 148 | ) { 149 | return; 150 | } else { 151 | throw error; 152 | } 153 | } 154 | } 155 | 156 | public static async delete(msg: Message): Promise { 157 | try { 158 | return await msg.delete(); 159 | } catch (error) { 160 | if ( 161 | error instanceof DiscordAPIError && 162 | typeof error.code == 'number' && 163 | IGNORED_ERRORS.includes(error.code) 164 | ) { 165 | return; 166 | } else { 167 | throw error; 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/utils/partial-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DiscordAPIError, 3 | RESTJSONErrorCodes as DiscordApiErrors, 4 | Message, 5 | MessageReaction, 6 | PartialMessage, 7 | PartialMessageReaction, 8 | PartialUser, 9 | User, 10 | } from 'discord.js'; 11 | 12 | const IGNORED_ERRORS = [ 13 | DiscordApiErrors.UnknownMessage, 14 | DiscordApiErrors.UnknownChannel, 15 | DiscordApiErrors.UnknownGuild, 16 | DiscordApiErrors.UnknownUser, 17 | DiscordApiErrors.UnknownInteraction, 18 | DiscordApiErrors.MissingAccess, 19 | ]; 20 | 21 | export class PartialUtils { 22 | public static async fillUser(user: User | PartialUser): Promise { 23 | if (user.partial) { 24 | try { 25 | return await user.fetch(); 26 | } catch (error) { 27 | if ( 28 | error instanceof DiscordAPIError && 29 | typeof error.code == 'number' && 30 | IGNORED_ERRORS.includes(error.code) 31 | ) { 32 | return; 33 | } else { 34 | throw error; 35 | } 36 | } 37 | } 38 | 39 | return user as User; 40 | } 41 | 42 | public static async fillMessage(msg: Message | PartialMessage): Promise { 43 | if (msg.partial) { 44 | try { 45 | return await msg.fetch(); 46 | } catch (error) { 47 | if ( 48 | error instanceof DiscordAPIError && 49 | typeof error.code == 'number' && 50 | IGNORED_ERRORS.includes(error.code) 51 | ) { 52 | return; 53 | } else { 54 | throw error; 55 | } 56 | } 57 | } 58 | 59 | return msg as Message; 60 | } 61 | 62 | public static async fillReaction( 63 | msgReaction: MessageReaction | PartialMessageReaction 64 | ): Promise { 65 | if (msgReaction.partial) { 66 | try { 67 | msgReaction = await msgReaction.fetch(); 68 | } catch (error) { 69 | if ( 70 | error instanceof DiscordAPIError && 71 | typeof error.code == 'number' && 72 | IGNORED_ERRORS.includes(error.code) 73 | ) { 74 | return; 75 | } else { 76 | throw error; 77 | } 78 | } 79 | } 80 | 81 | msgReaction.message = await this.fillMessage(msgReaction.message); 82 | if (!msgReaction.message) { 83 | return; 84 | } 85 | 86 | return msgReaction as MessageReaction; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/utils/permission-utils.ts: -------------------------------------------------------------------------------- 1 | import { Channel, DMChannel, GuildChannel, PermissionFlagsBits, ThreadChannel } from 'discord.js'; 2 | 3 | export class PermissionUtils { 4 | public static canSend(channel: Channel, embedLinks: boolean = false): boolean { 5 | if (channel instanceof DMChannel) { 6 | return true; 7 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 8 | let channelPerms = channel.permissionsFor(channel.client.user); 9 | if (!channelPerms) { 10 | // This can happen if the guild disconnected while a collector is running 11 | return false; 12 | } 13 | 14 | // VIEW_CHANNEL - Needed to view the channel 15 | // SEND_MESSAGES - Needed to send messages 16 | // EMBED_LINKS - Needed to send embedded links 17 | return channelPerms.has([ 18 | PermissionFlagsBits.ViewChannel, 19 | PermissionFlagsBits.SendMessages, 20 | ...(embedLinks ? [PermissionFlagsBits.EmbedLinks] : []), 21 | ]); 22 | } else { 23 | return false; 24 | } 25 | } 26 | 27 | public static canMention(channel: Channel): boolean { 28 | if (channel instanceof DMChannel) { 29 | return true; 30 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 31 | let channelPerms = channel.permissionsFor(channel.client.user); 32 | if (!channelPerms) { 33 | // This can happen if the guild disconnected while a collector is running 34 | return false; 35 | } 36 | 37 | // VIEW_CHANNEL - Needed to view the channel 38 | // MENTION_EVERYONE - Needed to mention @everyone, @here, and all roles 39 | return channelPerms.has([ 40 | PermissionFlagsBits.ViewChannel, 41 | PermissionFlagsBits.MentionEveryone, 42 | ]); 43 | } else { 44 | return false; 45 | } 46 | } 47 | 48 | public static canReact(channel: Channel, removeOthers: boolean = false): boolean { 49 | if (channel instanceof DMChannel) { 50 | return true; 51 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 52 | let channelPerms = channel.permissionsFor(channel.client.user); 53 | if (!channelPerms) { 54 | // This can happen if the guild disconnected while a collector is running 55 | return false; 56 | } 57 | 58 | // VIEW_CHANNEL - Needed to view the channel 59 | // ADD_REACTIONS - Needed to add new reactions to messages 60 | // READ_MESSAGE_HISTORY - Needed to add new reactions to messages 61 | // https://discordjs.guide/popular-topics/permissions-extended.html#implicit-permissions 62 | // MANAGE_MESSAGES - Needed to remove others reactions 63 | return channelPerms.has([ 64 | PermissionFlagsBits.ViewChannel, 65 | PermissionFlagsBits.AddReactions, 66 | PermissionFlagsBits.ReadMessageHistory, 67 | ...(removeOthers ? [PermissionFlagsBits.ManageMessages] : []), 68 | ]); 69 | } else { 70 | return false; 71 | } 72 | } 73 | 74 | public static canPin(channel: Channel, findOld: boolean = false): boolean { 75 | if (channel instanceof DMChannel) { 76 | return true; 77 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 78 | let channelPerms = channel.permissionsFor(channel.client.user); 79 | if (!channelPerms) { 80 | // This can happen if the guild disconnected while a collector is running 81 | return false; 82 | } 83 | 84 | // VIEW_CHANNEL - Needed to view the channel 85 | // MANAGE_MESSAGES - Needed to pin messages 86 | // READ_MESSAGE_HISTORY - Needed to find old pins 87 | return channelPerms.has([ 88 | PermissionFlagsBits.ViewChannel, 89 | PermissionFlagsBits.ManageMessages, 90 | ...(findOld ? [PermissionFlagsBits.ReadMessageHistory] : []), 91 | ]); 92 | } else { 93 | return false; 94 | } 95 | } 96 | 97 | public static canCreateThreads( 98 | channel: Channel, 99 | manageThreads: boolean = false, 100 | findOld: boolean = false 101 | ): boolean { 102 | if (channel instanceof DMChannel) { 103 | return false; 104 | } else if (channel instanceof GuildChannel || channel instanceof ThreadChannel) { 105 | let channelPerms = channel.permissionsFor(channel.client.user); 106 | if (!channelPerms) { 107 | // This can happen if the guild disconnected while a collector is running 108 | return false; 109 | } 110 | 111 | // VIEW_CHANNEL - Needed to view the channel 112 | // SEND_MESSAGES_IN_THREADS - Needed to send messages in threads 113 | // CREATE_PUBLIC_THREADS - Needed to create public threads 114 | // MANAGE_THREADS - Needed to rename, delete, archive, unarchive, slow mode threads 115 | // READ_MESSAGE_HISTORY - Needed to find old threads 116 | return channelPerms.has([ 117 | PermissionFlagsBits.ViewChannel, 118 | PermissionFlagsBits.SendMessagesInThreads, 119 | PermissionFlagsBits.CreatePublicThreads, 120 | ...(manageThreads ? [PermissionFlagsBits.ManageThreads] : []), 121 | ...(findOld ? [PermissionFlagsBits.ReadMessageHistory] : []), 122 | ]); 123 | } else { 124 | return false; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/utils/random-utils.ts: -------------------------------------------------------------------------------- 1 | export class RandomUtils { 2 | public static intFromInterval(min: number, max: number): number { 3 | return Math.floor(Math.random() * (max - min + 1) + min); 4 | } 5 | 6 | public static shuffle(input: any[]): any[] { 7 | for (let i = input.length - 1; i > 0; i--) { 8 | const j = Math.floor(Math.random() * (i + 1)); 9 | [input[i], input[j]] = [input[j], input[i]]; 10 | } 11 | return input; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/regex-utils.ts: -------------------------------------------------------------------------------- 1 | export class RegexUtils { 2 | public static regex(input: string): RegExp { 3 | let match = input.match(/^\/(.*)\/([^/]*)$/); 4 | if (!match) { 5 | return; 6 | } 7 | 8 | return new RegExp(match[1], match[2]); 9 | } 10 | 11 | public static escapeRegex(input: string): string { 12 | return input?.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); 13 | } 14 | 15 | public static discordId(input: string): string { 16 | return input?.match(/\b\d{17,20}\b/)?.[0]; 17 | } 18 | 19 | public static tag(input: string): { username: string; tag: string; discriminator: string } { 20 | let match = input.match(/\b(.+)#([\d]{4})\b/); 21 | if (!match) { 22 | return; 23 | } 24 | 25 | return { 26 | tag: match[0], 27 | username: match[1], 28 | discriminator: match[2], 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/shard-utils.ts: -------------------------------------------------------------------------------- 1 | import { fetchRecommendedShardCount, ShardClientUtil, ShardingManager } from 'discord.js'; 2 | 3 | import { MathUtils } from './index.js'; 4 | import { DiscordLimits } from '../constants/index.js'; 5 | 6 | export class ShardUtils { 7 | public static async requiredShardCount(token: string): Promise { 8 | return await this.recommendedShardCount(token, DiscordLimits.GUILDS_PER_SHARD); 9 | } 10 | 11 | public static async recommendedShardCount( 12 | token: string, 13 | serversPerShard: number 14 | ): Promise { 15 | return Math.ceil( 16 | await fetchRecommendedShardCount(token, { guildsPerShard: serversPerShard }) 17 | ); 18 | } 19 | 20 | public static shardIds(shardInterface: ShardingManager | ShardClientUtil): number[] { 21 | if (shardInterface instanceof ShardingManager) { 22 | return shardInterface.shards.map(shard => shard.id); 23 | } else if (shardInterface instanceof ShardClientUtil) { 24 | return shardInterface.ids; 25 | } 26 | } 27 | 28 | public static shardId(guildId: number | string, shardCount: number): number { 29 | // See sharding formula: 30 | // https://discord.com/developers/docs/topics/gateway#sharding-sharding-formula 31 | // tslint:disable-next-line:no-bitwise 32 | return Number((BigInt(guildId) >> 22n) % BigInt(shardCount)); 33 | } 34 | 35 | public static async serverCount( 36 | shardInterface: ShardingManager | ShardClientUtil 37 | ): Promise { 38 | let shardGuildCounts = (await shardInterface.fetchClientValues( 39 | 'guilds.cache.size' 40 | )) as number[]; 41 | return MathUtils.sum(shardGuildCounts); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/string-utils.ts: -------------------------------------------------------------------------------- 1 | export class StringUtils { 2 | public static truncate(input: string, length: number, addEllipsis: boolean = false): string { 3 | if (input.length <= length) { 4 | return input; 5 | } 6 | 7 | let output = input.substring(0, addEllipsis ? length - 3 : length); 8 | if (addEllipsis) { 9 | output += '...'; 10 | } 11 | 12 | return output; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/thread-utils.ts: -------------------------------------------------------------------------------- 1 | import { DiscordAPIError, RESTJSONErrorCodes as DiscordApiErrors, ThreadChannel } from 'discord.js'; 2 | 3 | const IGNORED_ERRORS = [ 4 | DiscordApiErrors.UnknownMessage, 5 | DiscordApiErrors.UnknownChannel, 6 | DiscordApiErrors.UnknownGuild, 7 | DiscordApiErrors.UnknownUser, 8 | DiscordApiErrors.UnknownInteraction, 9 | DiscordApiErrors.CannotSendMessagesToThisUser, // User blocked bot or DM disabled 10 | DiscordApiErrors.ReactionWasBlocked, // User blocked bot or DM disabled 11 | DiscordApiErrors.MaximumActiveThreads, 12 | ]; 13 | 14 | export class ThreadUtils { 15 | public static async archive( 16 | thread: ThreadChannel, 17 | archived: boolean = true 18 | ): Promise { 19 | try { 20 | return await thread.setArchived(archived); 21 | } catch (error) { 22 | if ( 23 | error instanceof DiscordAPIError && 24 | typeof error.code == 'number' && 25 | IGNORED_ERRORS.includes(error.code) 26 | ) { 27 | return; 28 | } else { 29 | throw error; 30 | } 31 | } 32 | } 33 | 34 | public static async lock( 35 | thread: ThreadChannel, 36 | locked: boolean = true 37 | ): Promise { 38 | try { 39 | return await thread.setLocked(locked); 40 | } catch (error) { 41 | if ( 42 | error instanceof DiscordAPIError && 43 | typeof error.code == 'number' && 44 | IGNORED_ERRORS.includes(error.code) 45 | ) { 46 | return; 47 | } else { 48 | throw error; 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/helpers/discord-mocks.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | import { 3 | ChannelType, 4 | GuildChannel, 5 | GuildMember, 6 | PermissionFlagsBits, 7 | PermissionsBitField, 8 | PermissionsString, 9 | ThreadChannel, 10 | User, 11 | } from 'discord.js'; 12 | 13 | /** 14 | * Creates a mock Discord.js User that correctly passes instanceof checks 15 | */ 16 | export function createMockUser(overrides = {}) { 17 | // Create base object with properties we need 18 | const baseUser = { 19 | id: '123456789012345678', 20 | username: 'TestUser', 21 | discriminator: '0000', 22 | tag: 'TestUser#0000', 23 | displayAvatarURL: vi.fn().mockReturnValue('https://example.com/avatar.png'), 24 | bot: false, 25 | system: false, 26 | flags: { bitfield: 0 }, 27 | createdAt: new Date(), 28 | createdTimestamp: Date.now(), 29 | // Common methods 30 | send: vi.fn().mockResolvedValue({}), 31 | fetch: vi.fn().mockImplementation(function () { 32 | return Promise.resolve(this); 33 | }), 34 | toString: vi.fn().mockReturnValue('<@123456789012345678>'), 35 | }; 36 | 37 | // Add overrides 38 | Object.assign(baseUser, overrides); 39 | 40 | // Create a properly structured mock that will pass instanceof checks 41 | const mockUser = Object.create(User.prototype, { 42 | ...Object.getOwnPropertyDescriptors(baseUser), 43 | // Make sure the user correctly identifies as a User 44 | constructor: { value: User }, 45 | }); 46 | 47 | return mockUser; 48 | } 49 | 50 | /** 51 | * Creates a mock Discord.js CommandInteraction 52 | */ 53 | export function createMockCommandInteraction(overrides = {}) { 54 | // Create a mock guild member first to ensure consistent user data 55 | const mockMember = createMockGuildMember(); 56 | 57 | return { 58 | id: '987612345678901234', 59 | user: mockMember.user, 60 | member: mockMember, 61 | client: { 62 | user: { 63 | id: '987654321098765432', 64 | username: 'TestBot', 65 | }, 66 | }, 67 | guild: mockMember.guild, 68 | channel: createMockGuildChannel(), 69 | commandName: 'test', 70 | options: { 71 | getString: vi.fn(), 72 | getUser: vi.fn(), 73 | getInteger: vi.fn(), 74 | getBoolean: vi.fn(), 75 | getSubcommand: vi.fn(), 76 | getSubcommandGroup: vi.fn(), 77 | }, 78 | reply: vi.fn().mockResolvedValue({}), 79 | editReply: vi.fn().mockResolvedValue({}), 80 | deferReply: vi.fn().mockResolvedValue({}), 81 | followUp: vi.fn().mockResolvedValue({}), 82 | deferred: false, 83 | replied: false, 84 | ...overrides, 85 | }; 86 | } 87 | 88 | /** 89 | * Creates a mock Discord.js GuildChannel that correctly passes instanceof checks 90 | */ 91 | export function createMockGuildChannel(overrides = {}) { 92 | // Create base object with properties we need 93 | const baseChannel = { 94 | id: '444555666777888999', 95 | name: 'test-channel', 96 | guild: { id: '111222333444555666', name: 'Test Guild' }, 97 | client: { 98 | user: { id: '987654321098765432' }, 99 | }, 100 | type: ChannelType.GuildText, 101 | }; 102 | 103 | // Add overrides 104 | Object.assign(baseChannel, overrides); 105 | 106 | // Create a properly structured mock that will pass instanceof checks 107 | const mockChannel = Object.create(GuildChannel.prototype, { 108 | ...Object.getOwnPropertyDescriptors(baseChannel), 109 | // Make sure the channel correctly identifies as a GuildChannel 110 | constructor: { value: GuildChannel }, 111 | }); 112 | 113 | return mockChannel; 114 | } 115 | 116 | /** 117 | * Creates a mock Discord.js ThreadChannel that correctly passes instanceof checks 118 | */ 119 | export function createMockThreadChannel(overrides = {}) { 120 | // Create base object with properties we need 121 | const baseChannel = { 122 | id: '444555666777888999', 123 | name: 'test-thread', 124 | guild: { id: '111222333444555666', name: 'Test Guild' }, 125 | client: { 126 | user: { id: '987654321098765432' }, 127 | }, 128 | type: ChannelType.PublicThread, 129 | permissionsFor: vi.fn().mockReturnValue({ 130 | has: vi.fn().mockReturnValue(true), 131 | }), 132 | }; 133 | 134 | // Add overrides 135 | Object.assign(baseChannel, overrides); 136 | 137 | // Create a properly structured mock that will pass instanceof checks 138 | const mockChannel = Object.create(ThreadChannel.prototype, { 139 | ...Object.getOwnPropertyDescriptors(baseChannel), 140 | // Make sure the channel correctly identifies as a ThreadChannel 141 | constructor: { value: ThreadChannel }, 142 | }); 143 | 144 | return mockChannel; 145 | } 146 | 147 | /** 148 | * Creates a mock Command object 149 | */ 150 | export function createMockCommand(overrides = {}) { 151 | return { 152 | names: ['test'], 153 | deferType: 'HIDDEN', 154 | requireClientPerms: [], 155 | execute: vi.fn().mockResolvedValue({}), 156 | cooldown: { 157 | take: vi.fn().mockReturnValue(false), 158 | amount: 1, 159 | interval: 5000, 160 | }, 161 | ...overrides, 162 | }; 163 | } 164 | 165 | /** 166 | * Creates a mock Discord.js GuildMember that correctly passes instanceof checks 167 | */ 168 | export function createMockGuildMember(overrides = {}) { 169 | // Create a mock user first 170 | const mockUser = createMockUser(); 171 | 172 | // Create base object with properties we need 173 | const baseMember = { 174 | id: mockUser.id, 175 | user: mockUser, 176 | guild: { id: '111222333444555666', name: 'Test Guild' }, 177 | displayName: mockUser.username, 178 | nickname: null, 179 | roles: { 180 | cache: new Map(), 181 | highest: { position: 1, id: '222333444555666777' }, 182 | add: vi.fn().mockResolvedValue({}), 183 | remove: vi.fn().mockResolvedValue({}), 184 | }, 185 | permissions: new PermissionsBitField(PermissionFlagsBits.SendMessages), 186 | permissionsIn: vi 187 | .fn() 188 | .mockReturnValue(new PermissionsBitField(PermissionFlagsBits.SendMessages)), 189 | joinedAt: new Date(), 190 | voice: { 191 | channelId: null, 192 | channel: null, 193 | mute: false, 194 | deaf: false, 195 | }, 196 | presence: { 197 | status: 'online', 198 | activities: [], 199 | }, 200 | manageable: true, 201 | kickable: true, 202 | bannable: true, 203 | moderatable: true, 204 | communicationDisabledUntil: null, 205 | // Common methods 206 | kick: vi.fn().mockResolvedValue({}), 207 | ban: vi.fn().mockResolvedValue({}), 208 | timeout: vi.fn().mockResolvedValue({}), 209 | edit: vi.fn().mockResolvedValue({}), 210 | fetch: vi.fn().mockImplementation(function () { 211 | return Promise.resolve(this); 212 | }), 213 | send: vi.fn().mockResolvedValue({}), 214 | }; 215 | 216 | // Add overrides 217 | Object.assign(baseMember, overrides); 218 | 219 | // Create a properly structured mock that will pass instanceof checks 220 | const mockMember = Object.create(GuildMember.prototype, { 221 | ...Object.getOwnPropertyDescriptors(baseMember), 222 | // Make sure the member correctly identifies as a GuildMember 223 | constructor: { value: GuildMember }, 224 | }); 225 | 226 | return mockMember; 227 | } 228 | -------------------------------------------------------------------------------- /tests/utils/command-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { CommandUtils } from '../../src/utils/command-utils.js'; 3 | import { Command } from '../../src/commands/index.js'; 4 | import { 5 | createMockCommand, 6 | createMockCommandInteraction, 7 | createMockGuildChannel, 8 | } from '../helpers/discord-mocks.js'; 9 | 10 | // Mock dependencies 11 | vi.mock('../../src/utils/index.js', () => ({ 12 | InteractionUtils: { 13 | send: vi.fn().mockResolvedValue({}), 14 | }, 15 | FormatUtils: { 16 | duration: vi.fn().mockReturnValue('5 seconds'), 17 | }, 18 | })); 19 | 20 | vi.mock('../../src/services/index.js', () => ({ 21 | Lang: { 22 | getEmbed: vi.fn().mockReturnValue({ title: 'Mock Embed' }), 23 | }, 24 | })); 25 | 26 | vi.mock('../../src/models/enum-helpers/index.js', () => ({ 27 | Permission: { 28 | Data: { 29 | ViewChannel: { 30 | displayName: vi.fn().mockReturnValue('View Channel'), 31 | }, 32 | SendMessages: { 33 | displayName: vi.fn().mockReturnValue('Send Messages'), 34 | }, 35 | }, 36 | }, 37 | })); 38 | 39 | describe('CommandUtils', () => { 40 | // Test findCommand method 41 | describe('findCommand', () => { 42 | let mockCommands: Command[]; 43 | 44 | beforeEach(() => { 45 | // Create mock commands using the helper 46 | mockCommands = [ 47 | createMockCommand({ names: ['test'] }), 48 | createMockCommand({ names: ['user', 'info'] }), 49 | createMockCommand({ names: ['user', 'avatar'] }), 50 | ] as unknown as Command[]; 51 | }); 52 | 53 | it('should find a command with exact match', () => { 54 | const result = CommandUtils.findCommand(mockCommands, ['test']); 55 | expect(result).toBe(mockCommands[0]); 56 | }); 57 | 58 | it('should find a nested command with exact match', () => { 59 | const result = CommandUtils.findCommand(mockCommands, ['user', 'info']); 60 | expect(result).toBe(mockCommands[1]); 61 | }); 62 | 63 | it('should return undefined if no match found', () => { 64 | const result = CommandUtils.findCommand(mockCommands, ['nonexistent']); 65 | expect(result).toBeUndefined(); 66 | }); 67 | }); 68 | 69 | // Test runChecks method 70 | describe('runChecks', () => { 71 | let mockCommand: Command & { 72 | cooldown: { take: ReturnType; amount: number; interval: number }; 73 | }; 74 | let mockInteraction: any; 75 | let mockEventData: any; 76 | 77 | beforeEach(() => { 78 | // Create a mock command with cooldown using helper 79 | const cmdMock = createMockCommand({ 80 | requireClientPerms: ['ViewChannel', 'SendMessages'], // Use correct permission names 81 | cooldown: { 82 | take: vi.fn(), 83 | amount: 1, 84 | interval: 5000, 85 | }, 86 | }); 87 | 88 | // Explicitly type the mock command to include the cooldown property 89 | mockCommand = cmdMock as unknown as Command & { 90 | cooldown: { 91 | take: ReturnType; 92 | amount: number; 93 | interval: number; 94 | }; 95 | }; 96 | 97 | // Create a mock interaction using helper 98 | mockInteraction = createMockCommandInteraction({ 99 | user: { id: '123456789012345678' }, 100 | client: { user: { id: '987654321098765432' } }, 101 | channel: createMockGuildChannel({ 102 | permissionsFor: vi.fn().mockReturnValue({ 103 | has: vi.fn().mockReturnValue(true), 104 | }), 105 | }), 106 | }); 107 | 108 | // Create mock event data 109 | mockEventData = { lang: 'en-US' }; 110 | }); 111 | 112 | it('should pass checks when all requirements are met', async () => { 113 | // Mock cooldown.take to return false (not limited) 114 | mockCommand.cooldown.take.mockReturnValue(false); 115 | 116 | const result = await CommandUtils.runChecks( 117 | mockCommand, 118 | mockInteraction, 119 | mockEventData 120 | ); 121 | 122 | expect(result).toBe(true); 123 | expect(mockCommand.cooldown.take).toHaveBeenCalledWith('123456789012345678'); 124 | }); 125 | 126 | it('should fail and send message when on cooldown', async () => { 127 | // Mock the imported InteractionUtils.send function 128 | const { InteractionUtils } = await import('../../src/utils/index.js'); 129 | 130 | // Mock cooldown.take to return true (is limited) 131 | mockCommand.cooldown.take.mockReturnValue(true); 132 | 133 | const result = await CommandUtils.runChecks( 134 | mockCommand, 135 | mockInteraction, 136 | mockEventData 137 | ); 138 | 139 | expect(result).toBe(false); 140 | expect(mockCommand.cooldown.take).toHaveBeenCalledWith('123456789012345678'); 141 | expect(InteractionUtils.send).toHaveBeenCalled(); 142 | }); 143 | 144 | it('should fail when missing client permissions', async () => { 145 | // Mock the imported InteractionUtils.send function 146 | const { InteractionUtils } = await import('../../src/utils/index.js'); 147 | 148 | // Create a GuildChannel mock with failing permission check 149 | mockInteraction.channel = createMockGuildChannel({ 150 | permissionsFor: vi.fn().mockReturnValue({ 151 | has: vi.fn().mockReturnValue(false), 152 | }), 153 | }); 154 | 155 | // Set up command for test 156 | mockCommand.cooldown.take.mockReturnValue(false); 157 | 158 | // Run test 159 | const result = await CommandUtils.runChecks( 160 | mockCommand, 161 | mockInteraction, 162 | mockEventData 163 | ); 164 | 165 | // Verify the result 166 | expect(result).toBe(false); 167 | expect(InteractionUtils.send).toHaveBeenCalled(); 168 | }); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /tests/utils/format-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { ApplicationCommand, Guild, Locale } from 'discord.js'; 3 | import { FormatUtils } from '../../src/utils/index.js'; 4 | 5 | // Mock any configs that might be loaded 6 | vi.mock('../../config/config.json', () => ({})); 7 | vi.mock('../../config/debug.json', () => ({})); 8 | vi.mock('../../lang/logs.json', () => ({})); 9 | 10 | // Mock the external dependencies 11 | vi.mock('filesize', () => ({ 12 | filesize: vi.fn().mockImplementation((bytes, options) => { 13 | if (bytes === 1024) return '1.00 KB'; 14 | if (bytes === 1048576) return '1.00 MB'; 15 | return `${bytes} B`; 16 | }), 17 | })); 18 | 19 | vi.mock('luxon', () => ({ 20 | Duration: { 21 | fromMillis: vi.fn().mockImplementation((ms, options) => ({ 22 | shiftTo: vi.fn().mockReturnValue({ 23 | toObject: vi.fn().mockReturnValue({ 24 | hours: ms === 3600000 ? 1 : 0, 25 | minutes: ms === 60000 ? 1 : 0, 26 | seconds: ms === 5000 ? 5 : 0, 27 | }), 28 | }), 29 | })), 30 | fromObject: vi.fn().mockImplementation(obj => ({ 31 | toHuman: vi.fn().mockImplementation(({ maximumFractionDigits }) => { 32 | if (obj.hours === 1) return '1 hour'; 33 | if (obj.minutes === 1) return '1 minute'; 34 | if (obj.seconds === 5) return '5 seconds'; 35 | return 'unknown duration'; 36 | }), 37 | })), 38 | }, 39 | })); 40 | 41 | describe('FormatUtils', () => { 42 | describe('roleMention', () => { 43 | it('should return @here for @here mentions', () => { 44 | const mockGuild = { id: '123456789012345678' } as Guild; 45 | const result = FormatUtils.roleMention(mockGuild, '@here'); 46 | expect(result).toBe('@here'); 47 | }); 48 | 49 | it('should return @everyone for guild id mentions', () => { 50 | const mockGuild = { id: '123456789012345678' } as Guild; 51 | const result = FormatUtils.roleMention(mockGuild, '123456789012345678'); 52 | expect(result).toBe('@everyone'); 53 | }); 54 | 55 | it('should format regular role mentions', () => { 56 | const mockGuild = { id: '123456789012345678' } as Guild; 57 | const result = FormatUtils.roleMention(mockGuild, '987654321098765432'); 58 | expect(result).toBe('<@&987654321098765432>'); 59 | }); 60 | }); 61 | 62 | describe('channelMention', () => { 63 | it('should format channel mentions', () => { 64 | const result = FormatUtils.channelMention('123456789012345678'); 65 | expect(result).toBe('<#123456789012345678>'); 66 | }); 67 | }); 68 | 69 | describe('userMention', () => { 70 | it('should format user mentions', () => { 71 | const result = FormatUtils.userMention('123456789012345678'); 72 | expect(result).toBe('<@!123456789012345678>'); 73 | }); 74 | }); 75 | 76 | describe('commandMention', () => { 77 | it('should format simple command mentions', () => { 78 | const mockCommand = { 79 | name: 'test', 80 | id: '123456789012345678', 81 | } as ApplicationCommand; 82 | 83 | const result = FormatUtils.commandMention(mockCommand); 84 | expect(result).toBe(''); 85 | }); 86 | 87 | it('should format command mentions with subcommands', () => { 88 | const mockCommand = { 89 | name: 'user', 90 | id: '123456789012345678', 91 | } as ApplicationCommand; 92 | 93 | const result = FormatUtils.commandMention(mockCommand, ['info']); 94 | expect(result).toBe(''); 95 | }); 96 | }); 97 | 98 | describe('duration', () => { 99 | it('should format hours correctly', () => { 100 | const result = FormatUtils.duration(3600000, Locale.EnglishUS); 101 | expect(result).toBe('1 hour'); 102 | }); 103 | 104 | it('should format minutes correctly', () => { 105 | const result = FormatUtils.duration(60000, Locale.EnglishUS); 106 | expect(result).toBe('1 minute'); 107 | }); 108 | 109 | it('should format seconds correctly', () => { 110 | const result = FormatUtils.duration(5000, Locale.EnglishUS); 111 | expect(result).toBe('5 seconds'); 112 | }); 113 | }); 114 | 115 | describe('fileSize', () => { 116 | it('should format bytes to KB correctly', () => { 117 | const result = FormatUtils.fileSize(1024); 118 | expect(result).toBe('1.00 KB'); 119 | }); 120 | 121 | it('should format bytes to MB correctly', () => { 122 | const result = FormatUtils.fileSize(1048576); 123 | expect(result).toBe('1.00 MB'); 124 | }); 125 | 126 | it('should handle small byte values', () => { 127 | const result = FormatUtils.fileSize(100); 128 | expect(result).toBe('100 B'); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /tests/utils/math-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { MathUtils } from '../../src/utils/index.js'; 3 | 4 | describe('MathUtils', () => { 5 | describe('sum', () => { 6 | it('should correctly sum an array of numbers', () => { 7 | const input = [1, 2, 3, 4, 5]; 8 | const result = MathUtils.sum(input); 9 | expect(result).toBe(15); 10 | }); 11 | 12 | it('should return 0 for an empty array', () => { 13 | const result = MathUtils.sum([]); 14 | expect(result).toBe(0); 15 | }); 16 | 17 | it('should handle negative numbers correctly', () => { 18 | const input = [-1, -2, 3, 4]; 19 | const result = MathUtils.sum(input); 20 | expect(result).toBe(4); 21 | }); 22 | }); 23 | 24 | describe('clamp', () => { 25 | it('should return the input value when within range', () => { 26 | const result = MathUtils.clamp(5, 1, 10); 27 | expect(result).toBe(5); 28 | }); 29 | 30 | it('should return the min value when input is too low', () => { 31 | const result = MathUtils.clamp(0, 1, 10); 32 | expect(result).toBe(1); 33 | }); 34 | 35 | it('should return the max value when input is too high', () => { 36 | const result = MathUtils.clamp(15, 1, 10); 37 | expect(result).toBe(10); 38 | }); 39 | 40 | it('should handle negative ranges correctly', () => { 41 | const result = MathUtils.clamp(-5, -10, -2); 42 | expect(result).toBe(-5); 43 | }); 44 | }); 45 | 46 | describe('range', () => { 47 | it('should create an array of sequential numbers from start', () => { 48 | const result = MathUtils.range(5, 3); 49 | expect(result).toEqual([5, 6, 7]); 50 | }); 51 | 52 | it('should create an empty array when size is 0', () => { 53 | const result = MathUtils.range(10, 0); 54 | expect(result).toEqual([]); 55 | }); 56 | 57 | it('should handle negative start values', () => { 58 | const result = MathUtils.range(-3, 4); 59 | expect(result).toEqual([-3, -2, -1, 0]); 60 | }); 61 | }); 62 | 63 | describe('ceilToMultiple', () => { 64 | it('should round up to the nearest multiple', () => { 65 | const result = MathUtils.ceilToMultiple(14, 5); 66 | expect(result).toBe(15); 67 | }); 68 | 69 | it('should not change value already at multiple', () => { 70 | const result = MathUtils.ceilToMultiple(15, 5); 71 | expect(result).toBe(15); 72 | }); 73 | 74 | it('should handle decimal inputs correctly', () => { 75 | const result = MathUtils.ceilToMultiple(10.5, 5); 76 | expect(result).toBe(15); 77 | }); 78 | 79 | it('should handle negative values correctly', () => { 80 | const result = MathUtils.ceilToMultiple(-12, 5); 81 | expect(result).toBe(-10); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /tests/utils/random-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 | import { RandomUtils } from '../../src/utils/index.js'; 3 | 4 | // Mock any configs that might be loaded 5 | vi.mock('../../config/config.json', () => ({})); 6 | vi.mock('../../config/debug.json', () => ({})); 7 | vi.mock('../../lang/logs.json', () => ({})); 8 | 9 | describe('RandomUtils', () => { 10 | // Store the original Math.random function 11 | const originalRandom = Math.random; 12 | 13 | // After each test, restore the original Math.random 14 | afterEach(() => { 15 | Math.random = originalRandom; 16 | }); 17 | 18 | describe('intFromInterval', () => { 19 | it('should return a number within the specified range', () => { 20 | // Test with a range of values 21 | for (let i = 0; i < 100; i++) { 22 | const min = 5; 23 | const max = 10; 24 | const result = RandomUtils.intFromInterval(min, max); 25 | 26 | expect(result).toBeGreaterThanOrEqual(min); 27 | expect(result).toBeLessThanOrEqual(max); 28 | expect(Number.isInteger(result)).toBe(true); 29 | } 30 | }); 31 | 32 | it('should use Math.random correctly', () => { 33 | // Mock Math.random to return a specific value 34 | Math.random = vi.fn().mockReturnValue(0.5); 35 | 36 | const result = RandomUtils.intFromInterval(1, 10); 37 | 38 | // With Math.random() = 0.5, we expect it to return the middle value 39 | // 1 + Math.floor(0.5 * (10 - 1 + 1)) = 1 + Math.floor(5) = 1 + 5 = 6 40 | expect(result).toBe(6); 41 | expect(Math.random).toHaveBeenCalled(); 42 | }); 43 | 44 | it('should handle min equal to max', () => { 45 | const result = RandomUtils.intFromInterval(5, 5); 46 | expect(result).toBe(5); 47 | }); 48 | 49 | it('should handle negative ranges', () => { 50 | Math.random = vi.fn().mockReturnValue(0.5); 51 | 52 | const result = RandomUtils.intFromInterval(-10, -5); 53 | 54 | // With Math.random() = 0.5, and range of -10 to -5 (6 numbers) 55 | // -10 + Math.floor(0.5 * (-5 - -10 + 1)) = -10 + Math.floor(0.5 * 6) = -10 + 3 = -7 56 | expect(result).toBe(-7); 57 | }); 58 | }); 59 | 60 | describe('shuffle', () => { 61 | it('should maintain the same elements after shuffling', () => { 62 | const original = [1, 2, 3, 4, 5]; 63 | const shuffled = RandomUtils.shuffle([...original]); 64 | 65 | // Check that no elements were added or removed 66 | expect(shuffled.length).toBe(original.length); 67 | original.forEach(item => { 68 | expect(shuffled).toContain(item); 69 | }); 70 | }); 71 | 72 | it('should shuffle elements based on Math.random', () => { 73 | // Create a predictable sequence of random values 74 | const randomValues = [0.5, 0.1, 0.9, 0.3]; 75 | let callCount = 0; 76 | Math.random = vi.fn().mockImplementation(() => { 77 | return randomValues[callCount++ % randomValues.length]; 78 | }); 79 | 80 | const original = [1, 2, 3, 4]; 81 | const shuffled = RandomUtils.shuffle([...original]); 82 | 83 | // With our mocked random sequence, we can predict the shuffle outcome 84 | // This relies on the specific Fisher-Yates implementation 85 | expect(shuffled).not.toEqual(original); 86 | expect(Math.random).toHaveBeenCalled(); 87 | }); 88 | 89 | it('should handle empty arrays', () => { 90 | const result = RandomUtils.shuffle([]); 91 | expect(result).toEqual([]); 92 | }); 93 | 94 | it('should handle single-element arrays', () => { 95 | const result = RandomUtils.shuffle([1]); 96 | expect(result).toEqual([1]); 97 | }); 98 | 99 | it('should return the input array reference', () => { 100 | const input = [1, 2, 3]; 101 | const result = RandomUtils.shuffle(input); 102 | expect(result).toBe(input); // Same reference 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /tests/utils/regex-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { RegexUtils } from '../../src/utils/index.js'; 3 | 4 | // Mock any configs that might be loaded 5 | vi.mock('../../config/config.json', () => ({})); 6 | vi.mock('../../config/debug.json', () => ({})); 7 | vi.mock('../../lang/logs.json', () => ({})); 8 | 9 | describe('RegexUtils', () => { 10 | describe('discordId', () => { 11 | it('should extract a valid Discord ID', () => { 12 | const input = 'User ID: 123456789012345678'; 13 | const result = RegexUtils.discordId(input); 14 | expect(result).toBe('123456789012345678'); 15 | }); 16 | 17 | it('should return undefined for invalid Discord ID', () => { 18 | const input = 'User ID: 12345'; 19 | const result = RegexUtils.discordId(input); 20 | expect(result).toBeUndefined(); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/utils/string-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { StringUtils } from '../../src/utils/index.js'; 3 | 4 | describe('StringUtils', () => { 5 | describe('truncate', () => { 6 | it('should return the input string when shorter than the specified length', () => { 7 | const input = 'Hello, world!'; 8 | const result = StringUtils.truncate(input, 20); 9 | expect(result).toBe(input); 10 | }); 11 | 12 | it('should truncate the string to the specified length', () => { 13 | const input = 'Hello, world!'; 14 | const result = StringUtils.truncate(input, 5); 15 | expect(result).toBe('Hello'); 16 | expect(result.length).toBe(5); 17 | }); 18 | 19 | it('should add ellipsis when specified', () => { 20 | const input = 'Hello, world!'; 21 | const result = StringUtils.truncate(input, 8, true); 22 | expect(result).toBe('Hello...'); 23 | expect(result.length).toBe(8); 24 | }); 25 | 26 | it('should handle edge case of empty string', () => { 27 | const input = ''; 28 | const result = StringUtils.truncate(input, 5); 29 | expect(result).toBe(''); 30 | }); 31 | 32 | it('should handle exact length input correctly', () => { 33 | const input = 'Hello'; 34 | const result = StringUtils.truncate(input, 5); 35 | expect(result).toBe('Hello'); 36 | expect(result.length).toBe(5); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "module": "es2022", 5 | "lib": ["es2021"], 6 | "declaration": true, 7 | "sourceMap": true, 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "strict": false, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true 17 | }, 18 | "exclude": ["dist", "node_modules", "tests", "vitest.config.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "noEmit": true 6 | }, 7 | "include": ["src/**/*", "tests/**/*", "vitest.config.ts"], 8 | "exclude": ["node_modules", "dist"] 9 | } -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'node', 6 | include: ['tests/**/*.test.ts'], 7 | exclude: ['node_modules', 'dist'], 8 | typecheck: { 9 | tsconfig: './tsconfig.test.json', 10 | }, 11 | }, 12 | }); 13 | --------------------------------------------------------------------------------