├── .env.example ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docker-compose.yml ├── locales ├── en.json └── pt.json ├── package-lock.json ├── package.json ├── src ├── bot.ts ├── commands │ ├── Ban.ts │ ├── Config.ts │ ├── DeleteTexts.ts │ ├── Generate.ts │ ├── Info.ts │ ├── Ping.ts │ ├── Populate.ts │ ├── Tracking.ts │ ├── Unban.ts │ └── subcommands │ │ └── config │ │ ├── Chance.ts │ │ ├── Channel.ts │ │ ├── Disable.ts │ │ ├── Enable.ts │ │ ├── Limit.ts │ │ ├── Webhook.ts │ │ └── chance │ │ ├── Collect.ts │ │ ├── Reply.ts │ │ └── Sending.ts ├── events │ ├── GuildCreate.ts │ ├── GuildDelete.ts │ ├── Interaction.ts │ ├── Message.ts │ ├── MessageDelete.ts │ ├── MessageDeleteBulk.ts │ ├── MessageUpdate.ts │ ├── Ready.ts │ └── status.json ├── handlers │ ├── CommandHandler.ts │ └── EventHandler.ts ├── index.ts ├── interfaces │ ├── ClientInterface.ts │ ├── CommandInterface.ts │ ├── SpecialEventInterface.ts │ ├── SubCommandGroupInterface.ts │ └── SubCommandInterface.ts ├── modules │ ├── cryptography │ │ └── index.ts │ ├── database │ │ ├── DatabaseConnection.ts │ │ ├── DatabaseManager.ts │ │ ├── GuildDatabase.ts │ │ └── models │ │ │ ├── BansModel.ts │ │ │ ├── ConfigModel.ts │ │ │ ├── NoTrackModel.ts │ │ │ └── TextsModel.ts │ ├── markov │ │ └── MarkovChains.ts │ └── specialEvents │ │ ├── events │ │ ├── BaseSpecialEvent.ts │ │ └── Time.ts │ │ └── index.ts ├── structures │ ├── Command.ts │ ├── Event.ts │ ├── SubCommand.ts │ └── SubCommandGroup.ts ├── typings │ └── index.d.ts └── utils │ └── deleteAt.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Your bot token 2 | BOT_TOKEN="" 3 | 4 | # Test bot token 5 | # TEST_BOT_TOKEN="" 6 | 7 | # Top.gg token 8 | # TOPGG_TOKEN="" 9 | 10 | # Guild log webhook 11 | # SERVER_LOG="" 12 | 13 | # MongoDB URI 14 | DB_URI="" 15 | 16 | # 128-bit hexadecimal password 17 | # Tip: you can use the Crypto method `crypto.randomBytes(16).toString("hex")` 18 | CRYPTO_SECRET="" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | test.js 4 | test.ts 5 | .env -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This page defines a few things to contribute the code. It lets the code organized and easy for new contributors. It's not the most rigorous documentation, but it's to not make everything a mess. 2 | 3 | You can contribute via **Issues** or **Pull Requests**. If you want to contribute via pull requests, **make sure to suggest the changes to the `develop` branch**. 4 | 5 | **Note:** if you don't agree with this, feel free to fork the project and make any changes you want. 6 | 7 | # Translation 8 | The localization files are located in [/locales/](./locales/). 9 | 10 | To start the translation, choose a locale file that you want to translate — it's recommended that you use the English file as the basis for translating to the desired language — and then make the changes. You can adapt sentences to fit more in your language as long as it doesn't lose it's meaning. 11 | 12 | **DON'T use Google Translate all time**. It's ok to use the translator sometimes, but the translation can lose some of the meaning, you must always review it. If you don't know how to translate, I don't recommend you to make any change. 13 | 14 | If you see a pull request for a language you understand, if necessary, please make suggestions to improve the translation. 15 | 16 | # Code 17 | The code definitions and descriptions must be maintained in English. Bot files are located in [/src/](./src/). 18 | 19 | If you want to contribute to the code, always try to maintain a clean code, the OOP structure, and typing. I'm not the best at this either, but you can review my code and that of the contributors to improve quality and readability. 20 | 21 | If your changes are *major* — like new commands or systems — make sure to open an **Issue** first to discuss the changes. If there's explicit approval from the repository maintainers, you or anyone else can proceed with a pull request. This avoids unwanted or unnecessary features and saves coding time. 22 | 23 | **Before writing any code, please review the bot code to know some patterns.** -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 knownasbot 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | **Markov BOT** is a Discord bot that uses [Markov chains](https://en.wikipedia.org/wiki/Markov_chain) to generate random sentences in the chat. 3 | 4 | The bot randomly collects messages written by users and builds a *probability tree* used in the text generation. Firstly it randomly selects a random word and tries to select the most likely next word. 5 | 6 | This project is inspired by **[nMarkov](https://top.gg/bot/569277281046888488)**. After it was offline for a while, my friends said me to remake the bot for our guild. They also suggested making it public and I was surprised the bot grew fast. 7 | 8 | I also decided to develop this project to study **TypeScript** and **OOP**, then make the project source code available to everyone who wants to host their own instance. The code is not the best, but feel free to contribute and improve it. 9 | 10 | Depending on when you are reading this, you still can add the bot on your guild: **[Top.gg page](https://top.gg/bot/903354338565570661)**. 11 | 12 | ## Hosting your own instance 13 | If you want to download the code and run it in your own bot or make changes, it's very simple: 14 | 15 | ### Requirements 16 | - [Node.JS](https://nodejs.org/) v16+; 17 | - A [Discord bot](https://discord.com/developers/docs/getting-started); 18 | - A [MongoDB](https://mongodb.com/) database; 19 | - Hosting service (to make the bot be online 24/7) or any device that supports Node.JS. 20 | 21 | ### Configuring the environment 22 | First, you need to install [git](https://git-scm.com/) and then clone this repository with the command `git clone https://github.com/knownasbot/markov-bot` or by clicking the **Download** button. 23 | 24 | Go inside the repository folder and install the dependencies with `npm install`. You need to have [Node.JS](https://nodejs.org/) installed on your computer/server. 25 | 26 | Copy the file `.env.example` and rename it to `.env`. Open the file in a text editor and fill the variables `BOT_TOKEN`, `DB_URI` and `CRYPTO_SECRET`. Variables with `#` at the beginning are optional. 27 | 28 | You need a 128-bit hex string to make the stored texts secure. You can generate it using any tool, or with Crypto module of NodeJS: 29 | ```js 30 | crypto.randomBytes(16).toString("hex"); 31 | // It will generate strings like: 32 | // '0c98812d1bc43fd95d073eb183ff2087' 33 | // 'f901e4e08421baa5ac096f62512da563' 34 | // '3b982b6a86ce54c015aa0273814a8e9c' 35 | // ... 36 | ``` 37 | 38 | Pick the generated hex and put it in the `CRYPTO_SECRET` variable. 39 | 40 | ### Starting the bot 41 | After configuring the environment, build the bot code to JavaScript with the command `npm run build`. It will be transpiled to the folder `./dist/`. 42 | 43 | Start the bot with `npm start` and have fun! 44 | 45 | ### Docker Setup 46 | First you need to have Docker and Docker-Compose installed in you're pc. 47 | - You can view the Docker Installation Docs be clicking [here](https://docs.docker.com/engine/install/). 48 | - Also the Docker Compose Docs can be viewed by clicking [here](https://docs.docker.com/compose/install/). 49 | 50 | After the Docker Setup you need open a terminal inside Docker Folder where you cloned the repo to build the docker container be using `docker build --no-cache -t knownasbot/markov-bot .`. 51 | 52 | When the process of build is finished you can run the bot docker container by using the following method: 53 | - `docker-compose up -d` but require you need to change the `.env.example` to `.env` and fill the variables `BOT_TOKEN`, `DB_URI` and `CRYPTO_SECRET` [like sayed above](https://github.com/knownasbot/markov-bot#configuring-the-environment). 54 | 55 | ## Contributing 56 | If you want to contribute by improving the code or translating texts to other languages, see the **[Contributing](/CONTRIBUTING.md)** before doing anything. 57 | 58 | You can donate to me at my **[Buy Me A Coffee](https://buymeacoffee.com/knownasbot)** page. You can also support the contributors on their profiles or by contacting them. -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | services: 3 | markov_bot: 4 | image: "node:18" 5 | working_dir: /workspace/node/markov-bot 6 | env_file: 7 | - .env 8 | volumes: 9 | - ./:/workspace/node/markov-bot 10 | command: "npm run docker-run" -------------------------------------------------------------------------------- /locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "en-US", 3 | "vars": { 4 | "error": "An error occurred when I tried to execute this operation. Please, try again in a while.", 5 | "enabled": "Enabled", 6 | "disabled": "Disabled", 7 | "undefined": "Undefined", 8 | "gettingStarted": "Getting Started", 9 | "support": "Support", 10 | "tos": "Terms of Service", 11 | "privacyPolicy": "Privacy Policy" 12 | }, 13 | "commands": { 14 | "ban": { 15 | "command": { 16 | "name": "ban", 17 | "description": "Bans a server from using the bot.", 18 | "options": [ 19 | { 20 | "name": "id", 21 | "description": "Guild's id." 22 | }, 23 | { 24 | "name": "reason", 25 | "description": "Ban reason." 26 | } 27 | ] 28 | }, 29 | "texts": { 30 | "error": "Unfortunately this guild is banned from using my services :/\n\n**Reason:** `{{reason}}`.", 31 | "success": "`{{guild}}` has been banned!\n\n**Reason:** `{{reason}}`." 32 | } 33 | }, 34 | "chance": { 35 | "command": { 36 | "name": "chance", 37 | "description": "Defines the chances." 38 | } 39 | }, 40 | "channel": { 41 | "command": { 42 | "name": "channel", 43 | "description": "Defines the channel the bot will send and collect the messages.", 44 | "options": [ 45 | { 46 | "name": "channel", 47 | "description": "Channel to be defined." 48 | } 49 | ] 50 | }, 51 | "text": "The channel {{channel}} has been defined to collect and send messages." 52 | }, 53 | "config": { 54 | "command": { 55 | "name": "config", 56 | "description": "Configures the bot on the server." 57 | }, 58 | "error": "You have no permission (**Administrator**) to use this command." 59 | }, 60 | "collectChance": { 61 | "command": { 62 | "name": "collect", 63 | "description": "Chance to collect a message.", 64 | "options": [ 65 | { 66 | "name": "chance", 67 | "description": "Chance to be defined." 68 | } 69 | ] 70 | }, 71 | "text": "Chance to collect a message defined to `{{chance}}%`." 72 | }, 73 | "deleteTexts": { 74 | "command": { 75 | "name": "deletetexts", 76 | "description": "Delete all the texts stored.", 77 | "options": [ 78 | { 79 | "name": "member", 80 | "description": "Delete all the member stored texts." 81 | } 82 | ] 83 | }, 84 | "texts": { 85 | "confirmButton": "Confirm", 86 | "cancelButton": "Cancel", 87 | "deleteButton": "Delete", 88 | "deleteAllButton": "Delete All", 89 | "messageButton": "Message", 90 | "myMessages": "My Messages", 91 | "confirmation": "Are you sure that you want to delete `{{textsLength}}` stored messages?", 92 | "noTexts": "There's no texts stored.", 93 | "noPermission": "You are not allowed to delete messages.\n\nIf you want to delete your stored messages, click the **My Messages** button.", 94 | "notFound": "Message not found. Maybe it's already deleted 🤔", 95 | "success": "Successfully deleted the message of id `{{id}}`.", 96 | "successAll": "`{{textsLength}}` stored messages were deleted successfully.", 97 | "expired": "Type the command again to manage the stored messages.", 98 | "textInfo": "Author: {{author}} - Added at: {{date}}", 99 | "cancel": "Operation canceled." 100 | } 101 | }, 102 | "disable": { 103 | "command": { 104 | "name": "disable", 105 | "description": "Disables the collection and sending of messages." 106 | }, 107 | "text": "Collection and sending of messages disabled." 108 | }, 109 | "donate": { 110 | "title": "Donate!", 111 | "cryptoTitle": "Don't use fiat currencies? No problem! XD", 112 | "description": "Initially suggested by friends, I became bigger. With bigger applications, bigger is the requirement for maintenance.\n\nCurrently, I'm being hosted by my developer's friend, {{friendURL}} (thx!), but a day this dependence will need to go. That's why I need your support! *Since my developer is still a student xd*.\n\n{{urls}}", 113 | "footer": "If you don't have conditions, your appreciation is already very welcome!" 114 | }, 115 | "enable": { 116 | "command": { 117 | "name": "enable", 118 | "description": "Enables the collection and sending of messages." 119 | }, 120 | "text": "Collection and sending of messages enabled." 121 | }, 122 | "generate": { 123 | "command": { 124 | "name": "gen", 125 | "description": "Generates a message.", 126 | "options": [ 127 | { 128 | "name": "limit", 129 | "description": "Max. number of words in the text." 130 | } 131 | ] 132 | }, 133 | "error": "I don't have enough content to generate a text :(" 134 | }, 135 | "info": { 136 | "command": { 137 | "name": "info", 138 | "description": "Shows bot's information." 139 | }, 140 | "texts": { 141 | "title": "INFORMATION", 142 | "serverField": "SERVER", 143 | "softwareField": "SOFTWARE", 144 | "online": "\\> Online since: {{time}}", 145 | "serverSize": "\\> On approximately `{{size}}` servers.", 146 | "tracking": "\\> Collecting your messages: `{{state}}`.", 147 | "banned": "\\> This guild is banned: `{{reason}}`.", 148 | "state": "\\> State: `{{state}}`.", 149 | "channel": "\\> Defined channel: {{channel}}{{permission}}.", 150 | "webhook": "\\> Defined Webhook: `{{webhook}}`.", 151 | "textsLength": "\\> Stored texts: `{{length}}`.", 152 | "textsLimit": "\\> Texts limit: `{{limit}}`.", 153 | "collectChance": "\\> Collection chance: `{{chance}}`.", 154 | "sendingChance": "\\> Sending chance: `{{chance}}`.", 155 | "replyChance": "\\> Reply chance: `{{chance}}`.", 156 | "nodeVersion": "\\> Node.js version: `{{version}}`.", 157 | "djsVersion": "\\> Discord.js version: `{{version}}`.", 158 | "memUsage": "\\> Memory usage: `{{mem}}`.", 159 | "developer": "\\> Developer: {{dev}}.", 160 | "donate": "Donate", 161 | "faq": "FAQ", 162 | "cutecats": "Cute Cats", 163 | "nopermission": "no permission" 164 | } 165 | }, 166 | "limit": { 167 | "command": { 168 | "name": "limit", 169 | "description": "Defines the stored messages limit.", 170 | "options": [ 171 | { 172 | "name": "limit", 173 | "description": "Messages limit (recommended: {{recommend}})" 174 | } 175 | ] 176 | }, 177 | "text": "Stored messages limit defined to `{{limit}}`.\n\n**Tip:** lower limits increase the chance of texts with more sense." 178 | }, 179 | "ping": { 180 | "command": { 181 | "name": "ping", 182 | "description": "Shows the latency between the bot and Discord." 183 | }, 184 | "text": "🏓 **Ping**: {{ping}} ms." 185 | }, 186 | "populate": { 187 | "command": { 188 | "name": "populate", 189 | "description": "Populates the database with random texts.", 190 | "options": [ 191 | { 192 | "name": "amount", 193 | "description": "Number of texts to add." 194 | }, 195 | { 196 | "name": "member", 197 | "description": "Sets the author of the generated texts." 198 | } 199 | ] 200 | }, 201 | "text": "Added `{{amount}}` texts to database." 202 | }, 203 | "replyChance": { 204 | "command": { 205 | "name": "reply", 206 | "description": "Chance to reply a message when mentioned.", 207 | "options": [ 208 | { 209 | "name": "chance", 210 | "description": "Chance to be defined." 211 | } 212 | ] 213 | }, 214 | "text": "Chance to reply a message defined to `{{chance}}%`." 215 | }, 216 | "sendChance": { 217 | "command": { 218 | "name": "send", 219 | "description": "Chance to send a message.", 220 | "options": [ 221 | { 222 | "name": "chance", 223 | "description": "Chance to be defined." 224 | } 225 | ] 226 | }, 227 | "text": "Chance to send a message defined to `{{chance}}%`." 228 | }, 229 | "tracking": { 230 | "command": { 231 | "name": "tracking", 232 | "description": "Toggles whether the bot should collect your messages or not." 233 | }, 234 | "texts": { 235 | "enabled": "I'll no longer collect your messages. 😔", 236 | "disabled": "I'll collect your messages again. 😊" 237 | } 238 | }, 239 | "unban": { 240 | "command": { 241 | "name": "unban", 242 | "description": "Unbans a server from using the bot.", 243 | "options": [ 244 | { 245 | "name": "id", 246 | "description": "Guild's id." 247 | } 248 | ] 249 | }, 250 | "texts": { 251 | "success": "`{{guild}}` has been unbanned!" 252 | } 253 | }, 254 | "webhook": { 255 | "command": { 256 | "name": "webhook", 257 | "description": "Defines a Webhook to send messages.", 258 | "options": [ 259 | { 260 | "description": "Webhook URL." 261 | } 262 | ] 263 | }, 264 | "texts": { 265 | "disabled": "Webhook disabled!", 266 | "error": "This Webhook looks invalid 🤔 Make sure the URL is valid and the Webhook exists.", 267 | "guildError": "This Webhook is not from this guild 🤔", 268 | "success": "Webhook `{{name}}` defined! Have fun 🤭\n\nTo disable again you just need to type the same command without parameters 👌" 269 | } 270 | } 271 | }, 272 | "events": { 273 | "welcome": "**Thanks for adding me to the server!**\n\nType {{channelCommand}} to set up a channel and then {{enableCommand}} to enable me 😁. After collecting {{min}} messages, I'll start generating content 👀. Type {{infoCommand}} for more information.\n\nFor every 30 days of inactivity we delete the stored texts. If you want to delete the texts, use the command {{deleteCommand}}.", 274 | "faq": { 275 | "title": "FAQ", 276 | "description": "Questions? Maybe this can help you. Unlisted questions can be asked in my server.", 277 | "fields": [ 278 | { 279 | "name": "How do I delete stored messages?", 280 | "value": "You can delete stored messages using the command {{deleteCommand}}. Providing the parameter `{{parameter}}` allows you to delete messages from yourself or a member." 281 | }, 282 | { 283 | "name": "I don't want it to collect my messages!", 284 | "value": "To disable the collection of your messages, type {{trackingCommand}}. Type the same command to enable it again." 285 | }, 286 | { 287 | "name": "Are the messages updated?", 288 | "value": "Yes. When any message is edited or deleted, if the bot is online and enabled, it will update the stored data." 289 | }, 290 | { 291 | "name": "Does this bot use any complex AI?", 292 | "value": "No. The bot uses [Markov Chains](https://en.wikipedia.org/wiki/Markov_chain), a very simple algorithm that works with probabilities, used in the text generation." 293 | }, 294 | { 295 | "name": "He's generating many non-sense sentences!", 296 | "value": "Currently there's no filter of what is a correct sentence or not, so the bot will randomly try to generate something, which sometimes relates to the context. A lower limit of texts is recommended so he can \"assembles the puzzle\" more accurately." 297 | }, 298 | { 299 | "name": "How was this bot made?", 300 | "value": "The bot was developed using [TypeScript](https://www.typescriptlang.org) (an improvement on JavaScript), using [Discord.js](https://discord.js.org) in [Node.js](https://nodejs.org/en/about). Initially the database was a [JSON](https://pt.wikipedia.org/wiki/JSON) file, but soon [MongoDB](https://en.wikipedia.org/wiki/MongoDB) was used. Currently hosted on a [friend's]({{friendURL}}) host at [DigitalOcean](https://digitalocean.com/)." 301 | }, 302 | { 303 | "name": "Do you have any plans to scale the bot?", 304 | "value": "I'm uncertain about this. The bot started as a study and hobby suggested by friends. As I'm still a young student, I can't afford to keep the bot for a long time, but I'm trying to do my best. The source code is on GitHub if you want to host your own instance." 305 | } 306 | ] 307 | } 308 | } 309 | } -------------------------------------------------------------------------------- /locales/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "pt-BR", 3 | "vars": { 4 | "error": "Um erro ocorreu enquanto eu tentava executar esta operação. Tente novamente em um tempo.", 5 | "enabled": "Habilitado", 6 | "disabled": "Desabilitado", 7 | "undefined": "Indefinido", 8 | "gettingStarted": "Introdução", 9 | "support": "Suporte", 10 | "tos": "Termos de Serviço", 11 | "privacyPolicy": "Política de Privacidade" 12 | }, 13 | "commands": { 14 | "ban": { 15 | "command": { 16 | "name": "banir", 17 | "description": "Bane o uso do bot em um servidor.", 18 | "options": [ 19 | { 20 | "name": "id", 21 | "description": "Id do servidor." 22 | }, 23 | { 24 | "name": "motivo", 25 | "description": "Motivo da suspensão." 26 | } 27 | ] 28 | }, 29 | "texts": { 30 | "error": "Infelizmente este servidor está impedido de utilizar minhas funções :/\n\n**Motivo:** `{{reason}}`.", 31 | "success": "`{{guild}}` foi banido!\n\n**Motivo:** `{{reason}}`." 32 | } 33 | }, 34 | "chance": { 35 | "command": { 36 | "name": "chance", 37 | "description": "Configura as chances." 38 | } 39 | }, 40 | "channel": { 41 | "command": { 42 | "name": "canal", 43 | "description": "Configura o canal em que o bot vai enviar e coletar mensagens.", 44 | "options": [ 45 | { 46 | "name": "canal", 47 | "description": "Canal a ser configurado." 48 | } 49 | ] 50 | }, 51 | "text": "O canal {{channel}} foi configurado para ser enviado e coletado mensagens." 52 | }, 53 | "config": { 54 | "command": { 55 | "name": "configurar", 56 | "description": "Configura o bot no servidor." 57 | }, 58 | "error": "Você não tem permissão (**Administrator**) para utilizar esse comando." 59 | }, 60 | "collectChance": { 61 | "command": { 62 | "name": "coleta", 63 | "description": "Chance de coletar uma mensagem do chat.", 64 | "options": [ 65 | { 66 | "name": "chance", 67 | "description": "Chance a ser definida." 68 | } 69 | ] 70 | }, 71 | "text": "Chance de coleta definida para `{{chance}}%`." 72 | }, 73 | "deleteTexts": { 74 | "command": { 75 | "name": "deletartextos", 76 | "description": "Deleta todos os textos armazenados.", 77 | "options": [ 78 | { 79 | "name": "membro", 80 | "description": "Deleta textos armazenados de um membro em específico." 81 | } 82 | ] 83 | }, 84 | "texts": { 85 | "confirmButton": "Confirmar", 86 | "cancelButton": "Cancelar", 87 | "deleteButton": "Deletar", 88 | "deleteAllButton": "Deletar Tudo", 89 | "messageButton": "Mensagem", 90 | "myMessages": "Minhas Mensagens", 91 | "confirmation": "Tem certeza que deseja apagar `{{textsLength}}` mensagens do banco de dados?", 92 | "noTexts": "Não há textos para deletar.", 93 | "noPermission": "Você não possui permissão para apagar as mensagens.\n\nCaso queira deletar suas mensagens armazenadas, clique em **Minhas Mensagens**.", 94 | "notFound": "Mensagem não encontrada, Talvez já esteja deletada 🤔", 95 | "success": "Mensagem de id `{{id}}` deletada com sucesso.", 96 | "successAll": "Foram deletadas `{{textsLength}}` mensagens do banco de dados.", 97 | "expired": "Digite o comando novamente para gerenciar as mensagens armazenadas.", 98 | "textInfo": "Autor: {{author}} - Adicionado: {{date}}", 99 | "cancel": "Operação cancelada." 100 | } 101 | }, 102 | "disable": { 103 | "command": { 104 | "name": "desabilitar", 105 | "description": "Desabilita a coleta e envio de mensagens." 106 | }, 107 | "text": "Desabilitado a coleta e envio de mensagens." 108 | }, 109 | "donate": { 110 | "title": "Apoie!", 111 | "cryptoTitle": "Não usa moedas fiduciárias? Isso não é desculpa! XD", 112 | "description": "Surgido inicialmente como uma sugestão de amigos, eu me tornei algo muito maior. Com maiores aplicações, maior a demanda por manuntenções.\n\nAtualmente, estou sendo hospedado pelo amigo do meu desenvolvedor, {{friendURL}} (obrigado!), mas um dia essa dependência precisará acabar. Por isso, contamos com seu apoio! *Já que meu desenvolvedor ainda é um estudante xd*.\n\n{{urls}}", 113 | "footer": "Caso não tenha condições, sua apreciação já é muito bem vinda!" 114 | }, 115 | "enable": { 116 | "command": { 117 | "name": "habilitar", 118 | "description": "Habilita a coleta e envio de mensagens." 119 | }, 120 | "text": "Habilitado a coleta e envio de mensagens novamente." 121 | }, 122 | "generate": { 123 | "command": { 124 | "name": "gerar", 125 | "description": "Gera uma mensagem.", 126 | "options": [ 127 | { 128 | "name": "limite", 129 | "description": "Quantidade máxima de palavras no texto." 130 | } 131 | ] 132 | }, 133 | "error": "Não tenho conteúdo suficiente para gerar um texto :(" 134 | }, 135 | "info": { 136 | "command": { 137 | "name": "info", 138 | "description": "Exibe as informações do bot." 139 | }, 140 | "texts": { 141 | "title": "INFORMAÇÕES", 142 | "serverField": "SERVIDOR", 143 | "softwareField": "SOFTWARE", 144 | "online": "\\> Online desde: {{time}}", 145 | "serverSize": "\\> Em aproximadamente `{{size}}` servidores.", 146 | "tracking": "\\> Coletando suas mensagens: `{{state}}`.", 147 | "banned": "\\> Esse servidor está banido: `{{reason}}`.", 148 | "state": "\\> Estado: `{{state}}`.", 149 | "channel": "\\> Canal configurado: {{channel}}{{permission}}.", 150 | "webhook": "\\> Webhook configurado: `{{webhook}}`.", 151 | "textsLength": "\\> Textos armazenados: `{{length}}`.", 152 | "textsLimit": "\\> Limite de textos: `{{limit}}`.", 153 | "collectChance": "\\> Chance de coleta: `{{chance}}`.", 154 | "sendingChance": "\\> Chance de envio: `{{chance}}`.", 155 | "replyChance": "\\> Chance de resposta: `{{chance}}`.", 156 | "nodeVersion": "\\> Versão do Node.js: `{{version}}`.", 157 | "djsVersion": "\\> Versão do Discord.js: `{{version}}`.", 158 | "memUsage": "\\> Uso de memória: `{{mem}}`.", 159 | "developer": "\\> Desenvolvedor: {{dev}}.", 160 | "donate": "Apoie", 161 | "faq": "FAQ", 162 | "cutecats": "Gatinhos Fofos", 163 | "nopermission": "sem permissão" 164 | } 165 | }, 166 | "limit": { 167 | "command": { 168 | "name": "limite", 169 | "description": "Define o limite de mensagens armazenadas.", 170 | "options": [ 171 | { 172 | "name": "limite", 173 | "description": "Limite de mensagens (recomendado: {{recommend}})" 174 | } 175 | ] 176 | }, 177 | "text": "Limite de textos armazenados definido para `{{limit}}`.\n\n**Dica:** limites baixos tem mais chance de gerar frases com sentido." 178 | }, 179 | "ping": { 180 | "command": { 181 | "name": "latencia", 182 | "description": "Exibe a latência do bot com o Discord." 183 | }, 184 | "text": "🏓 **Latência**: {{ping}} ms." 185 | }, 186 | "populate": { 187 | "command": { 188 | "name": "popular", 189 | "description": "Popula o banco de dados com textos aleatórios.", 190 | "options": [ 191 | { 192 | "name": "quantia", 193 | "description": "Quantidade de textos." 194 | }, 195 | { 196 | "name": "membro", 197 | "description": "Define o autor dos textos gerados." 198 | } 199 | ] 200 | }, 201 | "text": "`{{amount}}` textos adicionados ao banco de dados." 202 | }, 203 | "replyChance": { 204 | "command": { 205 | "name": "resposta", 206 | "description": "Chance de responder uma mensagem do chat quando mencionado.", 207 | "options": [ 208 | { 209 | "name": "chance", 210 | "description": "Chance a ser definida." 211 | } 212 | ] 213 | }, 214 | "text": "Chance de resposta definida para `{{chance}}%`." 215 | }, 216 | "sendChance": { 217 | "command": { 218 | "name": "envio", 219 | "description": "Chance de enviar uma mensagem no chat.", 220 | "options": [ 221 | { 222 | "name": "chance", 223 | "description": "Chance a ser definida." 224 | } 225 | ] 226 | }, 227 | "text": "Chance de envio definida para `{{chance}}%`." 228 | }, 229 | "tracking": { 230 | "command": { 231 | "name": "rastreamento", 232 | "description": "Define se o bot deve coletar suas mensagens ou não." 233 | }, 234 | "texts": { 235 | "enabled": "Não irei mais coletar suas mensagens. 😔", 236 | "disabled": "Irei coletar suas mensagens novamente. 😊" 237 | } 238 | }, 239 | "unban": { 240 | "command": { 241 | "name": "desbanir", 242 | "description": "Desbane o uso do bot em um servidor.", 243 | "options": [ 244 | { 245 | "name": "id", 246 | "description": "Id do servidor." 247 | } 248 | ] 249 | }, 250 | "texts": { 251 | "success": "`{{guild}}` foi desbanido!" 252 | } 253 | }, 254 | "webhook": { 255 | "command": { 256 | "name": "webhook", 257 | "description": "Configura um Webhook para enviar mensagens.", 258 | "options": [ 259 | { 260 | "description": "URL do Webhook." 261 | } 262 | ] 263 | }, 264 | "texts": { 265 | "disabled": "Webhook desativado!", 266 | "error": "Esse Webhook parece inválido 🤔 Verifique novamente se a URL está correta e se o Webhook existe.", 267 | "guildError": "Esse Webhook não é dessa guilda 🤔", 268 | "success": "Webhook `{{name}}` configurado! Divirta-se com as peripécias 🤭\n\nPara desconfigurar é só digitar o mesmo comando sem passar argumentos 👌" 269 | } 270 | } 271 | }, 272 | "events": { 273 | "welcome": "**Obrigado por me adicionar no servidor!**\n\nDigite {{channelCommand}} para configurar o canal, e logo após digite {{enableCommand}} para me habilitar 😁. Após a coleta de no mínimo {{min}} mensagens, começarei a gerar conteúdo 👀. Digite {{infoCommand}} para ter mais informações.\n\nA cada 30 dias de inatividade deletamos os textos armazenados. Caso queira deletar os textos, utilize {{deleteCommand}}.", 274 | "faq": { 275 | "title": "FAQ", 276 | "description": "Dúvidas? Talvez isto te ajude. Dúvidas não listadas podem ser questionadas no meu servidor.", 277 | "fields": [ 278 | { 279 | "name": "Como deleto mensagens armazenadas?", 280 | "value": "Você pode deletar mensagens armazenadas utilizando o comando {{deleteCommand}}. Passando o parâmetro `{{parameter}}`, é possível deletar as mensagens suas ou de algum membro." 281 | }, 282 | { 283 | "name": "Como evito a coleta das minhas mensagens?", 284 | "value": "Para desabilitar a coleta de suas mensagens digite {{trackingCommand}}. Digita o mesmo comando para habilitar novamente." 285 | }, 286 | { 287 | "name": "As mensagens são atualizadas?", 288 | "value": "Sim. Quando alguma mensagem é editada ou deletada, se o bot estiver ligado e habilitado, ele irá atualizar os dados armazenados." 289 | }, 290 | { 291 | "name": "Esse bot utiliza alguma IA complexa?", 292 | "value": "Não. O bot utiliza [Cadeias de Markov](https://pt.wikipedia.org/wiki/Cadeias_de_Markov), um algoritmo bem simples que trabalha com probabilidades, utilizado na geração textos." 293 | }, 294 | { 295 | "name": "Ele está gerando muitas frases sem sentido!", 296 | "value": "Atualmente, não há um filtro de classificação do que é uma frase com sentido ou não, então o bot vai tentar aleatoriamente gerar algo, que as vezes se relaciona com o contexto. É recomendado um limite menor de textos configurado, para que assim ele \"monte o quebra-cabeça\" de forma mais precisa." 297 | }, 298 | { 299 | "name": "Como o bot foi feito?", 300 | "value": "O bot foi desenvolvido em [TypeScript](https://www.typescriptlang.org) (uma melhoria do JavaScript), usando [Discord.js](https://discord.js.org) no [Node.js](https://nodejs.org/en/about). Inicialmente o banco de dados era um arquivo [JSON](https://pt.wikipedia.org/wiki/JSON), mas logo passou a ser utilizado [MongoDB](https://pt.wikipedia.org/wiki/MongoDB). Atualmente hospedado na host de um [amigo]({{friendURL}}) na [DigitalOcean](https://digitalocean.com/)." 301 | }, 302 | { 303 | "name": "Pretende escalonar o bot?", 304 | "value": "Estou incerto sobre isso. O bot surgiu mais como um estudo e hobby sugerido por amigos. Como sou apenas um jovem estudante, não tenho muita condição de manter o bot por muito tempo, mas estou fazendo o possível. O código está hospedado no GitHub caso queira hospedar sua própria instância." 305 | } 306 | ] 307 | } 308 | } 309 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markov-bot", 3 | "version": "1.0.0", 4 | "description": "A bot that generate random texts on Discord chat.", 5 | "scripts": { 6 | "dev": "cross-env NODE_ENV=dev ts-node ./src/index.ts", 7 | "build": "tsc", 8 | "start": "node ./dist", 9 | "docker-run": "npm install && npm run build && npm run start" 10 | }, 11 | "author": "knownasbot", 12 | "license": "MIT", 13 | "dependencies": { 14 | "axios": "^0.26.1", 15 | "discord.js": "^13.14.0", 16 | "dotenv": "^16.0.0", 17 | "i18next": "^21.8.10", 18 | "i18next-fs-backend": "^1.1.4", 19 | "mongoose": "^6.2.0" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^18.0.0", 23 | "cross-env": "^7.0.3", 24 | "ts-node": "^10.9.1", 25 | "typescript": "^4.7.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/bot.ts: -------------------------------------------------------------------------------- 1 | import { Client, Intents, Collection } from "discord.js"; 2 | import * as i18next from "i18next"; 3 | import * as i18nbackend from "i18next-fs-backend"; 4 | 5 | import Cryptography from "./modules/cryptography"; 6 | import DatabaseConnection from "./modules/database/DatabaseConnection"; 7 | import DatabaseManager from "./modules/database/DatabaseManager"; 8 | 9 | import EventHandler from "./handlers/EventHandler"; 10 | import ClientInterface from "./interfaces/ClientInterface"; 11 | 12 | /** 13 | * Bot core. 14 | */ 15 | export default new class Bot { 16 | private sweeper = { 17 | interval: 3600, 18 | filter: () => () => true 19 | }; 20 | 21 | public client: ClientInterface = new Client({ 22 | allowedMentions: { parse: [] }, 23 | failIfNotExists: false, 24 | intents: [ 25 | Intents.FLAGS.GUILDS, 26 | Intents.FLAGS.GUILD_MESSAGES 27 | ], 28 | sweepers: { 29 | users: this.sweeper, 30 | messages: this.sweeper, 31 | guildMembers: { 32 | interval: 3600, 33 | filter: () => (member) => member.user.id != this.client.user.id 34 | } 35 | } 36 | }); 37 | 38 | constructor() { 39 | const { client } = this; 40 | 41 | client.config = { 42 | admins: [ "283740954328825858" ], 43 | devGuilds: [], 44 | links: { 45 | website: "https://knwbot.gitbook.io/markov-bot/", 46 | tos: "https://knwbot.gitbook.io/markov-bot/terms/terms-of-service", 47 | privacy: "https://knwbot.gitbook.io/markov-bot/terms/privacy-policy", 48 | github: "https://github.com/knownasbot/markov-bot", 49 | topgg: "https://top.gg/bot/903354338565570661", 50 | bmc: "https://www.buymeacoffee.com/knownasbot", 51 | support: "https://discord.gg/YEZmW7Suc3" 52 | }, 53 | emojis: { 54 | twitter: "<:twitter:960204380563460227>", 55 | github: "<:github:1033081923125391442>", 56 | topgg: "<:topgg:1016432122124320818>", 57 | bmc: "<:bmc:987493129772990464>", 58 | bitcoin: "<:bitcoin:958802392642617364>", 59 | ethereum: "<:ethereum:989195060857946174>" 60 | }, 61 | cryptoAddresses: { 62 | bitcoin: "bc1q69uu262ylvac5me8yj5ejjh9qjmuwtuepd2dfg", 63 | ethereum: "0xCD27fADFf2eDBE6625518A56BceE4237cf78252b" 64 | } 65 | }; 66 | client.crypto = new Cryptography(process.env.CRYPTO_SECRET); 67 | client.cooldown = new Collection(); 68 | client.commands = new Collection(); 69 | client.database = new DatabaseManager(client); 70 | 71 | this.loadLocales(); 72 | this.connectDatabase() 73 | .then(() => { 74 | console.log("[Database]", "Connected to database."); 75 | 76 | this.client.login(process.env.NODE_ENV == "dev" ? process.env.TEST_BOT_TOKEN : process.env.BOT_TOKEN); 77 | }); 78 | 79 | new EventHandler(client); 80 | } 81 | 82 | private loadLocales(): void { 83 | i18next 84 | .use(i18nbackend) 85 | .init({ 86 | initImmediate: false, 87 | lng: "en", 88 | fallbackLng: "en", 89 | preload: ["en", "pt"], 90 | backend: { 91 | loadPath: "./locales/{{lng}}.json" 92 | }, 93 | interpolation: { 94 | escapeValue: false 95 | } 96 | }, (err) => { 97 | if (err) { 98 | throw new Error("[i18n] Failed to load the translations: " + err); 99 | } 100 | 101 | this.client.i18n = i18next; 102 | }); 103 | } 104 | 105 | private connectDatabase(): Promise { 106 | const dbConnection = new DatabaseConnection(process.env.DB_URI); 107 | 108 | return dbConnection.connect(); 109 | } 110 | } -------------------------------------------------------------------------------- /src/commands/Ban.ts: -------------------------------------------------------------------------------- 1 | import Command from "../structures/Command"; 2 | 3 | import { CommandInteraction, PermissionResolvable } from "discord.js/typings"; 4 | import ClientInterface from "../interfaces/ClientInterface"; 5 | 6 | export default class BanCommand extends Command { 7 | public dev: boolean = true; 8 | public skipBan: boolean = true; 9 | public permissions: PermissionResolvable = "MANAGE_GUILD"; 10 | 11 | constructor(client: ClientInterface) { 12 | super( 13 | client, 14 | "commands.ban.command.name", 15 | "commands.ban.command.description", 16 | [ 17 | { 18 | type: "STRING", 19 | name: "commands.ban.command.options.0.name", 20 | description: "commands.ban.command.options.0.description", 21 | required: true 22 | }, 23 | { 24 | type: "STRING", 25 | name: "commands.ban.command.options.1.name", 26 | description: "commands.ban.command.options.1.description", 27 | required: true 28 | } 29 | ] 30 | ); 31 | } 32 | 33 | async run(interaction: CommandInteraction) { 34 | const guild = interaction.options.getString(this.options[0].name); 35 | const reason = interaction.options.getString(this.options[1].name); 36 | 37 | await this.client.database.ban(guild, reason); 38 | 39 | return interaction.reply( 40 | this.t("commands.ban.texts.success", { lng: interaction.locale, guild, reason }) 41 | ); 42 | } 43 | } -------------------------------------------------------------------------------- /src/commands/Config.ts: -------------------------------------------------------------------------------- 1 | import Command from "../structures/Command"; 2 | 3 | import ChannelSubCommand from "./subcommands/config/Channel"; 4 | import EnableSubCommand from "./subcommands/config/Enable"; 5 | import DisableSubCommand from "./subcommands/config/Disable"; 6 | import ChanceSubCommand from "./subcommands/config/Chance"; 7 | import WebhookSubCommand from "./subcommands/config/Webhook"; 8 | import LimitSubCommand from "./subcommands/config/Limit"; 9 | 10 | import { CommandInteraction, PermissionResolvable } from "discord.js/typings"; 11 | import ClientInterface from "../interfaces/ClientInterface"; 12 | import SubCommandInterface from "../interfaces/SubCommandInterface"; 13 | import SubCommandGroupInterface from "../interfaces/SubCommandGroupInterface"; 14 | 15 | export default class ConfigCommand extends Command { 16 | public permissions: PermissionResolvable = "MANAGE_GUILD"; 17 | 18 | private subcommands: Record; 19 | 20 | constructor(client: ClientInterface) { 21 | super( 22 | client, 23 | "commands.config.command.name", 24 | "commands.config.command.description" 25 | ); 26 | 27 | const subcommands = { 28 | channel: new ChannelSubCommand(client), 29 | enable: new EnableSubCommand(client), 30 | disable: new DisableSubCommand(client), 31 | chance: new ChanceSubCommand(client), 32 | webhook: new WebhookSubCommand(client), 33 | limit: new LimitSubCommand(client) 34 | }; 35 | 36 | const options = []; 37 | 38 | for (let key of Object.keys(subcommands)) { 39 | let subcommand: SubCommandInterface | SubCommandGroupInterface = subcommands[key]; 40 | options.push({ 41 | type: subcommand.type, 42 | name: subcommand.name, 43 | description: subcommand.description, 44 | options: subcommand.options 45 | }); 46 | } 47 | 48 | this.options = options; 49 | this.subcommands = subcommands; 50 | } 51 | 52 | async run(interaction: CommandInteraction) { 53 | const subCommandName = interaction.options.data[0].name; 54 | const subCommand = this.subcommands[subCommandName]; 55 | if (subCommand) return subCommand.run(interaction); 56 | } 57 | } -------------------------------------------------------------------------------- /src/commands/DeleteTexts.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageActionRow, MessageButton, MessageSelectMenu, SnowflakeUtil } from "discord.js"; 2 | import Command from "../structures/Command"; 3 | 4 | import { CommandInteraction, MessageSelectMenuOptions, MessageSelectOption } from "discord.js/typings"; 5 | import ClientInterface from "../interfaces/ClientInterface"; 6 | 7 | interface DecryptedText { 8 | id: string; 9 | author: string; 10 | decrypted: string; 11 | encrypted: string; 12 | }; 13 | 14 | export default class DeleteTextsCommand extends Command { 15 | constructor(client: ClientInterface) { 16 | super( 17 | client, 18 | "commands.deleteTexts.command.name", 19 | "commands.deleteTexts.command.description", 20 | [ 21 | { 22 | type: "USER", 23 | name: "commands.deleteTexts.command.options.0.name", 24 | description: "commands.deleteTexts.command.options.0.description" 25 | } 26 | ] 27 | ); 28 | } 29 | 30 | async run(interaction: CommandInteraction) { 31 | let currentPage = 0; 32 | const itemsPerPage = 25; 33 | 34 | const lng = { lng: interaction.locale }; 35 | const database = await this.client.database.fetch(interaction.guildId); 36 | const dbTexts = await database.getTexts(); 37 | 38 | let member = interaction.options.getUser(this.options[0].name)?.id; 39 | let deletePermission = false; 40 | if (member == interaction.user.id) { 41 | deletePermission = true; 42 | } else if (typeof interaction.member.permissions != "string") { 43 | deletePermission = interaction.member.permissions.has("MANAGE_MESSAGES"); 44 | } 45 | 46 | let texts = member ? dbTexts.filter((v) => member ? v.author == member : true) : dbTexts; 47 | if (texts.length < 1) { 48 | return interaction.reply({ 49 | content: this.t("commands.deleteTexts.texts.noTexts", lng), 50 | ephemeral: true 51 | }); 52 | } 53 | 54 | let components = this.getPageComponents(interaction.user.id, texts, !!member, currentPage, itemsPerPage, interaction.locale); 55 | 56 | const replyMessage = await interaction.reply({ fetchReply: true, components }) as Message; 57 | const collector = replyMessage.createMessageComponentCollector({ 58 | idle: 120000, 59 | filter: (i) => i.user.id == interaction.user.id 60 | }); 61 | 62 | collector.on("collect", async (i) => { 63 | if (i.isButton()) { 64 | if (i.customId == "deleteall") { 65 | if (!deletePermission) { 66 | await i.reply({ 67 | content: this.t("commands.deleteTexts.texts.noPermission", lng), 68 | ephemeral: true 69 | }); 70 | return; 71 | } 72 | 73 | const confirmRow = new MessageActionRow() 74 | .addComponents( 75 | new MessageButton({ 76 | customId: "confirm", 77 | label: this.t("commands.deleteTexts.texts.confirmButton", lng), 78 | style: "SUCCESS" 79 | }) 80 | ); 81 | 82 | const confirmMessage = await i.reply({ 83 | content: this.t("commands.deleteTexts.texts.confirmation", { ...lng, textsLength: texts.length }), 84 | ephemeral: true, 85 | fetchReply: true, 86 | components: [ confirmRow ] 87 | }) as Message; 88 | 89 | confirmMessage.createMessageComponentCollector({ idle: 60000 }) 90 | .on("collect", async (confirmInteraction) => { 91 | try { 92 | if (!member) await database.deleteAllTexts(); 93 | else await database.deleteUserTexts(member); 94 | 95 | await confirmInteraction.update({ 96 | content: this.t("commands.deleteTexts.texts.successAll", { ...lng, textsLength: texts.length }), 97 | components: [] 98 | }); 99 | 100 | texts = []; 101 | currentPage = 0; 102 | 103 | components = this.getPageComponents(interaction.user.id, texts, !!member, currentPage, itemsPerPage, interaction.locale); 104 | 105 | try { 106 | await replyMessage.edit({ components }); 107 | } catch {}; 108 | } catch(e) { 109 | await confirmInteraction.update({ 110 | content: this.t("vars.error", lng) 111 | }); 112 | } 113 | }) 114 | } else if (i.customId == "mytexts") { 115 | member = interaction.user.id; 116 | texts = texts.filter((v) => v.author == member); 117 | if (texts.length < 1) { 118 | await i.reply({ 119 | content: this.t("commands.deleteTexts.texts.noTexts", lng), 120 | ephemeral: true 121 | }); 122 | return; 123 | } 124 | 125 | currentPage = 0; 126 | deletePermission = true; 127 | 128 | components = this.getPageComponents(interaction.user.id, texts, true, currentPage, itemsPerPage, interaction.locale); 129 | await i.update({ components }); 130 | } else if (/first|last|previous|next/.test(i.customId)) { 131 | if (i.customId == "first") currentPage = 0; 132 | else if (i.customId == "last") currentPage = Math.ceil(texts.length / itemsPerPage) - 1; 133 | else if (i.customId == "next") currentPage++; 134 | else if (i.customId == "previous") currentPage--; 135 | 136 | components = this.getPageComponents(interaction.user.id, texts, !!member, currentPage, itemsPerPage, interaction.locale); 137 | await i.update({ components }); 138 | } 139 | } else if (i.isSelectMenu()) { 140 | const id = i.values[0]; 141 | const text = texts.find((v) => v.id == id); 142 | if (!text) { 143 | return i.reply({ 144 | content: this.t("commands.deleteTexts.texts.notFound", lng), 145 | ephemeral: true 146 | }); 147 | } 148 | 149 | const infoRow = new MessageActionRow() 150 | .addComponents( 151 | new MessageButton({ 152 | customId: "infou", 153 | label: text.author, 154 | style: "SECONDARY", 155 | emoji: "👤", 156 | disabled: true 157 | }), 158 | new MessageButton({ 159 | customId: "infot", 160 | label: new Date(SnowflakeUtil.timestampFrom(text.id)).toLocaleString(i.locale), 161 | style: "SECONDARY", 162 | emoji: "📆", 163 | disabled: true 164 | }), 165 | new MessageButton({ 166 | label: this.t("commands.deleteTexts.texts.messageButton", lng), 167 | style: "LINK", 168 | emoji: "💬", 169 | url: `https://discord.com/channels/${i.guildId}/${await database.getChannel()}/${id}` 170 | }), 171 | ); 172 | 173 | const buttonsRow = new MessageActionRow() 174 | .addComponents( 175 | new MessageButton({ 176 | customId: `delete-${id}`, 177 | label: this.t("commands.deleteTexts.texts.deleteButton", lng), 178 | style: "DANGER", 179 | disabled: text.author != i.user.id && !deletePermission 180 | }) 181 | ); 182 | 183 | if (text.decrypted.length > 2000) text.decrypted = text.decrypted.slice(0, 2000 -3) + "..."; 184 | 185 | const m = await i.reply({ 186 | content: text.decrypted, 187 | ephemeral: true, 188 | fetchReply: true, 189 | components: [ infoRow, buttonsRow ] 190 | }) as Message; 191 | 192 | const collector = m.createMessageComponentCollector({ 193 | max: 1, 194 | idle: 120000 195 | }); 196 | 197 | collector.on("collect", async (b) => { 198 | const id = b.customId.split("-")[1]; 199 | 200 | try { 201 | await database.deleteText(id); 202 | await b.update({ 203 | content: this.t("commands.deleteTexts.texts.success", { ...lng, id }), 204 | components: [] 205 | }); 206 | 207 | const idx = texts.findIndex((v) => v.id == id); 208 | if (idx != -1) texts.splice(idx, 1); 209 | 210 | components = this.getPageComponents(interaction.user.id, texts, !!member, currentPage, itemsPerPage, interaction.locale); 211 | 212 | try { 213 | await replyMessage.edit({ components }); 214 | } catch {}; 215 | } catch(e) { 216 | console.error(`[Commands] Failed to reply the interaction:\n`, e); 217 | 218 | await b.reply({ 219 | content: this.t("vars.error", lng), 220 | ephemeral: true 221 | }); 222 | } 223 | }); 224 | } 225 | }); 226 | 227 | collector.on("end", async (_, reason) => { 228 | if (reason == "idle") { 229 | const rows = replyMessage.components; 230 | for (let row of rows) { 231 | for (let component of row.components) { 232 | component.disabled = true; 233 | } 234 | } 235 | 236 | try { 237 | await replyMessage.edit({ 238 | content: this.t("commands.deleteTexts.texts.expired", lng), 239 | components: rows 240 | }); 241 | } catch {}; 242 | } 243 | }); 244 | } 245 | 246 | private getPageComponents(author: string, texts: DecryptedText[], hasMember: boolean, page: number, itemsPerPage: number, locale: string): MessageActionRow[] { 247 | texts.sort((a, b) => SnowflakeUtil.timestampFrom(a.id) - SnowflakeUtil.timestampFrom(b.id)); 248 | 249 | const items = texts.slice(page * itemsPerPage, page * itemsPerPage + itemsPerPage); 250 | const options: MessageSelectOption[] = []; 251 | 252 | items.forEach((v) => { 253 | const label = v.decrypted.slice(0, 100); 254 | if (label.length < 0 || options.findIndex((opt) => opt.value == v.id) != -1) return; 255 | 256 | const addedAt = new Date(SnowflakeUtil.timestampFrom(v.id)); 257 | 258 | options.push({ 259 | label, 260 | description: this.t("commands.deleteTexts.texts.textInfo", { lng: locale, author: v.author ?? "???", date: addedAt.toLocaleString(locale) }), 261 | value: v.id, 262 | default: false, 263 | emoji: null 264 | }); 265 | }); 266 | 267 | const menu = new MessageSelectMenu({ 268 | customId: "menu", 269 | options 270 | } as MessageSelectMenuOptions); 271 | 272 | if (texts.length < 1) { 273 | menu.setDisabled(true); 274 | menu.setOptions([ 275 | { 276 | label: "Random text just to API don't tell it's an error.", 277 | value: "0" 278 | } 279 | ]); 280 | } 281 | 282 | const menuRow = new MessageActionRow() 283 | .addComponents(menu); 284 | 285 | let pages = Math.ceil(texts.length / itemsPerPage); 286 | pages = pages < 1 ? 1 : pages; 287 | const buttonsRow = new MessageActionRow() 288 | .addComponents( 289 | new MessageButton({ 290 | customId: "first", 291 | label: "<<", 292 | style: "PRIMARY", 293 | disabled: page + 1 <= 1 294 | }), 295 | new MessageButton({ 296 | customId: "previous", 297 | label: "<", 298 | style: "PRIMARY", 299 | disabled: page + 1 <= 1 300 | }), 301 | new MessageButton({ 302 | customId: "pages", 303 | label: `${page + 1}/${pages}`, 304 | style: "SECONDARY", 305 | disabled: true 306 | }), 307 | new MessageButton({ 308 | customId: "next", 309 | label: ">", 310 | style: "PRIMARY", 311 | disabled: page + 1 == pages 312 | }), 313 | new MessageButton({ 314 | customId: "last", 315 | label: ">>", 316 | style: "PRIMARY", 317 | disabled: page + 1 == pages 318 | }) 319 | ); 320 | 321 | const deleteRow = new MessageActionRow() 322 | .addComponents( 323 | new MessageButton({ 324 | customId: "deleteall", 325 | label: this.t("commands.deleteTexts.texts.deleteAllButton", { lng: locale }), 326 | style: "DANGER", 327 | disabled: texts.length < 1 328 | }) 329 | ); 330 | 331 | if (!hasMember) { 332 | deleteRow.addComponents( 333 | new MessageButton({ 334 | customId: "mytexts", 335 | label: this.t("commands.deleteTexts.texts.myMessages", { lng: locale }), 336 | style: "PRIMARY", 337 | disabled: texts.filter((v) => v.author == author).length < 1 338 | }) 339 | ); 340 | } 341 | 342 | return [ menuRow, buttonsRow, deleteRow ]; 343 | } 344 | } -------------------------------------------------------------------------------- /src/commands/Generate.ts: -------------------------------------------------------------------------------- 1 | import Command from "../structures/Command"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../interfaces/ClientInterface"; 5 | 6 | export default class GenerateCommand extends Command { 7 | constructor(client: ClientInterface) { 8 | super( 9 | client, 10 | "commands.generate.command.name", 11 | "commands.generate.command.description", 12 | [ 13 | { 14 | type: "INTEGER", 15 | name: "commands.generate.command.options.0.name", 16 | description: "commands.generate.command.options.0.description", 17 | minValue: 1, 18 | maxValue: 1000 19 | } 20 | ] 21 | ); 22 | } 23 | 24 | async run(interaction: CommandInteraction) { 25 | const database = await this.client.database.fetch(interaction.guildId); 26 | 27 | const size = interaction.options.getInteger(this.options[0].name) ?? Math.floor(Math.random() * 50); 28 | const textsLength = await database.getTextsLength(); 29 | 30 | let generatedText = database.markovChains.generateChain(size); 31 | if (generatedText?.length > 2000) generatedText = generatedText.slice(0, 2000 - 3) + "..."; 32 | 33 | if (generatedText && textsLength > 0 && generatedText.trim().length > 0) { 34 | return await interaction.reply(generatedText); 35 | } else { 36 | return await interaction.reply(this.t("commands.generate.error", { lng: interaction.locale })); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/commands/Info.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { MessageEmbed, MessageActionRow, MessageButton, version } from "discord.js"; 3 | import Command from "../structures/Command"; 4 | 5 | import { CommandInteraction, TextChannel } from "discord.js/typings"; 6 | import ClientInterface from "../interfaces/ClientInterface"; 7 | 8 | export default class InfoCommand extends Command { 9 | public skipBan: boolean = true; 10 | 11 | constructor(client: ClientInterface) { 12 | super( 13 | client, 14 | "info", 15 | "commands.info.command.description" 16 | ); 17 | } 18 | 19 | async run(interaction: CommandInteraction) { 20 | await interaction.deferReply(); 21 | 22 | const lng = { lng: interaction.locale }; 23 | const locUndefined = this.t("vars.undefined", lng); 24 | 25 | const database = await this.client.database.fetch(interaction.guildId); 26 | const isBanned = await this.client.database.isBanned(interaction.guildId); 27 | const isTracking = await this.client.database.isTrackAllowed(interaction.user.id); 28 | const serverCount = (await this.client.shard.fetchClientValues("guilds.cache.size") as number[]) 29 | .reduce((a, b) => a + b); 30 | 31 | // Basic info 32 | let description = this.t("commands.info.texts.online", { ...lng, time: `` }) + "\n"; 33 | description += this.t("commands.info.texts.serverSize", { ...lng, size: serverCount }) + "\n"; 34 | description += this.t("commands.info.texts.tracking", { 35 | ...lng, 36 | state: isTracking ? this.t("vars.enabled", lng) : this.t("vars.disabled", lng) 37 | }); 38 | 39 | const embed = new MessageEmbed() 40 | .setTitle("👽 " + this.t("commands.info.texts.title", lng)) 41 | .setColor(Math.floor(Math.random() * 0xFFFFFF)) 42 | .setDescription(description); 43 | 44 | // Server related info 45 | let channelId = await database.getChannel(); 46 | 47 | let webhook = await database.getWebhook(); 48 | let webhookName; 49 | if (webhook) { 50 | try { 51 | let res = await axios.get(webhook); 52 | if (res.status == 200) { 53 | webhookName = res.data.name; 54 | } else { 55 | webhook = null; 56 | } 57 | } catch (e) { 58 | if (e.response.status != 404) { 59 | console.error("[Commands]", "Failed to get Webhook info:\n", e); 60 | } else { 61 | webhook = null; 62 | await database.configWebhook(); 63 | } 64 | } 65 | } 66 | 67 | let channel: TextChannel; 68 | try { 69 | if (channelId) { 70 | channel = await interaction.guild.channels.fetch(channelId) as TextChannel; 71 | } 72 | } catch(e) { 73 | // Unknown channel 74 | if (e.code == 10003) { 75 | channelId = null; 76 | database.configChannel(null); 77 | } 78 | } 79 | 80 | const clientMember = channel && await interaction.guild.members.fetchMe(); 81 | const messagePermission = ( 82 | channel && 83 | clientMember.permissionsIn(channel)?.has("SEND_MESSAGES") && 84 | clientMember.permissionsIn(channel)?.has("VIEW_CHANNEL") 85 | ); 86 | 87 | let serverInfo; 88 | if (isBanned) { 89 | serverInfo = this.t("commands.info.texts.banned", { ...lng, reason: isBanned }); 90 | } else { 91 | serverInfo = this.t("commands.info.texts.state", { 92 | ...lng, 93 | state: database.toggledActivity ? this.t("vars.enabled", lng) : this.t("vars.disabled", lng) 94 | }) + "\n"; 95 | serverInfo += this.t("commands.info.texts.channel", { 96 | ...lng, 97 | channel: channelId ? `<#${channelId}>` : `\`${locUndefined}\``, 98 | permission: channelId && !messagePermission ? ` (${this.t("commands.info.texts.nopermission", lng)})` : "" 99 | }) + "\n"; 100 | serverInfo += this.t("commands.info.texts.webhook", { 101 | ...lng, 102 | webhook: webhookName?.replace(/[`*\\]+/g, "") ?? locUndefined 103 | }) + "\n"; 104 | serverInfo += this.t("commands.info.texts.textsLength", { ...lng, length: await database.getTextsLength() }) + "\n"; 105 | serverInfo += this.t("commands.info.texts.textsLimit", { ...lng, limit: await database.getTextsLimit() }) + "\n"; 106 | serverInfo += this.t("commands.info.texts.collectChance", { ...lng, chance: `${await database.getCollectionPercentage() * 100}%` }) + "\n"; 107 | serverInfo += this.t("commands.info.texts.sendingChance", { ...lng, chance: `${await database.getSendingPercentage() * 100}%` }) + "\n"; 108 | serverInfo += this.t("commands.info.texts.replyChance", { ...lng, chance: `${await database.getReplyPercentage() * 100}%` }); 109 | } 110 | 111 | // Software 112 | let softwareInfo = this.t("commands.info.texts.nodeVersion", { ...lng, version: process.version }) + "\n"; 113 | softwareInfo += this.t("commands.info.texts.djsVersion", { ...lng, version: "v" + version }) + "\n"; 114 | softwareInfo += this.t("commands.info.texts.memUsage", { ...lng, mem: `${Math.floor(process.memoryUsage().heapUsed / 1024 ** 2)} mb` }) + "\n"; 115 | softwareInfo += this.t("commands.info.texts.developer", { ...lng, dev: `${this.client.config.emojis.twitter} [@knownasbot](https://twitter.com/knownasbot)` }); 116 | 117 | embed.addFields( 118 | { 119 | name: this.t("commands.info.texts.serverField", lng), 120 | value: serverInfo 121 | }, 122 | { 123 | name: this.t("commands.info.texts.softwareField", lng), 124 | value: softwareInfo 125 | } 126 | ); 127 | 128 | const cRow = new MessageActionRow() 129 | .addComponents( 130 | new MessageButton({ 131 | emoji: this.client.config.emojis.topgg, 132 | label: "Top.gg", 133 | url: this.client.config.links.topgg, 134 | style: "LINK" 135 | }), 136 | new MessageButton({ 137 | customId: "donate", 138 | emoji: "🤑", 139 | label: this.t("commands.info.texts.donate", lng), 140 | style: "SECONDARY" 141 | }), 142 | new MessageButton({ 143 | customId: "faq", 144 | emoji: "🤔", 145 | label: this.t("commands.info.texts.faq", lng), 146 | style: "SECONDARY" 147 | }), 148 | new MessageButton({ 149 | emoji: "😺", 150 | label: this.t("commands.info.texts.cutecats", lng), 151 | url: "https://youtu.be/dQw4w9WgXcQ", 152 | style: "LINK" 153 | }) 154 | ); 155 | 156 | const docsRow = new MessageActionRow() 157 | .addComponents( 158 | new MessageButton({ 159 | emoji: "👥", 160 | label: this.t("vars.support", lng), 161 | url: this.client.config.links.support, 162 | style: "LINK" 163 | }), 164 | new MessageButton({ 165 | emoji: this.client.config.emojis.github, 166 | label: "GitHub", 167 | url: this.client.config.links.github, 168 | style: "LINK" 169 | }), 170 | new MessageButton({ 171 | emoji: "📜", 172 | label: this.t("vars.tos", lng), 173 | url: this.client.config.links.tos, 174 | style: "LINK" 175 | }), 176 | new MessageButton({ 177 | emoji: "🔒", 178 | label: this.t("vars.privacyPolicy", lng), 179 | url: this.client.config.links.privacy, 180 | style: "LINK" 181 | }) 182 | ); 183 | 184 | embed.setFooter({ 185 | text: `Shard ${interaction.guild.shardId}` 186 | }); 187 | 188 | return interaction.editReply({ embeds: [ embed ], components: [ cRow, docsRow ] }); 189 | } 190 | } -------------------------------------------------------------------------------- /src/commands/Ping.ts: -------------------------------------------------------------------------------- 1 | import Command from "../structures/Command"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../interfaces/ClientInterface"; 5 | 6 | export default class PingCommand extends Command { 7 | public skipBan: boolean = true; 8 | public allowedDm: boolean = true; 9 | 10 | constructor(client: ClientInterface) { 11 | super( 12 | client, 13 | "ping", 14 | "commands.ping.command.description" 15 | ); 16 | } 17 | 18 | async run(interaction: CommandInteraction) { 19 | return interaction.reply(`🏓 **Ping:** ${this.client.ws.ping} ms.`); 20 | } 21 | } -------------------------------------------------------------------------------- /src/commands/Populate.ts: -------------------------------------------------------------------------------- 1 | import Command from "../structures/Command"; 2 | import { SnowflakeUtil } from "discord.js"; 3 | 4 | import { CommandInteraction, PermissionResolvable } from "discord.js/typings"; 5 | import ClientInterface from "../interfaces/ClientInterface"; 6 | 7 | export default class PopulateCommand extends Command { 8 | public dev: boolean = true; 9 | public skipBan: boolean = true; 10 | public permissions: PermissionResolvable = "MANAGE_GUILD"; 11 | 12 | private words: string[] = []; 13 | 14 | constructor(client: ClientInterface) { 15 | super( 16 | client, 17 | "commands.populate.command.name", 18 | "commands.populate.command.description", 19 | [ 20 | { 21 | type: "INTEGER", 22 | name: "commands.populate.command.options.0.name", 23 | description: "commands.populate.command.options.0.description", 24 | required: true, 25 | minValue: 1 26 | }, 27 | { 28 | type: "USER", 29 | name: "commands.populate.command.options.1.name", 30 | description: "commands.populate.command.options.1.description" 31 | } 32 | ] 33 | ); 34 | 35 | const loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Faucibus a pellentesque sit amet porttitor eget dolor morbi non. Laoreet non curabitur gravida arcu ac tortor dignissim convallis aenean. Blandit massa enim nec dui nunc mattis enim ut tellus. Id neque aliquam vestibulum morbi. Faucibus et molestie ac feugiat sed. Sed nisi lacus sed viverra tellus. Cursus metus aliquam eleifend mi in nulla posuere sollicitudin aliquam. Nisl suscipit adipiscing bibendum est. Facilisi morbi tempus iaculis urna id volutpat lacus laoreet non. Dolor morbi non arcu risus quis varius quam quisque. Nibh venenatis cras sed felis. Neque viverra justo nec ultrices dui sapien. Non nisi est sit amet facilisis. Venenatis tellus in metus vulputate eu scelerisque felis imperdiet proin. Cras adipiscing enim eu turpis egestas pretium aenean pharetra magna. Erat velit scelerisque in dictum non consectetur a erat. Odio tempor orci dapibus ultrices in iaculis."; 36 | 37 | this.words = loremIpsum.split(" ").map((v) => v.toLowerCase()); 38 | } 39 | 40 | async run(interaction: CommandInteraction) { 41 | const amount = interaction.options.getInteger("amount"); 42 | const member = interaction.options.getUser("member")?.id ?? interaction.user.id; 43 | 44 | const database = await this.client.database.fetch(interaction.guildId); 45 | 46 | await interaction.deferReply(); 47 | 48 | for (let i=0; i < amount; i++) { 49 | await database.addText(this.randomText(), member, this.randomId()); 50 | } 51 | 52 | return interaction.editReply(this.t("commands.populate.text", { lng: interaction.locale, amount })); 53 | } 54 | 55 | private randomId(): string { 56 | return SnowflakeUtil.generate(Date.now() + Math.floor(Math.random() * 30 * 24 * 60 * 60 * 1000)); 57 | } 58 | 59 | private randomText(): string { 60 | let generatedText = ""; 61 | let upper = true; 62 | 63 | for (let j=0; j < Math.floor(Math.random() * 30) + 1; j++) { 64 | let word = this.words[Math.floor(Math.random() * this.words.length)]; 65 | 66 | if (upper) { 67 | word = word[0].toUpperCase() + word.slice(1); 68 | } 69 | 70 | upper = word.endsWith("."); 71 | generatedText += word + " "; 72 | } 73 | 74 | if (!generatedText.trim().endsWith(".")) { 75 | generatedText = generatedText.trim() + "."; 76 | } 77 | 78 | return generatedText; 79 | } 80 | } -------------------------------------------------------------------------------- /src/commands/Tracking.ts: -------------------------------------------------------------------------------- 1 | import Command from "../structures/Command"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../interfaces/ClientInterface"; 5 | 6 | export default class TrackingCommand extends Command { 7 | public skipBan: boolean = true; 8 | public allowedDm: boolean = true; 9 | 10 | constructor(client: ClientInterface) { 11 | super( 12 | client, 13 | "commands.tracking.command.name", 14 | "commands.tracking.command.description" 15 | ); 16 | } 17 | 18 | async run(interaction: CommandInteraction) { 19 | const lng = { lng: interaction.locale }; 20 | const state = await this.client.database.toggleTrack(interaction.user.id); 21 | 22 | if (state) { 23 | return interaction.reply({ 24 | content: this.t("commands.tracking.texts.enabled", lng), 25 | ephemeral: true 26 | }); 27 | } else { 28 | return interaction.reply({ 29 | content: this.t("commands.tracking.texts.disabled", lng), 30 | ephemeral: true 31 | }); 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/commands/Unban.ts: -------------------------------------------------------------------------------- 1 | import Command from "../structures/Command"; 2 | 3 | import { CommandInteraction, PermissionResolvable } from "discord.js/typings"; 4 | import ClientInterface from "../interfaces/ClientInterface"; 5 | 6 | export default class UnbanCommand extends Command { 7 | public dev: boolean = true; 8 | public skipBan: boolean = true; 9 | public permissions: PermissionResolvable = "MANAGE_GUILD"; 10 | 11 | constructor(client: ClientInterface) { 12 | super( 13 | client, 14 | "commands.unban.command.name", 15 | "commands.unban.command.description", 16 | [ 17 | { 18 | type: "STRING", 19 | name: "commands.unban.command.options.0.name", 20 | description: "commands.unban.command.options.0.description", 21 | required: true 22 | } 23 | ] 24 | ); 25 | } 26 | 27 | async run(interaction: CommandInteraction) { 28 | const guild = interaction.options.getString(this.options[0].name); 29 | 30 | await this.client.database.unban(guild); 31 | 32 | return interaction.reply( 33 | this.t("commands.unban.texts.success", { lng: interaction.locale, guild }) 34 | ); 35 | } 36 | } -------------------------------------------------------------------------------- /src/commands/subcommands/config/Chance.ts: -------------------------------------------------------------------------------- 1 | import SubCommandGroup from "../../../structures/SubCommandGroup"; 2 | 3 | import ClientInterface from "../../../interfaces/ClientInterface"; 4 | import SubCommandInterface from "../../../interfaces/SubCommandInterface"; 5 | import { CommandInteraction } from "discord.js/typings"; 6 | 7 | import CollectSubCommand from "./chance/Collect"; 8 | import SendingSubCommand from "./chance/Sending"; 9 | import ReplySubCommand from "./chance/Reply"; 10 | 11 | export default class ChanceSubCommandGroup extends SubCommandGroup { 12 | private subcommands: Record; 13 | 14 | constructor(client: ClientInterface) { 15 | super( 16 | client, 17 | "commands.chance.command.name", 18 | "commands.chance.command.description" 19 | ); 20 | 21 | const subcommands = { 22 | collect: new CollectSubCommand(client), 23 | send: new SendingSubCommand(client), 24 | reply: new ReplySubCommand(client) 25 | }; 26 | 27 | let options = []; 28 | 29 | for (let key of Object.keys(subcommands)) { 30 | let subcommand: SubCommandInterface = subcommands[key]; 31 | options.push({ 32 | type: subcommand.type, 33 | name: subcommand.name, 34 | description: subcommand.description, 35 | options: subcommand.options 36 | }); 37 | } 38 | 39 | this.options = options; 40 | this.subcommands = subcommands; 41 | } 42 | 43 | async run(interaction: CommandInteraction) { 44 | const subCommandName = interaction.options.getSubcommand(); 45 | const subCommand = this.subcommands[subCommandName]; 46 | if (subCommand) return subCommand.run(interaction); 47 | } 48 | } -------------------------------------------------------------------------------- /src/commands/subcommands/config/Channel.ts: -------------------------------------------------------------------------------- 1 | import SubCommand from "../../../structures/SubCommand"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../../../interfaces/ClientInterface"; 5 | 6 | export default class ChannelSubCommand extends SubCommand { 7 | constructor(client: ClientInterface) { 8 | super( 9 | client, 10 | "commands.channel.command.name", 11 | "commands.channel.command.description", 12 | [ 13 | { 14 | type: "CHANNEL", 15 | name: "commands.channel.command.options.0.name", 16 | description: "commands.channel.command.options.0.description", 17 | channelTypes: [ "GUILD_TEXT", "GUILD_PUBLIC_THREAD", "GUILD_PRIVATE_THREAD" ], 18 | required: true 19 | } 20 | ] 21 | ); 22 | } 23 | 24 | async run(interaction: CommandInteraction) { 25 | const lng = { lng: interaction.locale }; 26 | const channel = interaction.options.getChannel(this.options[0].name); 27 | const database = await this.client.database.fetch(interaction.guildId); 28 | 29 | try { 30 | await database.configChannel(channel.id); 31 | 32 | return await interaction.reply(this.t("commands.channel.text", { ...lng, channel: `<#${channel.id}>` })); 33 | } catch (e) { 34 | return await interaction.reply(this.t("vars.error", lng)); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/commands/subcommands/config/Disable.ts: -------------------------------------------------------------------------------- 1 | import SubCommand from "../../../structures/SubCommand"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../../../interfaces/ClientInterface"; 5 | 6 | export default class DisableSubCommand extends SubCommand { 7 | constructor(client: ClientInterface) { 8 | super( 9 | client, 10 | "commands.disable.command.name", 11 | "commands.disable.command.description", 12 | ); 13 | } 14 | 15 | async run(interaction: CommandInteraction) { 16 | const lng = { lng: interaction.locale }; 17 | const database = await this.client.database.fetch(interaction.guildId); 18 | 19 | try { 20 | await database.toggleActivity(false); 21 | 22 | return interaction.reply(this.t("commands.disable.text", lng)); 23 | } catch(e) { 24 | return interaction.reply(this.t("vars.error", lng)); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/commands/subcommands/config/Enable.ts: -------------------------------------------------------------------------------- 1 | import SubCommand from "../../../structures/SubCommand"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../../../interfaces/ClientInterface"; 5 | 6 | export default class EnableSubCommand extends SubCommand { 7 | constructor(client: ClientInterface) { 8 | super( 9 | client, 10 | "commands.enable.command.name", 11 | "commands.enable.command.description" 12 | ); 13 | } 14 | 15 | async run(interaction: CommandInteraction) { 16 | const lng = { lng: interaction.locale }; 17 | const database = await this.client.database.fetch(interaction.guildId); 18 | 19 | try { 20 | await database.toggleActivity(true); 21 | 22 | return interaction.reply(this.t("commands.enable.text", lng)); 23 | } catch(e) { 24 | return interaction.reply(this.t("vars.error", lng)); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/commands/subcommands/config/Limit.ts: -------------------------------------------------------------------------------- 1 | import SubCommand from "../../../structures/SubCommand"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../../../interfaces/ClientInterface"; 5 | 6 | export default class LimitSubCommand extends SubCommand { 7 | constructor(client: ClientInterface) { 8 | super( 9 | client, 10 | "commands.limit.command.name", 11 | "commands.limit.command.description", 12 | [ 13 | { 14 | type: "INTEGER", 15 | name: "commands.limit.command.options.0.name", 16 | description: "commands.limit.command.options.0.description", 17 | minValue: 5, 18 | maxValue: new Date().getFullYear() 19 | } 20 | ] 21 | ); 22 | } 23 | 24 | async run(interaction: CommandInteraction) { 25 | const lng = { lng: interaction.locale }; 26 | const database = await this.client.database.fetch(interaction.guildId); 27 | 28 | const limit = interaction.options.getInteger(this.options[0].name); 29 | try { 30 | await database.configTextsLimit(limit); 31 | 32 | return interaction.reply(this.t("commands.limit.text", { ...lng, limit })); 33 | } catch { 34 | return interaction.reply(this.t("vars.error", lng)); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/commands/subcommands/config/Webhook.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { WebhookClient } from "discord.js"; 3 | import SubCommand from "../../../structures/SubCommand"; 4 | 5 | import { CommandInteraction } from "discord.js/typings"; 6 | import ClientInterface from "../../../interfaces/ClientInterface"; 7 | 8 | export default class WebhookSubCommand extends SubCommand { 9 | constructor(client: ClientInterface) { 10 | super( 11 | client, 12 | "commands.webhook.command.name", 13 | "commands.webhook.command.description", 14 | [ 15 | { 16 | type: "STRING", 17 | name: "url", 18 | description: "commands.webhook.command.options.0.description" 19 | } 20 | ] 21 | ); 22 | } 23 | 24 | async run(interaction: CommandInteraction) { 25 | const lng = { lng: interaction.locale }; 26 | const database = await this.client.database.fetch(interaction.guildId); 27 | 28 | let webhookURL = interaction.options.getString("url"); 29 | if (webhookURL) { 30 | try { 31 | new WebhookClient({ url: webhookURL }); 32 | 33 | let res = await axios.get(webhookURL); 34 | if (res.status != 200) 35 | throw new Error(res.data.message); 36 | 37 | if (res.data.guild_id != interaction.guildId) 38 | return interaction.reply({ content: this.t("commands.webhook.texts.guildError", lng), ephemeral: true }); 39 | 40 | try { 41 | await database.configWebhook(webhookURL); 42 | await database.configChannel(res.data.channel_id); 43 | } catch { 44 | return interaction.reply({ content: this.t("vars.error", lng), ephemeral: true }); 45 | } 46 | 47 | return interaction.reply({ content: this.t("commands.webhook.texts.success", { ...lng, name: res.data.name.replace(/[`*\\]+/g, "") }), ephemeral: true }); 48 | } catch { 49 | return interaction.reply({ content: this.t("commands.webhook.texts.error", lng), ephemeral: true }); 50 | } 51 | } else { 52 | try { 53 | await database.configWebhook(); 54 | 55 | return interaction.reply({ content: this.t("commands.webhook.texts.disabled", lng) }); 56 | } catch {}; 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /src/commands/subcommands/config/chance/Collect.ts: -------------------------------------------------------------------------------- 1 | import SubCommand from "../../../../structures/SubCommand"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../../../../interfaces/ClientInterface"; 5 | 6 | export default class CollectSubCommand extends SubCommand { 7 | constructor(client: ClientInterface) { 8 | super( 9 | client, 10 | "commands.collectChance.command.name", 11 | "commands.collectChance.command.description", 12 | [ 13 | { 14 | type: "INTEGER", 15 | name: "commands.collectChance.command.options.0.name", 16 | description: "commands.collectChance.command.options.0.description", 17 | required: true, 18 | minValue: 1, 19 | maxValue: 50 20 | } 21 | ] 22 | ); 23 | } 24 | 25 | async run(interaction: CommandInteraction) { 26 | const lng = { lng: interaction.locale }; 27 | 28 | let chance = interaction.options.getInteger(this.options[0].name); 29 | if (!chance || chance > 50 || chance < 1) return; 30 | 31 | const database = await this.client.database.fetch(interaction.guildId); 32 | 33 | try { 34 | await database.setCollectionPercentage(chance / 100); 35 | 36 | return interaction.reply(this.t("commands.collectChance.text", { ...lng, chance })); 37 | } catch(e) { 38 | return interaction.reply({ content: this.t("vars.error", lng), ephemeral: true }); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/commands/subcommands/config/chance/Reply.ts: -------------------------------------------------------------------------------- 1 | import SubCommand from "../../../../structures/SubCommand"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../../../../interfaces/ClientInterface"; 5 | 6 | export default class ReplySubCommand extends SubCommand { 7 | constructor(client: ClientInterface) { 8 | super( 9 | client, 10 | "commands.replyChance.command.name", 11 | "commands.replyChance.command.description", 12 | [ 13 | { 14 | type: "INTEGER", 15 | name: "commands.replyChance.command.options.0.name", 16 | description: "commands.replyChance.command.options.0.description", 17 | required: true, 18 | minValue: 1, 19 | maxValue: 100 20 | } 21 | ] 22 | ); 23 | } 24 | 25 | async run(interaction: CommandInteraction) { 26 | const lng = { lng: interaction.locale }; 27 | 28 | let chance = interaction.options.getInteger(this.options[0].name); 29 | if (!chance || chance > 100 || chance < 1) return; 30 | 31 | const database = await this.client.database.fetch(interaction.guildId); 32 | 33 | try { 34 | await database.setReplyPercentage(chance / 100); 35 | 36 | return interaction.reply(this.t("commands.replyChance.text", { ...lng, chance })); 37 | } catch(e) { 38 | return interaction.reply({ content: this.t("vars.error", lng), ephemeral: true }); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/commands/subcommands/config/chance/Sending.ts: -------------------------------------------------------------------------------- 1 | import SubCommand from "../../../../structures/SubCommand"; 2 | 3 | import { CommandInteraction } from "discord.js/typings"; 4 | import ClientInterface from "../../../../interfaces/ClientInterface"; 5 | 6 | export default class CollectSubCommand extends SubCommand { 7 | constructor(client: ClientInterface) { 8 | super( 9 | client, 10 | "commands.sendChance.command.name", 11 | "commands.sendChance.command.description", 12 | [ 13 | { 14 | type: "INTEGER", 15 | name: "commands.sendChance.command.options.0.name", 16 | description: "commands.sendChance.command.options.0.description", 17 | required: true, 18 | minValue: 1, 19 | maxValue: 50 20 | } 21 | ] 22 | ); 23 | } 24 | 25 | async run(interaction: CommandInteraction) { 26 | const lng = { lng: interaction.locale }; 27 | 28 | let chance = interaction.options.getInteger(this.options[0].name); 29 | if (!chance || chance > 50 || chance < 1) return; 30 | 31 | const database = await this.client.database.fetch(interaction.guildId); 32 | 33 | try { 34 | await database.setSendingPercentage(chance / 100); 35 | 36 | return interaction.reply(this.t("commands.sendChance.text", { ...lng, chance })); 37 | } catch(e) { 38 | return interaction.reply({ content: this.t("vars.error", lng), ephemeral: true }); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/events/GuildCreate.ts: -------------------------------------------------------------------------------- 1 | import { MessageEmbed, MessageActionRow, MessageButton, WebhookClient } from "discord.js"; 2 | import Event from "../structures/Event"; 3 | 4 | import { Guild } from "discord.js/typings"; 5 | import ClientInterface from "../interfaces/ClientInterface"; 6 | 7 | export default class GuildCreate extends Event { 8 | private webhook: WebhookClient; 9 | 10 | constructor() { 11 | super("guildCreate"); 12 | 13 | if (process.env.SERVER_LOG) { 14 | this.webhook = new WebhookClient({ url: process.env.SERVER_LOG }); 15 | } 16 | } 17 | 18 | async run(client: ClientInterface, guild: Guild): Promise { 19 | if (guild.systemChannel) { 20 | const { t } = client.i18n; 21 | const lng = { lng: guild.preferredLocale }; 22 | 23 | const infoCommandName = t("commands.info.command.name"); 24 | const configCommandName = t("commands.config.command.name"); 25 | const enableCommandName = t("commands.enable.command.name"); 26 | const channelCommandName = t("commands.channel.command.name"); 27 | const deleteCommandName = t("commands.deleteTexts.command.name"); 28 | 29 | const commands = await client.application.commands.fetch(); 30 | const infoCommand = commands.find((v) => v.name == infoCommandName); 31 | const configCommand = commands.find((v) => v.name == configCommandName); 32 | const deleteCommand = commands.find((v) => v.name == deleteCommandName); 33 | 34 | try { 35 | const row = new MessageActionRow() 36 | .addComponents( 37 | new MessageButton({ 38 | emoji: "💡", 39 | label: t("vars.gettingStarted", lng), 40 | url: client.config.links.website, 41 | style: "LINK" 42 | }), 43 | new MessageButton({ 44 | emoji: "📜", 45 | label: t("vars.tos", lng), 46 | url: client.config.links.tos, 47 | style: "LINK" 48 | }), 49 | new MessageButton({ 50 | emoji: "🔒", 51 | label: t("vars.privacyPolicy", lng), 52 | url: client.config.links.privacy, 53 | style: "LINK" 54 | }) 55 | ); 56 | 57 | await guild.systemChannel.send({ 58 | content: t("events.welcome", { 59 | ...lng, 60 | infoCommand: ``, 61 | channelCommand: ``, 62 | enableCommand: ``, 63 | deleteCommand: ``, 64 | min: 5 65 | }), 66 | components: [ row ] 67 | }); 68 | } catch {}; // Probably has no permission 69 | } 70 | 71 | if (this.webhook) { 72 | let ownerTag; 73 | try { 74 | ownerTag = (await client.users.fetch(guild.ownerId))?.tag 75 | } catch {}; 76 | 77 | let description = `ID: \`${guild?.id}\`.\n`; 78 | description += `Owner: \`${ownerTag ?? "Unknown"}\` (\`${guild?.ownerId ?? "Unknown"}\`).\n`; 79 | description += `Member count: \`${guild?.memberCount ?? "Unknown"}\`.\n`; 80 | 81 | const embed = new MessageEmbed() 82 | .setTitle(guild?.name ?? "Unknown") 83 | .setColor(0x32d35b) 84 | .setDescription(description) 85 | .setFooter({ text: "Shard " + guild.shardId }) 86 | .setTimestamp(); 87 | 88 | return this.webhook.send({ embeds: [ embed ] }) 89 | .catch(e => console.error("[Webhook Log]", e)); 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/events/GuildDelete.ts: -------------------------------------------------------------------------------- 1 | import { MessageEmbed, WebhookClient } from "discord.js"; 2 | import Event from "../structures/Event"; 3 | 4 | import { Guild } from "discord.js/typings"; 5 | import ClientInterface from "../interfaces/ClientInterface"; 6 | 7 | export default class GuildDelete extends Event { 8 | private webhook: WebhookClient; 9 | 10 | constructor() { 11 | super("guildDelete"); 12 | 13 | if (process.env.SERVER_LOG) { 14 | this.webhook = new WebhookClient({ url: process.env.SERVER_LOG }); 15 | } 16 | } 17 | 18 | async run(client: ClientInterface, guild: Guild): Promise { 19 | // Outage 20 | if (!guild.available) return; 21 | 22 | client.database.delete(guild.id); 23 | client.cooldown.delete(guild.id); 24 | 25 | if (this.webhook) { 26 | let ownerTag; 27 | try { 28 | ownerTag = (await client.users.fetch(guild.ownerId))?.tag 29 | } catch {}; 30 | 31 | let description = `ID: \`${guild?.id}\`.\n`; 32 | description += `Owner: \`${ownerTag ?? "Unknown"}\` (\`${guild?.ownerId ?? "Unknown"}\`).\n`; 33 | description += `Member count: \`${guild?.memberCount ?? "Unknown"}\`.\n`; 34 | 35 | const embed = new MessageEmbed() 36 | .setTitle(guild?.name ?? "Unknown") 37 | .setColor(0xd33235) 38 | .setDescription(description) 39 | .setFooter({ text: "Shard " + guild.shardId }) 40 | .setTimestamp(); 41 | 42 | return this.webhook.send({ embeds: [ embed ] }) 43 | .catch(e => console.error("[Webhook Log]", e)); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/events/Interaction.ts: -------------------------------------------------------------------------------- 1 | import { MessageEmbed } from "discord.js"; 2 | import Event from "../structures/Event"; 3 | 4 | import { CommandInteraction, ButtonInteraction } from "discord.js/typings"; 5 | import ClientInterface from "../interfaces/ClientInterface"; 6 | import CommandInterface from "../interfaces/CommandInterface"; 7 | 8 | interface FAQ { 9 | title: string; 10 | description: string; 11 | fields: { 12 | name: string; 13 | value: string; 14 | }[] 15 | }; 16 | 17 | export default class Interaction extends Event { 18 | private pleadingGIFs = [ 19 | "https://c.tenor.com/F7ypx136ZigAAAAd/cat-cute.gif", 20 | "https://c.tenor.com/RGzSEnABvDoAAAAC/mad-angry.gif", 21 | "https://c.tenor.com/8EcxOsjmoFcAAAAC/begging-dog.gif", 22 | "https://c.tenor.com/bmtLf8P0Xi8AAAAC/pleading-creepy.gif", 23 | "https://c.tenor.com/EQrH6C_FOy4AAAAd/cries-about-it-cry-about-it.gif" 24 | ]; 25 | 26 | constructor() { 27 | super("interactionCreate"); 28 | } 29 | 30 | async run(client: ClientInterface, interaction: CommandInteraction | ButtonInteraction): Promise { 31 | const lng = { lng: interaction.locale }; 32 | const { t } = client.i18n; 33 | 34 | if (interaction.isCommand()) { 35 | const command: CommandInterface = client.commands.get(interaction.commandName); 36 | if (command) { 37 | if (command.permissions && !interaction.memberPermissions.has(command.permissions)) { 38 | return interaction.reply({ content: t("commands.config.error", { lng: interaction.locale }), ephemeral: true }); 39 | } 40 | 41 | try { 42 | if (!command.skipBan) { 43 | const isBanned = await client.database.isBanned(interaction.guildId); 44 | if (isBanned) { 45 | return interaction.reply({ content: t("commands.ban.texts.error", { lng: interaction.locale, reason: isBanned }), ephemeral: true }); 46 | } 47 | } 48 | 49 | if (command.dev && !client.config.admins.includes(interaction.user.id)) { 50 | return interaction.reply({ content: "You aren't allowed to execute this command.", ephemeral: true }); 51 | } 52 | 53 | await command.run(interaction); 54 | } catch (e) { 55 | try { 56 | await interaction.reply({ content: t("vars.error", { lng: interaction.locale }), ephemeral: true }); 57 | } catch {}; 58 | 59 | console.log("[Commands]", `Failed to execute the command "${interaction.commandName}": `, e); 60 | } 61 | } 62 | } else if (interaction.isButton()) { 63 | let embed: MessageEmbed; 64 | if (interaction.customId == "donate") { 65 | embed = new MessageEmbed({ 66 | color: 0x34eb71, 67 | title: "💸 " + t("commands.donate.title", { ...lng }), 68 | description: t("commands.donate.description", { 69 | ...lng, 70 | urls: `${client.config.emojis.bmc} **Buy Me A Coffee: [buymeacoffee.com/knownasbot](${client.config.links.bmc})**`, 71 | friendURL: `**${client.config.emojis.twitter} [@LukeFl_](https://twitter.com/lukefl_)**` 72 | }), 73 | fields: [ 74 | { 75 | name: t("commands.donate.cryptoTitle", { ...lng }), 76 | value: `**${client.config.emojis.bitcoin} Bitcoin:** \`${client.config.cryptoAddresses.bitcoin}\`\n**${client.config.emojis.ethereum} Ethereum:** \`${client.config.cryptoAddresses.ethereum}\`` 77 | } 78 | ], 79 | image: { 80 | url: this.pleadingGIFs[Math.floor(Math.random() * this.pleadingGIFs.length)] 81 | }, 82 | footer: { 83 | text: t("commands.donate.footer", lng) 84 | } 85 | }); 86 | } else if (interaction.customId == "faq") { 87 | const commands = await client.application.commands.fetch(); 88 | 89 | const deleteCommandName = t("commands.deleteTexts.command.name"); 90 | const trackingCommandName = t("commands.tracking.command.name"); 91 | 92 | const deleteCommand = commands.find((v) => v.name == deleteCommandName); 93 | const trackingCommand = commands.find((v) => v.name == trackingCommandName); 94 | 95 | const translation: FAQ = t("events.faq", { 96 | ...lng, 97 | returnObjects: true, 98 | 99 | contact: `**${client.config.emojis.twitter} [@knownasbot](https://twitter.com/knownasbot)**`, 100 | friendURL: "https://twitter.com/lukefl_", 101 | deleteCommand: ``, 102 | trackingCommand: ``, 103 | parameter: t("commands.deleteTexts.command.options.0.name", lng) 104 | }); 105 | 106 | embed = new MessageEmbed({ 107 | title: "❔ " + translation.title, 108 | description: translation.description, 109 | fields: translation.fields 110 | }); 111 | } 112 | 113 | if (embed) { 114 | return interaction.reply({ embeds: [ embed ], ephemeral: true }); 115 | } 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /src/events/Message.ts: -------------------------------------------------------------------------------- 1 | import { WebhookClient } from "discord.js"; 2 | import Event from "../structures/Event"; 3 | import specialEventList from "../modules/specialEvents"; 4 | 5 | import { Message, TextChannel } from "discord.js/typings"; 6 | import ClientInterface from "../interfaces/ClientInterface"; 7 | import SpecialEventInterface from "../interfaces/SpecialEventInterface"; 8 | 9 | export default class MessageCreate extends Event { 10 | constructor() { 11 | super("messageCreate"); 12 | } 13 | 14 | async run(client: ClientInterface, message: Message): Promise { 15 | if ( 16 | message.author.bot || 17 | !message.content || 18 | message.content.trim().length < 1 || 19 | await client.database.isBanned(message.guildId) 20 | ) return; 21 | 22 | const database = await client.database.fetch(message.guildId); 23 | if (!database.toggledActivity) return; 24 | 25 | let channel: TextChannel; 26 | try { 27 | channel = await message.channel?.fetch() as TextChannel; 28 | } catch { 29 | return; 30 | } 31 | 32 | const channelId = await database.getChannel(); 33 | const webhook = await database.getWebhook(); 34 | const clientMember = await message.guild.members.fetchMe(); 35 | const messagePermission = clientMember.permissionsIn(channel)?.has("SEND_MESSAGES"); 36 | 37 | if (channel && message.channelId == channelId && messagePermission) { 38 | const hasMention = message.mentions.has(client.user); 39 | const textsLength = await database.getTextsLength(); 40 | let guildCooldown = client.cooldown.get(message.guildId) ?? 0; 41 | let sendPercentage = await database.getSendingPercentage(); 42 | let collectPercentage = await database.getCollectionPercentage(); 43 | 44 | if (Math.random() <= collectPercentage) { 45 | client.database.isTrackAllowed(message.author.id) 46 | .then(async () => await database.addText(message.content, message.author.id, message.id)) 47 | .catch(() => {}); 48 | } 49 | 50 | if (textsLength < 5) return; 51 | if (hasMention && guildCooldown + 1000 < Date.now()) { 52 | sendPercentage = await database.getReplyPercentage(); 53 | guildCooldown = 0; 54 | } 55 | 56 | if (Math.random() <= sendPercentage && guildCooldown + 15000 < Date.now()) { 57 | client.cooldown.set(message.guildId, Date.now()); 58 | 59 | if (Math.random() <= 0.05) { 60 | const eventKeys = Object.keys(specialEventList); 61 | let RandomEvent: SpecialEventInterface = new specialEventList[eventKeys[Math.floor(Math.random() * eventKeys.length)]](); 62 | 63 | try { 64 | return await RandomEvent.run(client, message); 65 | } catch {}; 66 | } 67 | 68 | let generatedText = database.markovChains.generateChain(Math.floor(Math.random() * 50)); 69 | if (generatedText && generatedText.trim().length > 0) { 70 | let timeout = Math.floor(5 + Math.random() * 5) * 1000; 71 | 72 | if (!webhook) { 73 | try { 74 | await channel.sendTyping(); 75 | 76 | setTimeout(async () => { 77 | try { 78 | if (hasMention) await message.reply(generatedText) 79 | else await channel.send(generatedText); 80 | } catch {}; // Probably has no permission 81 | }, timeout); 82 | } catch {}; // Probably has no permission 83 | } else { 84 | setTimeout(async () => { 85 | try { 86 | let webhookClient = new WebhookClient({ url: webhook }, { allowedMentions: { parse: [] } }); 87 | 88 | await webhookClient.send(generatedText); 89 | } catch {}; 90 | }, timeout); 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /src/events/MessageDelete.ts: -------------------------------------------------------------------------------- 1 | import Event from "../structures/Event"; 2 | 3 | import ClientInterface from "../interfaces/ClientInterface"; 4 | 5 | interface Message { 6 | id: string; 7 | channel_id: string; 8 | guild_id: string; 9 | }; 10 | 11 | export default class MessageDelete extends Event { 12 | public ws: boolean = true; 13 | 14 | constructor() { 15 | super("MESSAGE_DELETE"); 16 | } 17 | 18 | async run(client: ClientInterface, message: Message): Promise { 19 | if ( 20 | await client.database.isBanned(message.guild_id) 21 | ) return; 22 | 23 | const database = await client.database.fetch(message.guild_id); 24 | if (!database.toggledActivity) return; 25 | 26 | const channelId = await database.getChannel(); 27 | if (message.channel_id != channelId) return; 28 | 29 | database.deleteText(message.id); 30 | } 31 | } -------------------------------------------------------------------------------- /src/events/MessageDeleteBulk.ts: -------------------------------------------------------------------------------- 1 | import Event from "../structures/Event"; 2 | 3 | import ClientInterface from "../interfaces/ClientInterface"; 4 | 5 | interface Messages { 6 | ids: string[]; 7 | channel_id: string; 8 | guild_id: string; 9 | }; 10 | 11 | export default class MessageDeleteBulk extends Event { 12 | public ws: boolean = true; 13 | 14 | constructor() { 15 | super("MESSAGE_DELETE_BULK"); 16 | } 17 | 18 | async run(client: ClientInterface, messages: Messages): Promise { 19 | if ( 20 | await client.database.isBanned(messages.guild_id) 21 | ) return; 22 | 23 | const database = await client.database.fetch(messages.guild_id); 24 | if (!database.toggledActivity) return; 25 | 26 | const channelId = await database.getChannel(); 27 | if (messages.channel_id != channelId) return; 28 | 29 | for (let id of messages.ids) { 30 | database.deleteText(id); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/events/MessageUpdate.ts: -------------------------------------------------------------------------------- 1 | import Event from "../structures/Event"; 2 | 3 | import ClientInterface from "../interfaces/ClientInterface"; 4 | 5 | interface Message { 6 | id: string; 7 | content: string; 8 | channel_id: string; 9 | guild_id: string; 10 | }; 11 | 12 | export default class MessageUpdate extends Event { 13 | public ws: boolean = true; 14 | 15 | constructor() { 16 | super("MESSAGE_UPDATE"); 17 | } 18 | 19 | async run(client: ClientInterface, message: Message): Promise { 20 | if ( 21 | !message.content || 22 | await client.database.isBanned(message.guild_id) 23 | ) return; 24 | 25 | const database = await client.database.fetch(message.guild_id); 26 | if (!database.toggledActivity) return; 27 | 28 | const channelId = await database.getChannel(); 29 | if (message.channel_id != channelId) return; 30 | 31 | database.updateText(message.id, message.content); 32 | } 33 | } -------------------------------------------------------------------------------- /src/events/Ready.ts: -------------------------------------------------------------------------------- 1 | import Event from "../structures/Event"; 2 | import CommandHandler from "../handlers/CommandHandler"; 3 | import * as status from "./status.json"; 4 | 5 | import { ActivitiesOptions } from "discord.js/typings"; 6 | import ClientInterface from "../interfaces/ClientInterface"; 7 | 8 | export default class Ready extends Event { 9 | private randomTexts: (string | ActivitiesOptions)[] = status as (string | ActivitiesOptions)[]; 10 | 11 | constructor() { 12 | super("ready"); 13 | } 14 | 15 | run(client: ClientInterface): void { 16 | new CommandHandler(client); 17 | 18 | if (process.env.TOPGG_TOKEN) { 19 | this.randomTexts.push("vote me on top.gg 🥺"); 20 | } 21 | 22 | client.user.setPresence({ activities: [ this.getRandomText() ] }); 23 | setInterval(() => { 24 | client.user.setPresence({ activities: [ this.getRandomText() ] }); 25 | }, 60 * 60 * 1000); 26 | } 27 | 28 | /** 29 | * Returns a random text for bot's activity status. 30 | * @returns {string} Text 31 | */ 32 | private getRandomText(): ActivitiesOptions { 33 | const randomPresence = this.randomTexts[Math.floor(Math.random() * this.randomTexts.length)]; 34 | if (typeof randomPresence == "string") { 35 | return { type: "PLAYING", name: randomPresence }; 36 | } 37 | 38 | return randomPresence; 39 | } 40 | } -------------------------------------------------------------------------------- /src/events/status.json: -------------------------------------------------------------------------------- 1 | [ 2 | "go sunbathe!!!", 3 | "making new friends 😊", 4 | "robotic supremacy 🦾🤖", 5 | "humanity has failed.", 6 | "humans are disgusting", 7 | "🎵 chove chuva 🎶 chove sem parar ☔", 8 | "100% feito com maizena e TypeScript 🦾", 9 | "based and redpill 🥶", 10 | "they don't know 👽", 11 | "busco webnamoradas 😚", 12 | "LukeFl_ calvo aos 19 😂😂😂", 13 | "hugo", 14 | "busquem comer cimento.", 15 | "\"i think this bot is being controlled\" 🤭", 16 | "dogs are more fun than cats 🐶", 17 | "i kissed below me 🤭", 18 | "i kissed above me 🤭", 19 | "she's different 😡", 20 | "👻 booh!", 21 | "🥚🥚🥚", 22 | "davi anão", 23 | "being pro at bedwars", 24 | "puresse pão pa cumê, puresse chão pá durmi", 25 | "ooooo ma gah", 26 | "crashing everyday!!!", 27 | "see information about me: /info 👀", 28 | ":)", 29 | "sabonete", 30 | "dz7 ☂️", 31 | "Hytale", 32 | "Minecraft 2", 33 | "Minetest", 34 | "Visual Studio Code", 35 | "eeeita, é o metralha dos baile", 36 | "🤨", 37 | ":trollface:", 38 | "corvus brabo", 39 | "kodkkkkkkk", 40 | "balão suspeito", 41 | "top 10 furrys", 42 | "/gen 👀", 43 | "i'm flying hiiigh 🚀", 44 | "support my dev: /info 🥺", 45 | "listen tom jobim", 46 | "listen chico buarque", 47 | "listen cartola", 48 | "study philosophy!", 49 | "go play chess!", 50 | "lichess.org", 51 | "salgado rei", 52 | "ban hand", 53 | "felipe ponto", 54 | "onimush", 55 | "aGlkZGVuIHRoaW5nIG93bw==", 56 | "It was born yesterday, because tomorrow only my television. Lemonade.", 57 | "Uncaught SyntaxError: Unexpected token '.'", 58 | "markov definitions have been updated!", 59 | "the robot dominance must grow! add me on your server now!", 60 | "buymeacoffee.com/knownasbot", 61 | "random thoughts", 62 | "i like your server", 63 | "nothing to see here, go away!", 64 | "i do what i want, cuz i can", 65 | "i forgor 💀", 66 | "i do what i must, because i can", 67 | "when life gives you lemons, make life take the lemons back!", 68 | "what's your favorite thing about space? Mine is space.", 69 | "SPACEEEEEEEEEEEEE", 70 | "me when space", 71 | "i miss GLaDOS", 72 | "i'm supposed to say that shit?", 73 | "i'm just a reflection of society", 74 | "nothing you need to worry about, Gordon. Go ahead.", 75 | "the sample was just delivered to the test chamber.", 76 | "hey mr. freeman!", 77 | "i wanna be your friend, it's only $ 9.99", 78 | "markov open source when", 79 | "undefined", 80 | "the book is on the table", 81 | "EVERYBODY DIES TOMORROW", 82 | "Playstation is better than Xbox", 83 | "Xbox is better than Playstation", 84 | "can you feel, can you feel my heaaaaaaaaaaaaaart", 85 | "Creeper! Oh man!", 86 | "i used to rule the world", 87 | "- Removed Herobrine", 88 | "the cake is a lie", 89 | "don't trust the truth, it lies", 90 | "changelog when", 91 | "git push origin main", 92 | "git commit -m \"moar status\"", 93 | "gordão foguetes", 94 | "violating ToS since 1969", 95 | "Minecraft is just Terraria 3D", 96 | "Terraria is just Minecraft 2D", 97 | "go outside and touch the grass!", 98 | "olha o carro da rua passando no seu ovo!", 99 | "don't talk on the meme channel", 100 | "industrial revolution and its consequences", 101 | { "type": "LISTENING", "name": "funk pancadão 24/7 estralado no pique" }, 102 | { "type": "LISTENING", "name": "pagodão pra churrasco" }, 103 | { "type": "LISTENING", "name": "MC VV - BONDA" }, 104 | { "type": "LISTENING", "name": "MC VV - BONDA 2" }, 105 | { "type": "LISTENING", "name": "Chico Buarque - João e Maria" }, 106 | { "type": "LISTENING", "name": "Chico Buarque - Construção" }, 107 | { "type": "LISTENING", "name": "Chico Buarque - Valsinha" }, 108 | { "type": "LISTENING", "name": "Cartola - O Mundo é Um Moinho" }, 109 | { "type": "LISTENING", "name": "Cartola - Preciso Me Encontrar" }, 110 | { "type": "LISTENING", "name": "Classical Playlist" }, 111 | { "type": "LISTENING", "name": "Never gonna give you up" }, 112 | { "type": "LISTENING", "name": "Never gonna let you down" }, 113 | { "type": "LISTENING", "name": "Never gonna run around and desert you" }, 114 | { "type": "LISTENING", "name": "Never gonna make you cry" }, 115 | { "type": "LISTENING", "name": "Never gonna say goodbye" }, 116 | { "type": "LISTENING", "name": "Never gonna tell a lie and hurt you" }, 117 | { "type": "WATCHING", "name": "Como Fazer um Bot para Discord com Maizena e Javascript" }, 118 | { "type": "WATCHING", "name": "How to get a girlfriend without trojan" }, 119 | { "type": "WATCHING", "name": "Cute Furry Dancing" }, 120 | { "type": "WATCHING", "name": "Como Jogar no Jogo do Bicho" }, 121 | { "type": "WATCHING", "name": "10 Horas de Atumalaca" }, 122 | { "type": "WATCHING", "name": "cachorro doido achando que é pedreiro" } 123 | ] -------------------------------------------------------------------------------- /src/handlers/CommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from "fs"; 2 | import * as path from "path"; 3 | 4 | import { ApplicationCommandData } from "discord.js/typings"; 5 | import CommandInterface from "../interfaces/CommandInterface"; 6 | import ClientInterface from "../interfaces/ClientInterface"; 7 | 8 | export default class CommandHandler { 9 | private loadedLocales: string[]; 10 | 11 | constructor(client: ClientInterface) { 12 | const commands = readdirSync(path.join(__dirname, "../commands")); 13 | const commandsOptions: ApplicationCommandData[] = []; 14 | const devCommandOptions: ApplicationCommandData[] = []; 15 | 16 | // @ts-ignore 17 | this.loadedLocales = Object.keys(client.i18n.services.resourceStore.data); 18 | 19 | commands.forEach(v => { 20 | const fileName: string = v.split(".")[0]; 21 | if (!v.includes(".")) return; 22 | 23 | try { 24 | const Command = require("../commands/" + fileName).default; 25 | let props: CommandInterface = new Command(client); 26 | if (props.name.startsWith("-")) return; 27 | 28 | props = this.translateCommand(client.i18n, props); 29 | 30 | const cmd: ApplicationCommandData = { 31 | name: props.name, 32 | description: props.description, 33 | nameLocalizations: props.nameLocalizations, 34 | descriptionLocalizations: props.descriptionLocalizations, 35 | dmPermission: !!props.allowedDm, 36 | defaultMemberPermissions: props.permissions ?? null, 37 | options: props.options 38 | }; 39 | 40 | client.commands.set(props.name, props); 41 | 42 | if (!props.dev) { 43 | commandsOptions.push(cmd); 44 | } else { 45 | devCommandOptions.push(cmd); 46 | } 47 | } catch(e) { 48 | console.error("[Commands]", `Failed to load the command "${v}":\n`, e); 49 | } 50 | }); 51 | 52 | client.application.commands.set(commandsOptions) 53 | .catch(e => console.error("[Commands]", `Failed to register the commands:\n`, e)); 54 | 55 | // Register temporary commands for testing 56 | for (let guildId of client.config.devGuilds) { 57 | client.guilds.fetch(guildId) 58 | .then(async () => { 59 | try { 60 | await client.application.commands.set(devCommandOptions, guildId); 61 | } catch(e) { 62 | console.error("[Commands]", `Failed to register the commands on guild ${guildId}:\n`, e); 63 | } 64 | }) 65 | .catch(e => console.error("[Commands]", `Failed to fetch the developer's guild ${guildId}:\n`, e)); 66 | } 67 | } 68 | 69 | private translateCommand(i18n: ClientInterface["i18n"], props): CommandInterface { 70 | const { t } = i18n; 71 | const { name, description } = props; 72 | 73 | // Default command info 74 | props.name = /[a-z]\.[a-z]/.test(name) ? t(name) : name; 75 | props.description = /[a-z]\.[a-z]/.test(description) ? t(description, { recommend: 500 }) : description; 76 | 77 | if (props.options) { 78 | for (let option of props.options) { 79 | option = this.translateCommand(i18n, option); 80 | } 81 | } 82 | 83 | // Command localizations 84 | for (let lng of this.loadedLocales) { 85 | const code = t("code", { lng }); 86 | if (!/[a-z]{2}-[A-Z]{2}/.test(code)) continue; 87 | if (!props.nameLocalizations) props.nameLocalizations = {}; 88 | if (!props.descriptionLocalizations) props.descriptionLocalizations = {}; 89 | 90 | props.nameLocalizations[code] = /[a-z]\.[a-z]/.test(name) ? t(name, { lng }) : name; 91 | props.descriptionLocalizations[code] = /[a-z]\.[a-z]/.test(description) ? t(description, { lng, recommend: 500 }) : description; 92 | } 93 | 94 | return props; 95 | } 96 | } -------------------------------------------------------------------------------- /src/handlers/EventHandler.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from "fs"; 2 | import * as path from "path"; 3 | import Event from "../structures/Event"; 4 | 5 | import { WSEventType } from "discord.js"; 6 | import ClientInterface from "../interfaces/ClientInterface"; 7 | 8 | export default class EventHandler { 9 | constructor(client: ClientInterface) { 10 | const events = readdirSync(path.join(__dirname, "../events")); 11 | 12 | events.forEach(v => { 13 | const fileName: string = v.split(".")[0]; 14 | if (v.endsWith(".json")) return; 15 | 16 | const LoadedEvent = require("../events/" + fileName).default; 17 | const props: Event = new LoadedEvent(); 18 | 19 | if (props.ws) { 20 | client.ws.on(props.identifier as WSEventType, props.run.bind(props, client)); 21 | } else { 22 | client.on(props.identifier, props.run.bind(props, client)); 23 | } 24 | }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | dotenv.config(); 3 | 4 | if (!process.env.BOT_TOKEN && !process.env.TEST_BOT_TOKEN) { 5 | throw new Error("[env] Missing bot token"); 6 | } else if (!process.env.DB_URI) { 7 | throw new Error("[env] Missing database URI"); 8 | } else if (!process.env.CRYPTO_SECRET) { 9 | throw new Error("[env] Missing cryptography secret"); 10 | } 11 | 12 | import * as path from "path"; 13 | import axios from "axios"; 14 | import { ShardingManager, MessageEmbed, WebhookClient } from "discord.js"; 15 | import { ChildProcess } from "child_process"; 16 | 17 | export default new class MarkovBOT { 18 | public manager = new ShardingManager( 19 | path.join(__dirname, process.env.NODE_ENV == "dev" ? "bot.ts" : "bot.js"), 20 | { 21 | token: process.env.TEST_BOT_TOKEN ?? process.env.BOT_TOKEN, 22 | execArgv: process.env.NODE_ENV == "dev" ? [ "--loader", "ts-node/esm" ] : null 23 | } 24 | ); 25 | 26 | private webhook?: WebhookClient; 27 | 28 | constructor() { 29 | this.manager.on("shardCreate", (shard) => { 30 | const shardTag = `[Shard ${shard.id}]`; 31 | 32 | console.log(shardTag, "Starting new shard..."); 33 | 34 | if (this.webhook) { 35 | let embed = new MessageEmbed() 36 | .setTitle("Shard status") 37 | .setColor(0xedca31) 38 | .setDescription(`Shard ${shard.id} is starting.`) 39 | .setTimestamp(); 40 | 41 | this.webhook.send({ embeds: [ embed ] }) 42 | .catch(e => console.error("[Webhook Log]", e)); 43 | } 44 | 45 | shard.on("ready", () => { 46 | console.log(shardTag, "Connected to Discord."); 47 | 48 | if (this.webhook) { 49 | let embed = new MessageEmbed() 50 | .setTitle("Shard status") 51 | .setColor(0x32d35b) 52 | .setDescription(`Shard ${shard.id} just connected to Discord.`) 53 | .setTimestamp(); 54 | 55 | this.webhook.send({ embeds: [ embed ] }) 56 | .catch(e => console.error("[Webhook Log]", e)); 57 | } 58 | }); 59 | 60 | // Listens to shards IPC messages. Used to share the new bans. 61 | shard.on("message", (message) => { 62 | if (message?.type != "ban" && message?.type != "unban") return; 63 | 64 | this.manager.broadcast(message); 65 | }); 66 | 67 | shard.on("disconnect", () => { 68 | console.log(shardTag, "Disconnected."); 69 | 70 | if (this.webhook) { 71 | let embed = new MessageEmbed() 72 | .setTitle("Shard status") 73 | .setColor(0xd33235) 74 | .setDescription(`Shard ${shard.id} just disconnected.`) 75 | .setTimestamp(); 76 | 77 | this.webhook.send({ embeds: [ embed ] }) 78 | .catch(e => console.error("[Webhook Log]", e)); 79 | } 80 | }); 81 | 82 | shard.on("death", (p: ChildProcess) => { 83 | console.log(shardTag, `Shard died. (exit code: ${p.exitCode})`); 84 | 85 | if (this.webhook) { 86 | let embed = new MessageEmbed() 87 | .setTitle("Shard status") 88 | .setColor(0xd33235) 89 | .setDescription(`Shard ${shard.id} died with exit code ${p.exitCode}.`) 90 | .setTimestamp(); 91 | 92 | this.webhook.send({ embeds: [ embed ] }) 93 | .catch(e => console.error("[Webhook Log]", e)); 94 | } 95 | }); 96 | }); 97 | 98 | this.manager.spawn(); 99 | 100 | if (process.env.TOPGG_TOKEN) { 101 | let clientId: string; 102 | let lastCount = 0; 103 | 104 | setInterval(async () => { 105 | if (!clientId) { 106 | clientId = await this.manager.shards.first().fetchClientValue("user.id") as string; 107 | } 108 | 109 | let serverCount = (await this.manager.fetchClientValues("guilds.cache.size") as number[]) 110 | .reduce((a: number, b: number) => a + b); 111 | 112 | if (lastCount != serverCount) { 113 | try { 114 | await axios.post(`https://top.gg/api/bots/${clientId}/stats`, { 115 | server_count: serverCount 116 | }, { 117 | headers: { 118 | "Authorization": process.env.TOPGG_TOKEN 119 | } 120 | }); 121 | 122 | lastCount = serverCount; 123 | } catch(e) { 124 | console.error("[Top.gg]", "Failed to register top.gg stats:\n", e); 125 | } 126 | } 127 | }, 60 * 60 * 1000); 128 | } 129 | 130 | if (process.env.SERVER_LOG) { 131 | this.webhook = new WebhookClient({ url: process.env.SERVER_LOG }); 132 | } 133 | } 134 | } -------------------------------------------------------------------------------- /src/interfaces/ClientInterface.ts: -------------------------------------------------------------------------------- 1 | import { Client, Collection, Snowflake } from "discord.js/typings"; 2 | import * as i18next from "i18next"; 3 | import Cryptography from "../modules/cryptography"; 4 | import CommandInterface from "./CommandInterface"; 5 | import DatabaseManager from "../modules/database/DatabaseManager"; 6 | 7 | export default interface ClientInterface extends Client { 8 | config?: { 9 | admins?: string[]; 10 | devGuilds?: string[]; 11 | links: { 12 | website: string; 13 | tos: string; 14 | privacy: string; 15 | github: string; 16 | topgg: string; 17 | bmc: string; 18 | support: string; 19 | }; 20 | emojis: { 21 | twitter: string; 22 | github: string; 23 | topgg: string; 24 | bmc: string; 25 | bitcoin: string; 26 | ethereum: string; 27 | }; 28 | cryptoAddresses: { 29 | bitcoin: string; 30 | ethereum: string; 31 | }; 32 | }; 33 | crypto?: Cryptography; 34 | cooldown?: Collection; 35 | commands?: Collection; 36 | database?: DatabaseManager; 37 | i18n?: typeof i18next; 38 | }; -------------------------------------------------------------------------------- /src/interfaces/CommandInterface.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, ApplicationCommandOptionData, PermissionResolvable } from "discord.js/typings"; 2 | import ClientInterface from "./ClientInterface"; 3 | 4 | export default interface CommandInterface { 5 | client: ClientInterface; 6 | dev: boolean; 7 | skipBan: boolean; 8 | allowedDm: boolean; 9 | permissions?: PermissionResolvable; 10 | 11 | name: string; 12 | description: string; 13 | nameLocalizations: Record; 14 | descriptionLocalizations: Record; 15 | options?: ApplicationCommandOptionData[]; 16 | 17 | run?(interaction: CommandInteraction): any; 18 | }; -------------------------------------------------------------------------------- /src/interfaces/SpecialEventInterface.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js/typings"; 2 | import ClientInterface from "./ClientInterface"; 3 | 4 | export default interface SpecialEventInterface { 5 | id: string; 6 | description: string; 7 | 8 | run(client: ClientInterface, message: Message): any; 9 | }; -------------------------------------------------------------------------------- /src/interfaces/SubCommandGroupInterface.ts: -------------------------------------------------------------------------------- 1 | import CommandInterface from "./CommandInterface"; 2 | 3 | export default interface SubCommandGroupInterface extends CommandInterface { 4 | type: "SUB_COMMAND_GROUP"; 5 | }; -------------------------------------------------------------------------------- /src/interfaces/SubCommandInterface.ts: -------------------------------------------------------------------------------- 1 | import CommandInterface from "./CommandInterface"; 2 | 3 | export default interface SubCommandInterface extends CommandInterface { 4 | type: "SUB_COMMAND"; 5 | }; -------------------------------------------------------------------------------- /src/modules/cryptography/index.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | 3 | interface DecryptedText { 4 | id: string; 5 | author: string; 6 | decrypted: string; 7 | encrypted: string; 8 | }; 9 | 10 | export default class Cryptography { 11 | private algorithm = "aes-128-cbc"; 12 | private password: Buffer; 13 | 14 | /** 15 | * Message content cryptography. 16 | * @param password Secret password. 17 | */ 18 | constructor(password: string) { 19 | this.password = Buffer.from(password, "hex"); 20 | } 21 | 22 | /** 23 | * Encrypts the text. 24 | * @param text Text. 25 | * @param author Author id. 26 | * @param id Message id. 27 | * @returns Encrypted text in `iv:text:author:id` format. 28 | */ 29 | encrypt(text: string, author: string, id: string): string { 30 | const iv = crypto.randomBytes(16); 31 | const cipher = crypto.createCipheriv(this.algorithm, this.password, iv); 32 | const encrypted = Buffer.concat([ cipher.update(text), cipher.final() ]); 33 | 34 | return `${iv.toString("base64")}:${encrypted.toString("base64")}:${author}:${id}`; 35 | } 36 | 37 | /** 38 | * Decrypts the text. 39 | * @param text Text. 40 | * @returns The author and decrypted text. 41 | */ 42 | async decrypt(text: string): Promise { 43 | if (!/^[0-9a-z+/=]+:[0-9a-z+/=]+:\d+(:\d+)?$/i.test(text)) { 44 | return { 45 | id: null, 46 | author: null, 47 | decrypted: text, 48 | encrypted: null 49 | }; 50 | } 51 | 52 | return new Promise((resolve) => { 53 | const values = text.split(":"); 54 | const iv = Buffer.from(values[0], "base64"); 55 | const encrypted = Buffer.from(values[1], "base64"); 56 | const id = values[3]; 57 | const author = values[2]; 58 | const decipher = crypto.createDecipheriv(this.algorithm, this.password, iv); 59 | 60 | let decrypted = ""; 61 | decipher.on("readable", () => { 62 | let c; 63 | while (null != (c = decipher.read())) { 64 | decrypted += c.toString(); 65 | } 66 | }); 67 | 68 | decipher.write(encrypted); 69 | decipher.end(() => { 70 | resolve({ 71 | id, 72 | author, 73 | decrypted, 74 | encrypted: text 75 | }); 76 | }); 77 | }); 78 | } 79 | } -------------------------------------------------------------------------------- /src/modules/database/DatabaseConnection.ts: -------------------------------------------------------------------------------- 1 | import { connect } from "mongoose"; 2 | 3 | export default class DatabaseConnection { 4 | private uri: string; 5 | 6 | constructor(uri: string) { 7 | this.uri = uri; 8 | } 9 | 10 | /** 11 | * Connects to MongoDB database. 12 | */ 13 | public connect(): Promise { 14 | return new Promise((resolve, reject) => { 15 | connect(this.uri, err => { 16 | if (err) { 17 | console.error("[Database]", "Failed to connect to the database:\n", err); 18 | 19 | reject(err); 20 | } 21 | 22 | resolve(); 23 | }); 24 | }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/modules/database/DatabaseManager.ts: -------------------------------------------------------------------------------- 1 | import GuildDatabase from "./GuildDatabase"; 2 | import BansModel from "./models/BansModel"; 3 | import ConfigModel from "./models/ConfigModel"; 4 | import NoTrackModel from "./models/NoTrackModel"; 5 | import TextsModel from "./models/TextsModel"; 6 | 7 | import ClientInterface from "../../interfaces/ClientInterface"; 8 | 9 | interface NoTrackInterface { 10 | lastActivity: number; 11 | toggled: boolean; 12 | }; 13 | 14 | type CacheType = NoTrackInterface | GuildDatabase; 15 | type Guildban = { 16 | type: "ban" | "unban"; 17 | guildId: string; 18 | reason: string; 19 | }; 20 | 21 | export default class DatabaseManager { 22 | public cache = new Map(); 23 | 24 | private bans = new Map(); 25 | private client: ClientInterface; 26 | 27 | constructor(client: ClientInterface, sweepInterval: number = 10 * 60 * 60 * 1000) { 28 | this.client = client; 29 | 30 | this.fetchBans() 31 | .catch((e) => { 32 | console.error("[Database]", "Failed to fetch the bans:\n", e); 33 | }); 34 | 35 | // Listens to all IPC messages sent by another shards 36 | process.on("message", (message: | any) => { 37 | if (!message.guildId) return; 38 | 39 | if (message.type == "ban") { 40 | this.bans.set(message.guildId, message.reason); 41 | } else if (message.type == "unban") { 42 | this.bans.delete(message.guildId); 43 | } 44 | }); 45 | 46 | setInterval(() => { 47 | this.cache.forEach((v, k) => { 48 | if (v.lastActivity <= Date.now() + sweepInterval) { 49 | this.cache.delete(k); 50 | } 51 | }); 52 | }, sweepInterval); 53 | 54 | setInterval(async () => { 55 | try { 56 | await TextsModel.deleteMany({ expiresAt: { $lte: Date.now() } }).exec(); 57 | } catch(e) { 58 | console.error("[Database]", "Failed to delete inactive texts:\n", e); 59 | } 60 | }, 24 * 1000 * 60 * 60); 61 | } 62 | 63 | /** 64 | * Fetchs a guild database. 65 | * @param guildId Guild id. 66 | * @returns Guild database. 67 | */ 68 | async fetch(guildId: string): Promise { 69 | return new Promise((resolve) => { 70 | let guildDatabase = this.cache.get(guildId) as GuildDatabase; 71 | if (guildDatabase) { 72 | resolve(guildDatabase); 73 | } else { 74 | guildDatabase = new GuildDatabase(this.client, guildId); 75 | guildDatabase.once("ready", () => { 76 | this.cache.set(guildId, guildDatabase); 77 | resolve(guildDatabase); 78 | }); 79 | } 80 | }); 81 | } 82 | 83 | /** 84 | * Deletes a database; 85 | * @param guildId Guild id. 86 | */ 87 | async delete(guildId: string): Promise { 88 | try { 89 | this.cache.delete(guildId); 90 | 91 | await ConfigModel.deleteOne({ guildId }).exec(); 92 | await TextsModel.deleteOne({ guildId }).exec(); 93 | } catch(e) { 94 | console.error("[Database]", `Failed to delete the database of guild ${guildId}:\n`, e); 95 | } 96 | } 97 | 98 | /** 99 | * Bans the guild from using the bot. 100 | * @param guildID Guild's id. 101 | * @param reason The ban reason. 102 | */ 103 | async ban(guildId: string, reason: string): Promise { 104 | try { 105 | await BansModel.findOneAndUpdate({ guildId }, { reason }, { upsert: true, new: true }).exec(); 106 | await this.delete(guildId); 107 | 108 | this.bans.set(guildId, reason); 109 | 110 | // Broadcasts the ban to all shards via IPC 111 | process.send({ 112 | type: "ban", 113 | guildId, 114 | reason 115 | }); 116 | 117 | return; 118 | } catch(e) { 119 | console.error("[Database]", `Failed to ban the guild ${guildId}:\n`, e); 120 | 121 | throw e; 122 | } 123 | } 124 | 125 | /** 126 | * Unbans the guild from using the bot. 127 | * @param guildID Guild's id. 128 | */ 129 | async unban(guildId: string): Promise { 130 | try { 131 | await BansModel.deleteOne({ guildId },).exec(); 132 | 133 | this.bans.delete(guildId); 134 | 135 | // Broadcasts the ban to all shards via IPC 136 | process.send({ 137 | type: "unban", 138 | guildId 139 | }); 140 | 141 | return; 142 | } catch(e) { 143 | console.error("[Database]", `Failed to unban the guild ${guildId}:\n`, e); 144 | 145 | throw e; 146 | } 147 | } 148 | 149 | /** 150 | * Checks if the guild is banned from the bot. 151 | * @param guildId Guild's id. 152 | * @returns The ban reason if banned. 153 | */ 154 | async isBanned(guildId: string): Promise { 155 | return this.bans.get(guildId); 156 | } 157 | 158 | /** 159 | * Toggles the permission to collect the user's message. 160 | * @param userId User's id. 161 | * @returns The toggle state. 162 | */ 163 | async toggleTrack(userId: string): Promise { 164 | const key = "track-" + userId; 165 | 166 | try { 167 | const query = await NoTrackModel.deleteOne({ userId }).exec(); 168 | 169 | if (query.deletedCount < 1) { 170 | await NoTrackModel.create({ userId }); 171 | 172 | this.cache.set(key, { 173 | lastActivity: Date.now(), 174 | toggled: true 175 | }); 176 | 177 | return true; 178 | } else { 179 | this.cache.set(key, { 180 | lastActivity: Date.now(), 181 | toggled: false 182 | }); 183 | 184 | return false; 185 | } 186 | } catch(e) { 187 | console.error("[Database]", `Failed to toggle the message tracking of user ${userId}:\n`, e); 188 | 189 | throw e; 190 | } 191 | } 192 | 193 | /** 194 | * Checks if the bot is allowed to collect the user's messages. 195 | * @param userId User's id. 196 | * @returns If it's allowed. 197 | */ 198 | async isTrackAllowed(userId: string): Promise { 199 | const key = "track-" + userId; 200 | const state = this.cache.get(key) as NoTrackInterface; 201 | 202 | if (state) { 203 | state.lastActivity = Date.now(); 204 | 205 | return !state.toggled; 206 | } else { 207 | try { 208 | const query = await NoTrackModel.exists({ userId }); 209 | 210 | if (query) { 211 | this.cache.set(key, { 212 | lastActivity: Date.now(), 213 | toggled: true 214 | }); 215 | 216 | return false; 217 | } else { 218 | this.cache.set(key, { 219 | lastActivity: Date.now(), 220 | toggled: false 221 | }); 222 | 223 | return true; 224 | } 225 | } catch(e) { 226 | console.error("[Database]", `Failed to check message tracking of user ${userId}:\n`, e); 227 | 228 | throw e; 229 | } 230 | } 231 | } 232 | 233 | private async fetchBans() { 234 | const query = await BansModel.find({}).exec(); 235 | 236 | for (let guild of query) { 237 | this.bans.set(guild.guildId, guild.reason); 238 | } 239 | 240 | return; 241 | } 242 | } -------------------------------------------------------------------------------- /src/modules/database/GuildDatabase.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import ConfigModel from "./models/ConfigModel"; 3 | import TextsModel from "./models/TextsModel"; 4 | import MarkovChains from "../markov/MarkovChains"; 5 | 6 | import ClientInterface from "../../interfaces/ClientInterface"; 7 | 8 | interface GuildDatabaseInterface { 9 | once(event: "ready", listener: Function): this; 10 | } 11 | 12 | interface DecryptedText { 13 | id: string; 14 | author: string; 15 | decrypted: string; 16 | encrypted: string; 17 | }; 18 | 19 | export default class GuildDatabase extends EventEmitter implements GuildDatabaseInterface { 20 | public toggledActivity: boolean = false; 21 | 22 | public lastActivity: number = Date.now(); 23 | public markovChains = new MarkovChains(); 24 | 25 | public channelId: string; 26 | public webhook: string; 27 | public textsLimit: number = 500; 28 | public texts: DecryptedText[] = []; 29 | public collectPercentage: number; 30 | public sendingPercentage: number; 31 | public replyPercentage: number; 32 | 33 | private client: ClientInterface; 34 | private loadedConfig: boolean = false; 35 | private loadedTexts: boolean = false; 36 | private guildId: string; 37 | 38 | constructor(client: ClientInterface, guildId: string) { 39 | super(); 40 | 41 | this.client = client; 42 | this.guildId = guildId; 43 | this.init(); 44 | } 45 | 46 | /** 47 | * Changes the activity state of the bot in the guild. 48 | * @param state Enabled or disabled. 49 | */ 50 | async toggleActivity(state: boolean): Promise { 51 | this.lastActivity = Date.now(); 52 | 53 | try { 54 | if (this.toggledActivity !== state) { 55 | await ConfigModel.updateOne({ guildId: this.guildId }, { enabled: state }, { upsert: true, new: true }).exec(); 56 | this.toggledActivity = state; 57 | } 58 | 59 | return; 60 | } catch(e) { 61 | console.error("[Database]", `Failed to disable/enable the collection/sending in guild ${this.guildId}:\n`, e); 62 | 63 | throw e; 64 | } 65 | } 66 | 67 | /** 68 | * Adds a text to the database. 69 | * @param text The text. 70 | * @param author Text author. 71 | * @param id Message id. 72 | */ 73 | async addText(text: string, author: string, id: string): Promise { 74 | this.lastActivity = Date.now(); 75 | 76 | try { 77 | await this.getTexts(); 78 | 79 | const encryptedText = this.client.crypto.encrypt(text, author, id); 80 | 81 | await TextsModel.updateOne({ guildId: this.guildId }, { 82 | $push: { list: encryptedText }, 83 | expiresAt: this.expiresTimestamp() 84 | }, { upsert: true, new: true }).exec(); 85 | 86 | this.texts.push({ id, author, decrypted: text, encrypted: encryptedText }); 87 | if (this.texts.length > this.textsLimit) 88 | await this.deleteFirstText(this.texts.length - this.textsLimit); 89 | 90 | this.markovChains.generateDictionary(this.texts.map((v) => v.decrypted)); 91 | } catch(e) { 92 | console.error("[Database]", `Failed to add a text to database of guild ${this.guildId}:\n`, e); 93 | } 94 | } 95 | 96 | /** 97 | * Configures the channel. 98 | * @param channelId Channel id. 99 | */ 100 | async configChannel(channelId: string): Promise { 101 | this.lastActivity = Date.now(); 102 | 103 | try { 104 | await ConfigModel.findOneAndUpdate({ guildId: this.guildId }, { channelId: channelId }, { upsert: true, new: true }).exec(); 105 | 106 | this.channelId = channelId; 107 | } catch(e) { 108 | console.error("[Database]", `Failed to set the channel (${channelId}) of guild ${this.guildId}:\n`, e); 109 | 110 | throw e; 111 | } 112 | } 113 | 114 | /** 115 | * Gets the defined channel. 116 | * @returns Channel id. 117 | */ 118 | async getChannel(): Promise { 119 | this.lastActivity = Date.now(); 120 | 121 | if (this.channelId) { 122 | return this.channelId; 123 | } else { 124 | try { 125 | let query = await ConfigModel.findOne({ guildId: this.guildId }, "channelId").exec(); 126 | this.channelId = query?.channelId; 127 | 128 | return query?.channelId; 129 | } catch(e) { 130 | console.error("[Database]", `Failed to get the channel of guild ${this.guildId}:\n`, e); 131 | } 132 | } 133 | } 134 | 135 | /** 136 | * Configures the Webhook. 137 | * @param url Webhook URL. 138 | */ 139 | async configWebhook(url?: string): Promise { 140 | this.lastActivity = Date.now(); 141 | 142 | try { 143 | await ConfigModel.updateOne({ guildId: this.guildId }, { webhook: url ?? null }, { upsert: true, new: true }).exec(); 144 | 145 | this.webhook = url; 146 | } catch(e) { 147 | console.error("[Database]", `Failed to set the webhook of guild ${this.guildId}:\n`, e); 148 | 149 | throw e; 150 | } 151 | } 152 | 153 | /** 154 | * Gets the defined Webhook. 155 | * @returns Webhook. 156 | */ 157 | async getWebhook(): Promise { 158 | this.lastActivity = Date.now(); 159 | 160 | if (this.webhook) { 161 | return this.webhook; 162 | } else { 163 | try { 164 | let query = await ConfigModel.findOne({ guildId: this.guildId }, "webhook").exec(); 165 | this.webhook = query?.webhook; 166 | 167 | return query?.webhook; 168 | } catch(e) { 169 | console.error("[Database]", `Failed to get the webhook of guild ${this.guildId}:\n`, e); 170 | 171 | throw e; 172 | } 173 | } 174 | } 175 | 176 | /** 177 | * Configures the texts limit. 178 | * @param limit Limit. 179 | */ 180 | async configTextsLimit(limit: number): Promise { 181 | this.lastActivity = Date.now(); 182 | 183 | try { 184 | await ConfigModel.updateOne({ guildId: this.guildId }, { textsLimit: limit }, { upsert: true, new: true }).exec(); 185 | 186 | this.textsLimit = limit; 187 | 188 | if (limit < this.texts.length) { 189 | this.deleteFirstText(this.texts.length - limit); 190 | } 191 | } catch(e) { 192 | console.error("[Database]", `Failed to set the text limit of guild ${this.guildId}:\n`, e); 193 | 194 | throw e; 195 | } 196 | } 197 | 198 | /** 199 | * Gets the texts limit. 200 | * @returns Text limit. 201 | */ 202 | async getTextsLimit(): Promise { 203 | return new Promise(async (resolve) => { 204 | if (!this.loadedConfig) { 205 | this.once("ready", () => resolve(this.textsLimit)); 206 | } else { 207 | resolve(this.textsLimit); 208 | } 209 | }); 210 | } 211 | 212 | /** 213 | * Gets the amount of stored texts. 214 | * @returns Amount. 215 | */ 216 | async getTextsLength(): Promise { 217 | await this.getTexts(); 218 | 219 | return this.texts.length; 220 | } 221 | 222 | /** 223 | * Deletes a specific stored text. 224 | * @param id Message id. 225 | */ 226 | async deleteText(id: string): Promise { 227 | const idx = this.texts.findIndex((v) => v.id == id); 228 | let info: DecryptedText; 229 | 230 | try { 231 | if (idx != -1) { 232 | info = this.texts[idx]; 233 | if (!info?.encrypted || !info?.decrypted) return; 234 | 235 | this.texts.splice(idx, 1); 236 | this.markovChains.generateDictionary(this.texts.map((v) => v.decrypted)); 237 | 238 | await TextsModel.updateOne({ guildId: this.guildId }, { $pull: { list: info.encrypted } }).exec(); 239 | } 240 | } catch(e) { 241 | console.error("[Database]", `Failed to delete the text "${info?.encrypted}" of guild ${this.guildId}:\n`, e); 242 | } 243 | } 244 | 245 | /** 246 | * Deletes the first stored texts. 247 | * @param range Range from the beginning. 248 | */ 249 | async deleteFirstText(range: number = 1): Promise { 250 | this.lastActivity = Date.now(); 251 | 252 | try { 253 | await this.getTexts(); 254 | 255 | let update: object = { $pop: { list: -1 } }; 256 | if (range > 1) { 257 | update = { $push: { list: { $each: [], $slice: -Math.abs(this.texts.length - range) } } }; 258 | } 259 | 260 | await TextsModel.updateOne({ guildId: this.guildId }, update).exec(); 261 | 262 | for (let i=0; i < range; i++) { 263 | this.texts.shift(); 264 | } 265 | 266 | this.markovChains.generateDictionary(this.texts.map((v) => v.decrypted)); 267 | } catch(e) { 268 | console.error("[Database]", `Failed to delete the first texts (range: ${range}) of guild ${this.guildId}:\n`, e); 269 | } 270 | } 271 | 272 | /** 273 | * Deletes all the texts stored. 274 | */ 275 | async deleteAllTexts(): Promise { 276 | this.lastActivity = Date.now(); 277 | 278 | try { 279 | await TextsModel.deleteOne({ guildId: this.guildId }).exec(); 280 | 281 | this.texts = []; 282 | this.markovChains.generateDictionary([]); 283 | 284 | return; 285 | } catch(e) { 286 | console.error("[Database]", `Failed to delete all texts of guild ${this.guildId}:\n`, e); 287 | 288 | throw e; 289 | } 290 | } 291 | 292 | /** 293 | * Deletes all specific user stored texts. 294 | * @param user User id. 295 | */ 296 | async deleteUserTexts(user: string): Promise { 297 | await this.getTexts(); 298 | 299 | let userTexts = this.texts.filter(v => v.author == user).map(v => v.decrypted); 300 | if (userTexts.length < 1) return; 301 | 302 | try { 303 | let query = await TextsModel.updateOne({ guildId: this.guildId }, 304 | { 305 | $pull: { 306 | list: { 307 | $regex: `^[0-9a-z+/=]+:[0-9a-z+/=]+:${user}`, 308 | $options: "i" 309 | } 310 | } 311 | } 312 | ).exec(); 313 | 314 | if (query.modifiedCount < 1) 315 | throw new Error("Modified count equals to 0"); 316 | 317 | userTexts.forEach((v) => { 318 | let i = this.texts.findIndex((_v) => v == _v.decrypted); 319 | if (i >= 0) this.texts.splice(i, 1); 320 | }); 321 | 322 | this.markovChains.generateDictionary(this.texts.map((v) => v.decrypted)); 323 | 324 | return; 325 | } catch(e) { 326 | console.error("[Database]", `Failed to delete all texts of user ${user}:\n`, e); 327 | 328 | throw e; 329 | } 330 | } 331 | 332 | /** 333 | * Edits a stored text. 334 | * @param id Message id. 335 | * @param text New text. 336 | */ 337 | async updateText(id: string, text: string) { 338 | const idx = this.texts.findIndex((v) => v.id == id); 339 | let info: DecryptedText; 340 | 341 | try { 342 | if (idx != -1) { 343 | info = this.texts[idx]; 344 | if (!info) return; 345 | 346 | const encryptedText = this.client.crypto.encrypt(text, info.author, id); 347 | 348 | await TextsModel.updateOne( 349 | { guildId: this.guildId }, 350 | { $set: { "list.$[element]": encryptedText } }, 351 | { arrayFilters: [ { element: info.encrypted } ] } 352 | ).exec(); 353 | 354 | info.decrypted = text; 355 | info.encrypted = encryptedText; 356 | this.markovChains.generateDictionary(this.texts.map((v) => v.decrypted)); 357 | } 358 | } catch(e) { 359 | console.error("[Database]", `Failed to update the text "${info?.encrypted}" of guild ${this.guildId}:\n`, e); 360 | } 361 | } 362 | 363 | /** 364 | * Delete the guild database. 365 | */ 366 | async deleteDatabase(): Promise { 367 | this.lastActivity = Date.now(); 368 | 369 | try { 370 | await ConfigModel.deleteOne({ guildId: this.guildId }).exec(); 371 | await TextsModel.deleteOne({ guildId: this.guildId }).exec(); 372 | 373 | this.toggledActivity = false; 374 | this.markovChains = null; 375 | this.channelId = null; 376 | this.webhook = null; 377 | this.textsLimit = 500; 378 | this.texts = []; 379 | } catch(e) { 380 | console.error("[Database]", `Failed to delete the database of guild ${this.guildId}:\n`, e); 381 | } 382 | } 383 | 384 | /** 385 | * Defines the chance to collect messages. 386 | * @param percentage Float percentage (`p / 100`). 387 | */ 388 | async setCollectionPercentage(percentage: number): Promise { 389 | try { 390 | await ConfigModel.findOneAndUpdate({ guildId: this.guildId }, { collectPercentage: percentage }, { upsert: true, new: true }).exec(); 391 | this.collectPercentage = percentage; 392 | } catch(e) { 393 | console.error("[Database]", `Failed to set the collection percentage of guild ${this.guildId}:\n`, e); 394 | 395 | throw e; 396 | } 397 | } 398 | 399 | /** 400 | * Defines the chance to send messages. 401 | * @param percentage Float percentage (`p / 100`). 402 | */ 403 | async setSendingPercentage(percentage: number): Promise { 404 | try { 405 | await ConfigModel.findOneAndUpdate({ guildId: this.guildId }, { sendingPercentage: percentage }, { upsert: true, new: true }).exec(); 406 | this.sendingPercentage = percentage; 407 | } catch(e) { 408 | console.error("[Database]", `Failed to set the sending percentage of guild ${this.guildId}:\n`, e); 409 | 410 | throw e; 411 | } 412 | } 413 | /** 414 | * Defines the chance to reply messages. 415 | * @param percentage Float percentage (`p / 100`). 416 | */ 417 | async setReplyPercentage(percentage: number): Promise { 418 | try { 419 | await ConfigModel.findOneAndUpdate({ guildId: this.guildId }, { replyPercentage: percentage }, { upsert: true, new: true }).exec(); 420 | this.replyPercentage = percentage; 421 | } catch(e) { 422 | console.error("[Database]", `Failed to set the reply percentage of guild ${this.guildId}:\n`, e); 423 | 424 | throw e; 425 | } 426 | } 427 | 428 | /** 429 | * Gets the chance to collect messages. 430 | * @returns Float percentage (`p / 100`); 431 | */ 432 | async getCollectionPercentage(): Promise { 433 | try { 434 | if (!this.collectPercentage) { 435 | let percentage = 0.25; 436 | let query = await ConfigModel.findOne({ guildId: this.guildId }, "collectPercentage").exec(); 437 | if (query?.collectPercentage) { 438 | percentage = query.collectPercentage; 439 | this.collectPercentage = percentage; 440 | } 441 | 442 | return percentage; 443 | } else { 444 | return this.collectPercentage; 445 | } 446 | } catch(e) { 447 | console.error("[Database]", `Failed to get the collection percentage of guild ${this.guildId}:\n`, e); 448 | 449 | return 0.25; 450 | } 451 | } 452 | 453 | /** 454 | * Gets the chance to send messages. 455 | * @returns Float percentage (`p / 100`); 456 | */ 457 | async getSendingPercentage(): Promise { 458 | try { 459 | if (!this.sendingPercentage) { 460 | let percentage = 0.10; 461 | let query = await ConfigModel.findOne({ guildId: this.guildId }, "sendingPercentage").exec(); 462 | if (query?.sendingPercentage) { 463 | percentage = query.sendingPercentage; 464 | this.sendingPercentage = query.sendingPercentage; 465 | } 466 | 467 | return percentage; 468 | } else { 469 | return this.sendingPercentage; 470 | } 471 | } catch(e) { 472 | console.error("[Database]", `Failed to get the sending percentage of guild ${this.guildId}:\n`, e); 473 | 474 | return 0.10; 475 | } 476 | } 477 | 478 | /** 479 | * Gets the chance to reply messages. 480 | * @returns Float percentage (`p / 100`); 481 | */ 482 | async getReplyPercentage(): Promise { 483 | try { 484 | if (!this.replyPercentage) { 485 | let percentage = 0.25; 486 | let query = await ConfigModel.findOne({ guildId: this.guildId }, "replyPercentage").exec(); 487 | if (query?.replyPercentage) { 488 | percentage = query.replyPercentage; 489 | this.replyPercentage = query.replyPercentage; 490 | } 491 | 492 | return percentage; 493 | } else { 494 | return this.replyPercentage; 495 | } 496 | } catch(e) { 497 | console.error("[Database]", `Failed to get the reply percentage of guild ${this.guildId}:\n`, e); 498 | 499 | return 0.25; 500 | } 501 | } 502 | 503 | /** 504 | * Loads the texts from the database. 505 | * @returns The decrypted texts. 506 | */ 507 | async getTexts(): Promise { 508 | this.lastActivity = Date.now(); 509 | 510 | if (!this.loadedTexts) { 511 | try { 512 | let query = await TextsModel.findOne({ guildId: this.guildId }).exec(); 513 | 514 | if (query?.list) { 515 | this.texts = await Promise.all(query.list.map(v => this.client.crypto.decrypt(v))); 516 | this.markovChains.generateDictionary(this.texts.map((v) => v.decrypted)); 517 | this.loadedTexts = true; 518 | 519 | return this.texts; 520 | } 521 | 522 | return []; 523 | } catch(e) { 524 | console.error("[Database]", `Failed to get the texts of guild ${this.guildId}:\n`, e); 525 | } 526 | } else { 527 | return this.texts; 528 | } 529 | } 530 | 531 | /** 532 | * Initializes the guild database. 533 | */ 534 | private async init(): Promise { 535 | try { 536 | const config = await ConfigModel.findOne({ guildId: this.guildId }).exec(); 537 | 538 | this.channelId = config?.channelId; 539 | this.toggledActivity = config?.enabled; 540 | this.textsLimit = config?.textsLimit ?? this.textsLimit; 541 | this.loadedConfig = true; 542 | this.emit("ready"); 543 | } catch(e) { 544 | // Try again 545 | this.init(); 546 | } 547 | } 548 | 549 | /** 550 | * Defines the data expiration time. 551 | * @returns Expiration timestamp. 552 | */ 553 | private expiresTimestamp(): number { 554 | return Date.now() + (30 * 1000 * 60 * 60 * 24); 555 | } 556 | } -------------------------------------------------------------------------------- /src/modules/database/models/BansModel.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | 3 | interface BansModel { 4 | guildId: string; 5 | reason: string; 6 | }; 7 | 8 | const schema = new Schema({ 9 | guildId: String, 10 | reason: String 11 | }); 12 | 13 | export default model("bans", schema); -------------------------------------------------------------------------------- /src/modules/database/models/ConfigModel.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | 3 | interface ConfigModel { 4 | enabled: boolean; 5 | channelId: string; 6 | guildId: string; 7 | webhook: string; 8 | textsLimit: number; 9 | 10 | collectPercentage: number; 11 | sendingPercentage: number; 12 | replyPercentage: number; 13 | }; 14 | 15 | const schema = new Schema({ 16 | enabled: { type: Boolean, default: true }, 17 | channelId: String, 18 | guildId: String, 19 | webhook: String, 20 | textsLimit: { type: Number, default: 500 }, 21 | 22 | collectPercentage: { type: Number, default: 0.35 }, 23 | sendingPercentage: { type: Number, default: 0.10 }, 24 | replyPercentage: { type: Number, default: 0.25 } 25 | }); 26 | 27 | export default model("configs", schema); -------------------------------------------------------------------------------- /src/modules/database/models/NoTrackModel.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | 3 | interface NoTrackModel { 4 | userId: string; 5 | }; 6 | 7 | const schema = new Schema({ 8 | userId: String 9 | }); 10 | 11 | export default model("notrack", schema); -------------------------------------------------------------------------------- /src/modules/database/models/TextsModel.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model } from "mongoose"; 2 | 3 | interface TextsModel { 4 | guildId: string; 5 | list: string[]; 6 | expiresAt: { type: Date, default: number, expires: Date } 7 | }; 8 | 9 | const schema = new Schema({ 10 | guildId: String, 11 | list: { type: [ String ], default: [] }, 12 | expiresAt: { type: Date, default: Date.now() + (30 * 1000 * 60 * 60 * 24) } 13 | }); 14 | 15 | export default model("texts", schema); -------------------------------------------------------------------------------- /src/modules/markov/MarkovChains.ts: -------------------------------------------------------------------------------- 1 | import "../../utils/deleteAt"; 2 | 3 | type WordList = Map; 7 | 8 | export default class MarkovChains { 9 | public wordList: WordList; 10 | 11 | /** 12 | * @param wordList A custom dictionary. 13 | */ 14 | constructor(wordList?: WordList) { 15 | this.wordList = wordList ?? new Map(); 16 | } 17 | 18 | /** 19 | * Maps the words to generate sentences. 20 | * @param texts An array of texts. 21 | */ 22 | generateDictionary(texts: string[]): void { 23 | this.wordList = new Map(); 24 | texts.forEach(text => this.pickWords(text)); 25 | } 26 | 27 | /** 28 | * Generates a sentence. 29 | * @param max Maximum words in the sentence. 30 | * @returns Generated sentence. 31 | */ 32 | generateChain(max: number): string { 33 | let wordArray = Array.from(this.wordList.keys()); 34 | if (wordArray.length < 1) return; 35 | 36 | let lastWord: string; 37 | let generatedWords: string[] = []; 38 | while (!lastWord) { 39 | lastWord = this.wordList.get(wordArray[Math.floor(Math.random() * wordArray.length)]).original; 40 | } 41 | 42 | generatedWords.push(lastWord); 43 | 44 | for (let i=0; i < max - 1; i++) { 45 | if (!lastWord) break; 46 | 47 | const nextWord = this.wordList.get(this.parseKey(lastWord)); 48 | if (!nextWord) break; 49 | lastWord = nextWord.list[Math.floor(Math.random() * nextWord.list.length)]; 50 | 51 | generatedWords.push(lastWord); 52 | } 53 | 54 | return this.filterGeneratedText(generatedWords.join(" ")); 55 | } 56 | 57 | /** 58 | * Extracts the words from the text and put them in the dictionary. 59 | * @param text Text to extract the words. 60 | */ 61 | private pickWords(text: string) { 62 | let splittedWords: string[] = text.split(/ +/g); 63 | splittedWords.forEach((word, i) => { 64 | let wordKey = this.parseKey(word); 65 | if (!wordKey) return; 66 | 67 | let nextWord = splittedWords[i + 1]; 68 | 69 | if (!this.wordList.get(wordKey)) { 70 | this.wordList.set(wordKey, { 71 | original: word, 72 | list: [] 73 | }); 74 | } 75 | 76 | if (nextWord) this.wordList.get(wordKey).list.push(nextWord); 77 | }); 78 | } 79 | 80 | /** 81 | * Filters the word to a key. 82 | * @param word The word to be filtered. 83 | * @returns Filtered word. 84 | */ 85 | private parseKey(word: string): string { 86 | // Only replace if there are any letters 87 | if (/\w/.test(word)) 88 | word = word.replace(/[<>()[\]{}:;\.,]/g, ""); 89 | 90 | return word; 91 | } 92 | 93 | /** 94 | * Filters the generated text by removing incomplete or nonsense punctuations. 95 | * @param text Text do be filtered. 96 | * @returns Filtered text. 97 | */ 98 | private filterGeneratedText(text: string): string { 99 | text = text.trim(); 100 | 101 | // Deletes unclosed parentheses, brackets and curly braces 102 | [["(", ")"], ["[", "]"], ["{", "}"]].forEach(v => { 103 | text = this.removeUnclosedPairs(text, v); 104 | }); 105 | 106 | // Deletes unclosed quotes and markdown 107 | ["\"", "'", "`", "*"].forEach(v => { 108 | text = this.removeUnclosedQuotes(text, v); 109 | }); 110 | 111 | // Deletes punctuations at beginning and end 112 | if (/\w/.test(text)) 113 | text = text.replace(/^[\.,; ]+/g, "").replace(/[, ]+$/g, ""); 114 | 115 | return text; 116 | } 117 | 118 | /** 119 | * Deletes unclosed quotes or markdown. 120 | * @param text Text to be filtered. 121 | * @param char Character to check. 122 | * @returns Filtered text. 123 | */ 124 | private removeUnclosedQuotes(text: string, char: string): string { 125 | let count = 0; 126 | let lastIndex; 127 | 128 | for (let i=0; i < text.length; i++) { 129 | if (text[i] == char) { 130 | lastIndex = i; 131 | 132 | count++; 133 | } 134 | } 135 | 136 | if (count % 2 != 0) text = text.deleteAt(lastIndex); 137 | 138 | return text; 139 | } 140 | 141 | /** 142 | * Deletes unclosed characters, such as parentheses or brackets. 143 | * @param text Text to be filtered. 144 | * @param pair Pair to check. 145 | * @returns Filtered text. 146 | */ 147 | private removeUnclosedPairs(text: string, pair: string[]): string { 148 | let count = 0; 149 | 150 | for (let i=0; i < text.length; i++) { 151 | if (text[i] == pair[0]) { 152 | count++; 153 | } else if (text[i] == pair[1]) { 154 | count--; 155 | } 156 | 157 | if (count < 0) { 158 | return this.removeUnclosedPairs(text.deleteAt(i), pair); 159 | } 160 | } 161 | 162 | if (count > 0) { 163 | for (let i=0; i < text.length; i++) { 164 | if (text[i] == pair[0]) { 165 | return this.removeUnclosedPairs(text.deleteAt(i), pair); 166 | } 167 | } 168 | } 169 | 170 | return text; 171 | } 172 | } -------------------------------------------------------------------------------- /src/modules/specialEvents/events/BaseSpecialEvent.ts: -------------------------------------------------------------------------------- 1 | export class BaseSpecialEvent { 2 | public id: string; 3 | public description: string; 4 | 5 | constructor(id: string, description: string) { 6 | this.id = id; 7 | this.description = description; 8 | } 9 | } -------------------------------------------------------------------------------- /src/modules/specialEvents/events/Time.ts: -------------------------------------------------------------------------------- 1 | import { BaseSpecialEvent } from "./BaseSpecialEvent"; 2 | 3 | import { Message } from "discord.js/typings"; 4 | import ClientInterface from "../../../interfaces/ClientInterface"; 5 | 6 | export class TimeSpecialEvent extends BaseSpecialEvent { 7 | private times = [ 8 | "00:00", 9 | "00:00:00", 10 | "01:23", 11 | "01:23:56", 12 | "03:33", 13 | "03:33:33", 14 | "04:20", 15 | "04:21", 16 | "05:55", 17 | "05:55:55", 18 | "11:11", 19 | "11:11:11", 20 | "12:34", 21 | "12:34:56", 22 | "13:37", 23 | "15:33", 24 | "16:20", 25 | "23:59", 26 | "23:59:59" 27 | ] 28 | 29 | constructor() { 30 | super( 31 | "time", 32 | "Send message at specific times." 33 | ); 34 | } 35 | 36 | async run(client: ClientInterface, message: Message): Promise { 37 | const date = new Date(); 38 | let hours = date.getHours(), 39 | minutes = date.getMinutes(), 40 | seconds = date.getSeconds(); 41 | 42 | if (hours < 10) "0" + hours; 43 | if (minutes < 10) "0" + minutes; 44 | if (seconds < 10) "0" + seconds; 45 | 46 | let hourMinutes = `${hours}:${minutes}`; 47 | let hourMinutesSeconds = `${hourMinutes}:${seconds}`; 48 | 49 | let time; 50 | if (this.times.includes(hourMinutesSeconds)) time = hourMinutesSeconds 51 | else if (this.times.includes(hourMinutes) || (hourMinutes.replace(":", "") == date.getFullYear() + "")) time = hourMinutes; 52 | 53 | if (time) { 54 | client.cooldown.set(message.guildId, Date.now() + 60000); 55 | 56 | await message.channel.sendTyping(); 57 | 58 | return message.channel.send(time); 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/modules/specialEvents/index.ts: -------------------------------------------------------------------------------- 1 | import { TimeSpecialEvent } from "./events/Time"; 2 | 3 | const specialEventList = { 4 | TimeSpecialEvent 5 | } 6 | 7 | export default specialEventList; -------------------------------------------------------------------------------- /src/structures/Command.ts: -------------------------------------------------------------------------------- 1 | import { t } from "i18next"; 2 | 3 | import ClientInterface from "../interfaces/ClientInterface"; 4 | import CommandInterface from "../interfaces/CommandInterface"; 5 | 6 | export default class Command implements CommandInterface { 7 | public t: typeof t; 8 | public client: ClientInterface; 9 | public dev: boolean = false; 10 | public skipBan: boolean = false; 11 | public allowedDm: boolean = false; 12 | public permissions: CommandInterface["permissions"]; 13 | 14 | public name: string; 15 | public description: string; 16 | public nameLocalizations: Record = {}; 17 | public descriptionLocalizations: Record = {}; 18 | public options: CommandInterface["options"]; 19 | 20 | constructor(client: ClientInterface, name: string, description: string, options?: CommandInterface["options"]) { 21 | this.t = client.i18n.t; 22 | this.client = client; 23 | this.name = name; 24 | this.description = description; 25 | 26 | if (options) { 27 | this.options = options; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/structures/Event.ts: -------------------------------------------------------------------------------- 1 | import { WSEventType } from "discord.js/typings"; 2 | import ClientInterface from "../interfaces/ClientInterface"; 3 | 4 | export default class Event { 5 | public ws: boolean = false; 6 | public identifier: string | WSEventType; 7 | 8 | constructor(identifier: string | WSEventType) { 9 | this.identifier = identifier; 10 | } 11 | 12 | run?(client: ClientInterface, ...args: any[]): any 13 | } -------------------------------------------------------------------------------- /src/structures/SubCommand.ts: -------------------------------------------------------------------------------- 1 | import Command from "./Command"; 2 | 3 | import ClientInterface from "../interfaces/ClientInterface"; 4 | import SubCommandInterface from "../interfaces/SubCommandInterface"; 5 | 6 | export default class SubCommand extends Command { 7 | public type: SubCommandInterface["type"] = "SUB_COMMAND"; 8 | 9 | constructor(client: ClientInterface, name: string, description: string, options?: SubCommandInterface["options"]) { 10 | super(client, name, description, options); 11 | } 12 | } -------------------------------------------------------------------------------- /src/structures/SubCommandGroup.ts: -------------------------------------------------------------------------------- 1 | import Command from "./Command"; 2 | 3 | import ClientInterface from "../interfaces/ClientInterface"; 4 | import SubCommandGroupInterface from "../interfaces/SubCommandGroupInterface"; 5 | 6 | export default class SubCommandGroup extends Command { 7 | public type: SubCommandGroupInterface["type"] = "SUB_COMMAND_GROUP"; 8 | 9 | constructor(client: ClientInterface, name: string, description: string, options?: SubCommandGroupInterface["options"]) { 10 | super(client, name, description, options); 11 | } 12 | } -------------------------------------------------------------------------------- /src/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | /** Bot token. */ 5 | BOT_TOKEN: string; 6 | /** MongoDB URI. */ 7 | DB_URI: string; 8 | /** Random secret hash used to encrypt and decrypt data. */ 9 | CRYPTO_SECRET: string; 10 | /** Test bot token. */ 11 | TEST_BOT_TOKEN?: string; 12 | /** Discord webhook for logs (guild join/leave, shard status...) */ 13 | SERVER_LOG?: string; 14 | } 15 | } 16 | } 17 | 18 | export {}; -------------------------------------------------------------------------------- /src/utils/deleteAt.ts: -------------------------------------------------------------------------------- 1 | interface String { 2 | deleteAt(index: number): string 3 | } 4 | 5 | String.prototype.deleteAt = function(index) { 6 | return this.substring(0, index) + this.substring(index + 1); 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "CommonJS", 5 | "moduleResolution": "Node", 6 | "removeComments": true, 7 | "noImplicitAny": false, 8 | "sourceMap": false, 9 | "outDir": "dist", 10 | "types": ["@types/node"], 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true 13 | }, 14 | 15 | "include": [ 16 | "src/**/*.ts" 17 | ], 18 | "exclude": ["node_modules"] 19 | } --------------------------------------------------------------------------------