├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── CI.yaml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── .vscode └── botcmd.code-snippets ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── __tests__ └── utils.test.ts ├── compose-dev.yaml ├── docker-compose.yml ├── docs └── Boolean Pfp.png ├── jest.config.js ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20220531024427_init │ │ └── migration.sql │ ├── 20220531024708_modmail │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── src ├── commands │ ├── announce.ts │ ├── clear.ts │ ├── config.ts │ ├── deny.ts │ ├── members.ts │ ├── ping.ts │ ├── quiz.ts │ ├── repeat.ts │ ├── rolemenu.ts │ ├── selfrole.ts │ ├── user.ts │ └── warn.ts ├── database │ ├── badges.ts │ ├── channels.ts │ ├── index.ts │ ├── roles.ts │ └── selfroles.ts ├── events │ ├── error.ts │ ├── guildMemberAdd.ts │ ├── guildMemberRemove.ts │ ├── guildMemberUpdate.ts │ ├── interactionCreate.ts │ ├── messageCreate.ts │ ├── messageDelete.ts │ ├── messageUpdate.ts │ └── ready.ts ├── files.ts ├── index.ts ├── services │ └── modmail │ │ ├── commands │ │ ├── DeleteCtxMenu.ts │ │ ├── EditCtxMenu.ts │ │ └── ModmailCmd.ts │ │ ├── constants.ts │ │ ├── database.ts │ │ ├── index.ts │ │ ├── sync.ts │ │ └── util.ts ├── structures │ ├── Bot.ts │ ├── BotCommand.ts │ ├── Logger.ts │ └── index.ts ├── types.ts └── utils.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .husky 3 | __tests__ 4 | node_modules 5 | dist 6 | .editorconfig 7 | .env.example 8 | .eslintignore 9 | .eslintrc.json 10 | .gitignore 11 | .prettierignore 12 | .prettierrc.json 13 | CONTRIBUTING.md 14 | jest.config.ts 15 | README.md 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | indent_style = space 5 | indent_size = 4 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TOKEN=your bot token 2 | LOG_LEVEL=info 3 | 4 | # Uncomment and populate to use. 5 | #DEV_SERVER= 6 | 7 | # Docker users: keep DATABASE_URL the same. 8 | DATABASE_URL="postgresql://postgres:supersecurepassword@postgres/postgres?schema=boolean" 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "airbnb-base", 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": "latest" 17 | }, 18 | "plugins": ["@typescript-eslint"], 19 | "rules": { 20 | "quotes": ["error", "double"], 21 | "semi": ["error", "always"], 22 | "camelcase": "warn", 23 | "import/no-unresolved": "off", 24 | "import/extensions": "off", 25 | "class-methods-use-this": "off", 26 | "no-console": "off" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/CI.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | with: 10 | node-version: "16" 11 | - run: npm install 12 | - run: npm run lint 13 | - run: npm run build 14 | - run: npm run test 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/* 2 | !.vscode/settings.json 3 | !.vscode/tasks.json 4 | !.vscode/launch.json 5 | !.vscode/extensions.json 6 | .history/ 7 | *.vsix 8 | .history 9 | .ionide 10 | .vscode/*.code-snippets 11 | *.code-workspace 12 | src/configs/config.ts 13 | /node_modules 14 | /dist 15 | .env 16 | pnpm-lock.yaml 17 | yarn.lock 18 | .vscode 19 | .idea 20 | .DS_Store 21 | 22 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *-lock.json 3 | dist -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "importOrder": ["^[./]"], 4 | "importOrderSeparation": true, 5 | "importOrderSortSpecifiers": true, 6 | "semi": true, 7 | "singleQuote": false 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/botcmd.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | // Place your boolean workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 7 | // Placeholders with the same ids are connected. 8 | // Example: 9 | // "Print to console": { 10 | // "scope": "javascript,typescript", 11 | // "prefix": "log", 12 | // "body": [ 13 | // "console.log('$1');", 14 | // "$2" 15 | // ], 16 | // "description": "Log output to console" 17 | // } 18 | "botcmd": { 19 | "scope": "typescript", 20 | "prefix": "botcmd", 21 | "body": [ 22 | "export default class $1 extends BotCommand {", 23 | "\tconstructor() {", 24 | "\t\tsuper(", 25 | "\t\t\t\"$2\",", 26 | "\t\t\t\"$3.\",", 27 | "\t\t\tnew SlashCommandBuilder()", 28 | "\t\t\t\t.setName(\"$2\")", 29 | "\t\t\t\t.setDescription(\"$3.\")", 30 | "\t\t\t\t.toJSON(),", 31 | "\t\t\t{},", 32 | "\t\t);", 33 | "\t}", 34 | "\tpublic async execute(", 35 | "\t\tinteraction: CommandInteraction<\"cached\">,", 36 | "\t\tclient: Bot,", 37 | "\t): Promise {", 38 | "\t}", 39 | "}", 40 | ], 41 | "description": "Create a bot command." 42 | } 43 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for taking the time to read the contributing guide, your efforts are greatly appreciated! Please understand that there is a chance your code could be overwritten in the future - that is the nature of open source! 4 | 5 | ## Guidelines 6 | 7 | In order to contribute your code, please follow the guidelines listed. Failure to do so may result in your pull request being closed. 8 | 9 | ### Safe Code 10 | 11 | This is obvious, please avoid unsafe code and exploits :) 12 | 13 | ### Libraries 14 | 15 | Where possible, please limit the use of libraries. Please avoid libraries that are deprecated, not in development or have low weekly downloads. 16 | 17 | ### Conventions 18 | 19 | Please follow the coding style and conventions used in the code base, to keep code as clean as possible. 20 | 21 | These mainly include: 22 | 23 | - Camel case variables 24 | - Use of async & await 25 | - Spacing between operators 26 | - Use of ES Modules (where possible) 27 | 28 | ### Issues 29 | 30 | Contributions must have corresponding issues. If the issue does not yet exist, please make a new one before contributing - also ensure that you mention that you are claiming the issue, so nobody works on the same issue. **If you are unsure whether your changes will be wanted or not, please check with conaticus**. 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:17-alpine AS base 2 | 3 | WORKDIR /opt/app 4 | COPY package*.json . 5 | RUN npm install 6 | COPY . . 7 | 8 | FROM base AS builder 9 | 10 | RUN npm exec prisma generate 11 | RUN npm run build 12 | 13 | FROM builder as development 14 | 15 | RUN apk add git 16 | CMD npm run dev 17 | 18 | FROM builder as production 19 | 20 | CMD npm run start 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Boolean

2 | 3 | ![Boolean's Picture](./docs/Boolean%20Pfp.png) 4 | 5 | ## About 6 | Boolean is a Discord bot for the Conaticus' [Discord server](https://discord.gg/conaticus). Boolean contains a collection of useful modules for your server, like a mod mailing system, a custom role menu, etc. Want to see how Boolean was made? Watch the development process [here](https://www.youtube.com/watch?v=xq2jR3_msmk) 7 | 8 | ## Setup and configuration guide 9 | 10 | ### Requirements 11 | ``` 12 | - Postgres Server 13 | - Node v16x or above 14 | ``` 15 | 16 | ### Installation with Docker 17 | If you don't have a server, you can use Docker to host the bot. You can simply use the `docker-compose up` command to start the bot. 18 | 19 | If you need to update the bot, you can use the `docker-compose down` command to stop the bot, and then use the `docker-compose up --build` command to start it again or make sure to pull down latest changes from git using `git pull origin master` or use the built-in script `scripts/update.sh`. 20 | 21 | ### Installation 22 | If you have all the requirements for hosting the bot, please follow the next step carefully! 23 | 24 | 1. Clone this repository to your computer, or to your vps. 25 | ``` 26 | git clone https://github.com/conaticusgrp/boolean 27 | ``` 28 | 29 | 2. Copy the contents of the `.env.example` file to the `.env` file and fill in the values. (Make sure you have declared the bots token in the `.env` file!) 30 | 3. Create a new database with your MYSQL server, and make sure you have the correct credentials in the `.env` file. 31 | 4. Run the following command to install the dependencies: 32 | ``` 33 | npm install 34 | ``` 35 | 5. Run the following command to start the bot: 36 | ``` 37 | npm run dev 38 | ``` 39 | 40 | ### Configuration 41 | 42 | Configuration can be performed via the `/config` command to set special roles 43 | and channels that the bot identifies and utilizes. 44 | 45 | ## Logging 46 | 47 | ### Console levels and their references 48 | 49 | - Fatal : `logger.console.fatal("")` 50 | - Error : `logger.console.error("")` 51 | - Warn : `logger.console.warn("")` 52 | - Info : `logger.console.info("")` 53 | - Debug : `logger.console.debug("")` 54 | - Trace : `logger.console.trace("")` 55 | - Silent : `logger.console.silent("")` 56 | 57 | #### Channel logging 58 | 59 | -Embed : `logger.channel(, )` 60 | 61 | ## Contributing 62 | 63 | Look at the [CONTRIBUTING](https://github.com/conaticus/boolean/blob/master/CONTRIBUTING.md) to find out how you can help contribute to the development of this bot. 64 | 65 | ## Support 66 | 67 | If you have any questions, please join the [Discord](https://discord.gg/conaticus) or open an issue here on GitHub. 68 | 69 | ## Youtube 70 | 71 | If you like cool coding projects like this, subscribe to [Conaticus](https://www.youtube.com/channel/UCRLHJ-7b4pjDpBBHAUXEvjQ) 72 | -------------------------------------------------------------------------------- /__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { Collection, MessageAttachment } from "discord.js"; 2 | import { formatAttachmentsURL } from "../src/utils"; 3 | 4 | it("should format attachment url", () => { 5 | const attachmentWithUrl = (url: string): MessageAttachment => { 6 | const mockMessageAtatchment = new MessageAttachment(Buffer.alloc(0)); 7 | mockMessageAtatchment.url = url; 8 | return mockMessageAtatchment; 9 | }; 10 | 11 | const mockDataCollection = new Collection(); 12 | 13 | mockDataCollection.set( 14 | "1", 15 | attachmentWithUrl("https://example.com/file.png") 16 | ); 17 | 18 | expect(formatAttachmentsURL(mockDataCollection)).toBe( 19 | "[`Attachment-0-File`](https://example.com/file.png)" 20 | ); 21 | 22 | mockDataCollection.set( 23 | "2", 24 | attachmentWithUrl("https://example.com/file2.png") 25 | ); 26 | 27 | expect(formatAttachmentsURL(mockDataCollection)).toBe( 28 | "[`Attachment-0-File`](https://example.com/file.png)\n[`Attachment-1-File`](https://example.com/file2.png)" 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /compose-dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:14.2-alpine 4 | boolean: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | target: development 9 | restart: unless-stopped 10 | depends_on: 11 | - postgres 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:14.2-alpine 4 | restart: unless-stopped 5 | environment: 6 | - POSTGRES_PASSWORD=supersecurepassword 7 | - PGDATA=/var/lib/postgresql/data/pgdata 8 | volumes: 9 | - "boolean-data:/var/lib/postgresql/data/pgdata" 10 | boolean: 11 | build: . 12 | environment: 13 | - DATABASE_URL=postgresql://postgres:supersecurepassword@postgres/postgres?schema=boolean 14 | env_file: 15 | - .env 16 | restart: unless-stopped 17 | depends_on: 18 | - postgres 19 | volumes: 20 | boolean-data: {} 21 | -------------------------------------------------------------------------------- /docs/Boolean Pfp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conaticusgrp/boolean-old/da5a8b4ea4eed1a24db8d179aa7ab3aca69c6fea/docs/Boolean Pfp.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | module.exports = { 7 | preset: "ts-jest", 8 | clearMocks: true, 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "conaticus-bot", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "./dist/index.js", 6 | "keywords": [], 7 | "author": "conaticus", 8 | "license": "MIT", 9 | "scripts": { 10 | "dev": "ts-node-dev --respawn ./src/index.ts", 11 | "build": "tsc", 12 | "start": "NODE_ENV=production node .", 13 | "check": "tsc -w --noEmit", 14 | "format": "prettier --write ./src", 15 | "prepare": "husky install", 16 | "test": "jest", 17 | "lint": "eslint ./src --ext .ts" 18 | }, 19 | "dependencies": { 20 | "@discordjs/builders": "^0.13.0", 21 | "@discordjs/rest": "^0.4.1", 22 | "@prisma/client": "^3.14.0", 23 | "discord-api-types": "^0.31.1", 24 | "discord.js": "^13.7.0", 25 | "pg": "^8.7.3", 26 | "pino": "^7.11.0", 27 | "pino-pretty": "^7.6.1", 28 | "uuid": "^8.3.2" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^27.5.1", 32 | "@types/node": "^17.0.35", 33 | "@types/uuid": "^8.3.4", 34 | "@typescript-eslint/eslint-plugin": "^5.25.0", 35 | "@typescript-eslint/parser": "^5.25.0", 36 | "dotenv": "^16.0.1", 37 | "eslint": "^8.15.0", 38 | "eslint-config-airbnb-base": "^15.0.0", 39 | "eslint-config-prettier": "^8.5.0", 40 | "eslint-plugin-import": "^2.26.0", 41 | "husky": "^8.0.1", 42 | "jest": "^28.1.0", 43 | "prettier": "^2.6.2", 44 | "prisma": "^3.14.0", 45 | "ts-jest": "^28.0.2", 46 | "ts-node": "^10.9.1", 47 | "ts-node-dev": "^2.0.0", 48 | "typescript": "^4.7.4" 49 | }, 50 | "engines": { 51 | "node": ">=16.6.0" 52 | }, 53 | "engineStrict": true 54 | } 55 | -------------------------------------------------------------------------------- /prisma/migrations/20220531024427_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "SelfRoleList" ( 3 | "guildId" TEXT NOT NULL, 4 | "title" TEXT NOT NULL, 5 | "id" TEXT NOT NULL, 6 | 7 | CONSTRAINT "SelfRoleList_pkey" PRIMARY KEY ("id") 8 | ); 9 | 10 | -- CreateTable 11 | CREATE TABLE "RoleChoice" ( 12 | "roleId" TEXT NOT NULL, 13 | "listId" TEXT NOT NULL, 14 | 15 | CONSTRAINT "RoleChoice_pkey" PRIMARY KEY ("roleId") 16 | ); 17 | 18 | -- CreateTable 19 | CREATE TABLE "Badge" ( 20 | "guildId" TEXT NOT NULL, 21 | "badgeName" TEXT NOT NULL, 22 | "emoji" TEXT NOT NULL, 23 | "id" TEXT NOT NULL, 24 | 25 | CONSTRAINT "Badge_pkey" PRIMARY KEY ("id") 26 | ); 27 | 28 | -- CreateTable 29 | CREATE TABLE "SpecialChannel" ( 30 | "guildId" TEXT NOT NULL, 31 | "label" TEXT NOT NULL, 32 | "channelId" TEXT NOT NULL, 33 | "id" TEXT NOT NULL, 34 | 35 | CONSTRAINT "SpecialChannel_pkey" PRIMARY KEY ("id") 36 | ); 37 | 38 | -- CreateTable 39 | CREATE TABLE "SpecialRole" ( 40 | "guildId" TEXT NOT NULL, 41 | "label" TEXT NOT NULL, 42 | "roleId" TEXT NOT NULL, 43 | "id" TEXT NOT NULL, 44 | 45 | CONSTRAINT "SpecialRole_pkey" PRIMARY KEY ("id") 46 | ); 47 | 48 | -- AddForeignKey 49 | ALTER TABLE "RoleChoice" ADD CONSTRAINT "RoleChoice_listId_fkey" FOREIGN KEY ("listId") REFERENCES "SelfRoleList"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 50 | -------------------------------------------------------------------------------- /prisma/migrations/20220531024708_modmail/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "ModmailMessage" ( 3 | "guildId" TEXT NOT NULL, 4 | "channelId" TEXT, 5 | "content" TEXT NOT NULL, 6 | "modmailId" TEXT NOT NULL, 7 | "senderId" TEXT NOT NULL, 8 | "deleted" BOOLEAN NOT NULL DEFAULT false, 9 | "memberCopyId" TEXT NOT NULL, 10 | "staffCopyId" TEXT NOT NULL, 11 | "id" TEXT NOT NULL, 12 | 13 | CONSTRAINT "ModmailMessage_pkey" PRIMARY KEY ("id") 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "ModmailAttachment" ( 18 | "messageId" TEXT NOT NULL, 19 | "url" TEXT NOT NULL, 20 | "name" TEXT NOT NULL, 21 | "id" TEXT NOT NULL, 22 | 23 | CONSTRAINT "ModmailAttachment_pkey" PRIMARY KEY ("id") 24 | ); 25 | 26 | -- CreateTable 27 | CREATE TABLE "ModmailEdit" ( 28 | "messageId" TEXT NOT NULL, 29 | "content" TEXT NOT NULL, 30 | "iteration" INTEGER NOT NULL, 31 | "id" TEXT NOT NULL, 32 | 33 | CONSTRAINT "ModmailEdit_pkey" PRIMARY KEY ("id") 34 | ); 35 | 36 | -- CreateTable 37 | CREATE TABLE "Modmail" ( 38 | "guildId" TEXT NOT NULL, 39 | "channelId" TEXT NOT NULL, 40 | "memberId" TEXT NOT NULL, 41 | "authorId" TEXT NOT NULL, 42 | "closed" BOOLEAN NOT NULL DEFAULT false, 43 | "id" TEXT NOT NULL, 44 | 45 | CONSTRAINT "Modmail_pkey" PRIMARY KEY ("id") 46 | ); 47 | 48 | -- AddForeignKey 49 | ALTER TABLE "ModmailMessage" ADD CONSTRAINT "ModmailMessage_modmailId_fkey" FOREIGN KEY ("modmailId") REFERENCES "Modmail"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 50 | 51 | -- AddForeignKey 52 | ALTER TABLE "ModmailAttachment" ADD CONSTRAINT "ModmailAttachment_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "ModmailMessage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 53 | 54 | -- AddForeignKey 55 | ALTER TABLE "ModmailEdit" ADD CONSTRAINT "ModmailEdit_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "ModmailMessage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 56 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // Checkout Prisma's documentation 2 | // https://www.prisma.io/docs/getting-started 3 | generator client { 4 | provider = "prisma-client-js" 5 | } 6 | 7 | datasource db { 8 | provider = "postgresql" 9 | url = env("DATABASE_URL") 10 | } 11 | 12 | model SelfRoleList { 13 | guildId String 14 | title String 15 | id String @id @default(uuid()) 16 | choices RoleChoice[] 17 | } 18 | 19 | model RoleChoice { 20 | roleId String @id 21 | ReactionMessage SelfRoleList? @relation(fields: [listId], references: [id]) 22 | listId String 23 | } 24 | 25 | model Badge { 26 | guildId String 27 | badgeName String 28 | emoji String 29 | id String @id @default(uuid()) 30 | } 31 | 32 | model SpecialChannel { 33 | guildId String 34 | label String 35 | channelId String 36 | id String @id @default(uuid()) 37 | } 38 | 39 | model SpecialRole { 40 | guildId String 41 | label String 42 | roleId String 43 | id String @id @default(uuid()) 44 | } 45 | 46 | model ModmailMessage { 47 | guildId String 48 | channelId String? 49 | content String 50 | modmail Modmail @relation(fields: [modmailId], references: [id]) 51 | modmailId String 52 | senderId String 53 | deleted Boolean @default(false) 54 | // This is the member's copy of the message 55 | memberCopyId String 56 | // This is the staff's copy of the message 57 | staffCopyId String 58 | // Database copy 59 | id String @id @default(uuid()) 60 | edits ModmailEdit[] 61 | attachments ModmailAttachment[] 62 | } 63 | 64 | model ModmailAttachment { 65 | message ModmailMessage @relation(fields: [messageId], references: [id]) 66 | messageId String 67 | url String 68 | name String 69 | id String @id @default(uuid()) 70 | } 71 | 72 | model ModmailEdit { 73 | message ModmailMessage @relation(fields: [messageId], references: [id]) 74 | messageId String 75 | content String 76 | iteration Int 77 | id String @id @default(uuid()) 78 | } 79 | 80 | model Modmail { 81 | guildId String 82 | channelId String 83 | // The community member involved. 84 | memberId String 85 | // The user that created the modmail 86 | authorId String 87 | messages ModmailMessage[] 88 | closed Boolean @default(false) 89 | id String @id @default(uuid()) 90 | } 91 | -------------------------------------------------------------------------------- /src/commands/announce.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { 3 | ModalSubmitInteraction, 4 | CommandInteraction, 5 | MessageEmbed, 6 | TextChannel, 7 | } from "discord.js"; 8 | 9 | import { getSpecialChannel, getSpecialRole } from "../database"; 10 | import { BotCommand } from "../structures"; 11 | 12 | class Announce extends BotCommand { 13 | constructor() { 14 | super( 15 | new SlashCommandBuilder() 16 | .setName("announce") 17 | .setDescription("Write an announcement for the server.") 18 | .toJSON(), 19 | { timeout: 6000, requiredPerms: ["ADMINISTRATOR"] } 20 | ); 21 | } 22 | 23 | public async execute(interaction: CommandInteraction): Promise { 24 | if (interaction.guildId === null) { 25 | throw new Error("This belongs in a server."); 26 | } 27 | const optAnnounce = await getSpecialChannel( 28 | interaction.guildId, 29 | "announcements" 30 | ); 31 | if (optAnnounce === null) { 32 | throw new Error("There is not an announcements channel yet."); 33 | } 34 | const announcementsChannel = optAnnounce as TextChannel; 35 | const announcementRole = await getSpecialRole( 36 | interaction.guildId, 37 | "announcements" 38 | ); 39 | if (announcementRole === null) { 40 | throw new Error("There is not an announcements role yet."); 41 | } 42 | 43 | await interaction.showModal({ 44 | customId: `announcement_${interaction.id}`, 45 | title: "Make an announcement", 46 | components: [ 47 | { 48 | type: "ACTION_ROW", 49 | components: [ 50 | { 51 | type: "TEXT_INPUT", 52 | customId: "title", 53 | label: "Title", 54 | style: "SHORT", 55 | required: true, 56 | maxLength: 256, 57 | }, 58 | ], 59 | }, 60 | { 61 | type: "ACTION_ROW", 62 | components: [ 63 | { 64 | type: "TEXT_INPUT", 65 | customId: "content", 66 | label: "Message", 67 | style: "PARAGRAPH", 68 | required: true, 69 | }, 70 | ], 71 | }, 72 | ], 73 | }); 74 | const modalInteraction = await interaction 75 | .awaitModalSubmit({ 76 | filter: (i: ModalSubmitInteraction) => 77 | i.user.id === interaction.user.id && 78 | i.customId === `announcement_${interaction.id}`, 79 | time: 600_000, 80 | }) 81 | .catch(() => null); 82 | 83 | if (!modalInteraction) { 84 | const errorEmbed = new MessageEmbed() 85 | .setColor("RED") 86 | .setDescription("Announcement cancelled."); 87 | await interaction.followUp({ embeds: [errorEmbed] }); 88 | return; 89 | } 90 | 91 | const announcementEmbed = new MessageEmbed() 92 | .setColor("ORANGE") 93 | .setTitle(modalInteraction.fields.getTextInputValue("title")) 94 | .setDescription( 95 | modalInteraction.fields.getTextInputValue("content") 96 | ); 97 | 98 | const successEmbed = new MessageEmbed() 99 | .setColor("GREEN") 100 | .setDescription( 101 | `Successfully created announcement in ${announcementsChannel}` 102 | ); 103 | 104 | await modalInteraction.reply({ 105 | embeds: [successEmbed], 106 | }); 107 | 108 | await announcementsChannel.send({ 109 | content: announcementRole.toString(), 110 | embeds: [announcementEmbed], 111 | }); 112 | } 113 | } 114 | 115 | export default new Announce(); 116 | -------------------------------------------------------------------------------- /src/commands/clear.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { 3 | CommandInteraction, 4 | Message, 5 | MessageEmbed, 6 | PartialMessage, 7 | } from "discord.js"; 8 | 9 | import { Bot, BotCommand } from "../structures"; 10 | import { handleAssets, newEmbed } from "../utils"; 11 | 12 | class Clear extends BotCommand { 13 | constructor() { 14 | super( 15 | new SlashCommandBuilder() 16 | .setName("clear") 17 | .setDescription("Delete specified amount of messages.") 18 | .addNumberOption((option) => 19 | option 20 | .setName("amount") 21 | .setDescription("Amount of messages to delete") 22 | .setMinValue(2) 23 | .setRequired(true) 24 | ) 25 | .toJSON(), 26 | { requiredPerms: ["MANAGE_MESSAGES"] } 27 | ); 28 | } 29 | 30 | public async execute( 31 | interaction: CommandInteraction<"cached">, 32 | client: Bot 33 | ): Promise { 34 | await interaction.deferReply({ ephemeral: true }); 35 | if (interaction.channel === null) { 36 | throw new Error("How did we get here?"); 37 | } 38 | const deleted = await interaction.channel.bulkDelete( 39 | interaction.options.getNumber("amount", true), 40 | true 41 | ); 42 | 43 | // Respond to the user 44 | const successEmbed = new MessageEmbed() 45 | .setColor("GREEN") 46 | .setDescription(`Deleted \`${deleted.size}\` messages.`); 47 | await interaction.editReply({ embeds: [successEmbed] }); 48 | 49 | // Log the cleared messages 50 | const filter = (del: Message | PartialMessage | undefined) => { 51 | return del !== undefined && del.author !== null; 52 | }; 53 | const embeds = deleted.map((message) => { 54 | if (!filter(message)) { 55 | return; 56 | } 57 | const msg = message as Message; 58 | const embed = newEmbed(msg).addFields([ 59 | { name: "\u200B", value: "\u200B", inline: true }, 60 | { 61 | name: "Executor", 62 | value: interaction.user.toString(), 63 | inline: true, 64 | }, 65 | { 66 | name: "Sent at", 67 | value: ``, 68 | inline: true, 69 | }, 70 | { name: "\u200B", value: "\u200B", inline: true }, 71 | ]); 72 | // Add stickers 73 | handleAssets(msg, embed); 74 | return embed; 75 | }); 76 | const tasks = []; 77 | for (let i = 0; i < embeds.length; i += 1) { 78 | const embed = embeds[i]; 79 | if (embed !== undefined) { 80 | const task = client.logger.channel(interaction.guildId, embed); 81 | tasks.push(task); 82 | } 83 | } 84 | await Promise.all(tasks); 85 | } 86 | } 87 | 88 | export default new Clear(); 89 | -------------------------------------------------------------------------------- /src/commands/config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | APIApplicationCommandOptionChoice, 3 | ChannelType, 4 | } from "discord-api-types/v10"; 5 | import { SlashCommandBuilder } from "@discordjs/builders"; 6 | import { CommandInteraction } from "discord.js"; 7 | 8 | import { 9 | Badges, 10 | DEFAULT_BADGES, 11 | setBadge, 12 | setSpecialChannel, 13 | setSpecialRole, 14 | SpecialChannel, 15 | SpecialRole, 16 | } from "../database"; 17 | import { BotCommand } from "../structures"; 18 | 19 | const badges: APIApplicationCommandOptionChoice[] = Object.keys( 20 | DEFAULT_BADGES 21 | ).map((v) => ({ 22 | name: v, 23 | value: v, 24 | })); 25 | const specRoles: APIApplicationCommandOptionChoice[] = [ 26 | "announcements", 27 | "members", 28 | ].map((v) => ({ 29 | name: v, 30 | value: v, 31 | })); 32 | const specChannels: APIApplicationCommandOptionChoice[] = [ 33 | "announcements", 34 | "information", 35 | "suggestions", 36 | "welcomes", 37 | "warnings", 38 | "logs", 39 | "roles", 40 | "appeals", 41 | "modmail", 42 | "help", 43 | ].map((v) => ({ 44 | name: v, 45 | value: v, 46 | })); 47 | 48 | class Config extends BotCommand { 49 | constructor() { 50 | super( 51 | new SlashCommandBuilder() 52 | .setName("config") 53 | .setDescription("Configure the bot.") 54 | .addSubcommand((sub) => 55 | sub 56 | .setName("setchannel") 57 | .setDescription("Set a special channel.") 58 | .addStringOption((opt) => 59 | opt 60 | .setName("label") 61 | .setDescription("The special channel name.") 62 | .addChoices(...specChannels) 63 | .setRequired(true) 64 | ) 65 | .addChannelOption((opt) => 66 | opt 67 | .setName("channel") 68 | .setDescription("The channel to associate.") 69 | .addChannelTypes(ChannelType.GuildText) 70 | .setRequired(true) 71 | ) 72 | ) 73 | .addSubcommand((sub) => 74 | sub 75 | .setName("setbadge") 76 | .setDescription("Set a badge.") 77 | .addStringOption((opt) => 78 | opt 79 | .setName("label") 80 | .setDescription("The badge to set.") 81 | .addChoices(...badges) 82 | .setRequired(true) 83 | ) 84 | .addStringOption((opt) => 85 | opt 86 | .setName("emoji") 87 | .setDescription("The emoji to associate.") 88 | .setRequired(true) 89 | ) 90 | ) 91 | .addSubcommand((sub) => 92 | sub 93 | .setName("setrole") 94 | .setDescription("Set a special role.") 95 | .addStringOption((opt) => 96 | opt 97 | .setName("label") 98 | .setDescription("The special role name.") 99 | .addChoices(...specRoles) 100 | .setRequired(true) 101 | ) 102 | .addRoleOption((opt) => 103 | opt 104 | .setName("role") 105 | .setDescription("The role to associate.") 106 | .setRequired(true) 107 | ) 108 | ) 109 | .toJSON(), 110 | { requiredPerms: ["ADMINISTRATOR"] } 111 | ); 112 | } 113 | 114 | private static async setChannel( 115 | guildId: string, 116 | inter: CommandInteraction 117 | ) { 118 | const label = inter.options.getString("label", true); 119 | const channel = inter.options.getChannel("channel", true); 120 | await setSpecialChannel(guildId, label as SpecialChannel, channel.id); 121 | } 122 | 123 | private static async setBadge(guildId: string, inter: CommandInteraction) { 124 | const label = inter.options.getString("label", true); 125 | const emoji = inter.options.getString("emoji", true); 126 | await setBadge(guildId, label as Badges, emoji); 127 | } 128 | 129 | private static async setRole(guildId: string, inter: CommandInteraction) { 130 | const label = inter.options.getString("label", true); 131 | const role = inter.options.getRole("role", true); 132 | await setSpecialRole(guildId, label as SpecialRole, role.id); 133 | } 134 | 135 | public async execute(interaction: CommandInteraction): Promise { 136 | const subCommand = interaction.options.getSubcommand(); 137 | const { guildId } = interaction; 138 | if (guildId === null) { 139 | await interaction.reply("This command belongs in a server."); 140 | return; 141 | } 142 | switch (subCommand) { 143 | case "setchannel": 144 | await Config.setChannel(guildId, interaction); 145 | break; 146 | case "setbadge": 147 | await Config.setBadge(guildId, interaction); 148 | break; 149 | case "setrole": 150 | await Config.setRole(guildId, interaction); 151 | break; 152 | default: 153 | await interaction.reply("How did we get here?"); 154 | return; 155 | } 156 | await interaction.reply("Done."); 157 | } 158 | } 159 | 160 | export default new Config(); 161 | -------------------------------------------------------------------------------- /src/commands/deny.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { CommandInteraction, MessageEmbed } from "discord.js"; 3 | 4 | import { BotCommand } from "../structures"; 5 | 6 | class Deny extends BotCommand { 7 | private static tooYoung = 8 | "Hello %s, thank you for applying to be a" + 9 | " moderator in the conaticus server. Unfortunately, your application has" + 10 | " been denied because you do not meet the age requirement of 15."; 11 | 12 | private static tooNew = 13 | "Hello thank you for applying to be a moderator" + 14 | " in the conaticus server. Unfortunately, your application has been" + 15 | " denied because you do not meet the requirement of being in the" + 16 | " server for at least one week.\nYou may reapply in a month."; 17 | 18 | private static other = 19 | "Hello %s unfortunately your moderation" + 20 | " application has been denied - but don't worry - this is nothing personal!" + 21 | "\n\nThank you for applying, and there is always the opportunity to" + 22 | " try again in a month." + 23 | "\n\nThe reason your application was not accepted cannot be" + 24 | " disclosed, however here are some common reasons:" + 25 | "\n\nAnswers that are too short/vague" + 26 | "\nLack of moderator experience" + 27 | "\nInappropiate scenario action" + 28 | "\nCan't be as active as other applicants" + 29 | "\nUnprofessionalism in the application" + 30 | "\nInactivity in the server"; 31 | 32 | constructor() { 33 | super( 34 | new SlashCommandBuilder() 35 | .setName("deny") 36 | .setDescription("Deny a user's moderator application.") 37 | .addUserOption((option) => 38 | option 39 | .setName("user") 40 | .setDescription("User denied.") 41 | .setRequired(true) 42 | ) 43 | .addStringOption((option) => 44 | option 45 | .setName("reason") 46 | .setDescription("Reason for the warning.") 47 | .setRequired(true) 48 | .addChoices({ 49 | name: "Too young", 50 | value: "Too young", 51 | }) 52 | .addChoices({ 53 | name: "Not been in the server long enough", 54 | value: "Not been in the server long enough", 55 | }) 56 | .addChoices({ 57 | name: "Other", 58 | value: "Other", 59 | }) 60 | ) 61 | .toJSON(), 62 | { requiredPerms: ["MANAGE_ROLES"] } 63 | ); 64 | } 65 | 66 | public async execute( 67 | interaction: CommandInteraction<"cached"> 68 | ): Promise { 69 | const member = interaction.options.getMember("user"); 70 | 71 | const dmEmbed = new MessageEmbed() 72 | .setColor("RED") 73 | .setTitle("Your application has been denied"); 74 | 75 | const user = 76 | interaction.options.getMember("user")?.user.username || "null"; 77 | let reason; 78 | switch (interaction.options.getString("reason", true)) { 79 | case "Too young": 80 | reason = Deny.tooYoung.replace("%s", user); 81 | break; 82 | case "Not been in the server long enough": 83 | reason = Deny.tooNew.replace("%s", user); 84 | break; 85 | default: 86 | reason = Deny.other.replace("%s", user); 87 | } 88 | 89 | dmEmbed.setDescription(reason); 90 | await member?.send({ embeds: [dmEmbed] }); 91 | 92 | const successMessageEmbed = new MessageEmbed() 93 | .setColor("GREEN") 94 | .setDescription("Successfully denied!"); 95 | 96 | await interaction.reply({ 97 | embeds: [successMessageEmbed], 98 | ephemeral: true, 99 | }); 100 | } 101 | } 102 | 103 | export default new Deny(); 104 | -------------------------------------------------------------------------------- /src/commands/members.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { CommandInteraction, MessageEmbed } from "discord.js"; 3 | 4 | import { Bot, BotCommand } from "../structures"; 5 | 6 | class Members extends BotCommand { 7 | constructor() { 8 | super( 9 | new SlashCommandBuilder() 10 | .setName("members") 11 | .setDescription("The number of members in this server.") 12 | .toJSON(), 13 | {} 14 | ); 15 | } 16 | 17 | public async execute( 18 | interaction: CommandInteraction<"cached">, 19 | client: Bot 20 | ): Promise { 21 | const membersCount = client.guilds.cache 22 | .map((guild) => guild.memberCount) 23 | .reduce((a, b) => a + b, 0); 24 | const successMessageEmbed = new MessageEmbed().setDescription( 25 | `There are ${membersCount} members in server` 26 | ); 27 | 28 | await interaction.reply({ 29 | embeds: [successMessageEmbed], 30 | ephemeral: true, 31 | }); 32 | } 33 | } 34 | 35 | export default new Members(); 36 | -------------------------------------------------------------------------------- /src/commands/ping.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { CommandInteraction, MessageEmbed } from "discord.js"; 3 | 4 | import { BotCommand } from "../structures"; 5 | 6 | class Ping extends BotCommand { 7 | constructor() { 8 | super( 9 | new SlashCommandBuilder() 10 | .setName("ping") 11 | .setDescription("Pings the bot.") 12 | .toJSON(), 13 | { timeout: 2000 } 14 | ); 15 | } 16 | 17 | public async execute( 18 | interaction: CommandInteraction<"cached"> 19 | ): Promise { 20 | const embed = new MessageEmbed() 21 | .setTitle("Ping") 22 | .setDescription(`API Latency: \`${interaction.client.ws.ping}\`ms`) 23 | .setColor("ORANGE"); 24 | interaction.reply({ embeds: [embed], ephemeral: true }); 25 | } 26 | 27 | private setTimeout(sec: number): Promise { 28 | return new Promise((resolve) => { 29 | setTimeout(resolve, sec * 1000); 30 | }); 31 | } 32 | } 33 | 34 | export default new Ping(); 35 | -------------------------------------------------------------------------------- /src/commands/quiz.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { 3 | CommandInteraction, 4 | Message, 5 | MessageEmbed, 6 | MessageReaction, 7 | TextChannel, 8 | User, 9 | } from "discord.js"; 10 | 11 | import { Bot, BotCommand } from "../structures"; 12 | 13 | interface IQuestion { 14 | question: string; 15 | options: string[]; 16 | correctOptionIdx: number; 17 | } 18 | 19 | interface IQuestionChoice { 20 | userId: string; 21 | correct: boolean; 22 | } 23 | 24 | const numberEmojis = [ 25 | "1️⃣", 26 | "2️⃣", 27 | "3️⃣", 28 | "4️⃣", 29 | "5️⃣", 30 | "6️⃣", 31 | "7️⃣", 32 | "8️⃣", 33 | "9️⃣", 34 | "🔟", 35 | ]; 36 | 37 | const constructEmbedMessage = (message: string): { embeds: MessageEmbed[] } => { 38 | const embed = new MessageEmbed().setDescription(message).setColor("ORANGE"); 39 | return { embeds: [embed] }; 40 | }; 41 | 42 | const collectMessage = async ( 43 | user: User, 44 | channel: TextChannel, 45 | time = 120_000 46 | ): Promise => { 47 | const collector = channel.createMessageCollector({ 48 | filter: (msg: Message) => msg.author.id === user.id, 49 | max: 1, 50 | time, 51 | }); 52 | 53 | return new Promise((resolve) => { 54 | collector.on("collect", (msg) => resolve(msg)); 55 | collector.on("end", () => resolve(null)); 56 | }); 57 | }; 58 | 59 | const cancelQuiz = async (user: User, channel: TextChannel) => { 60 | const errEmbed = new MessageEmbed() 61 | .setColor("RED") 62 | .setTitle("Quiz cancelled.") 63 | .setDescription("Quiz creation timed out."); 64 | await channel.send({ embeds: [errEmbed] }); 65 | }; 66 | 67 | const querySeconds = async ( 68 | user: User, 69 | channel: TextChannel 70 | ): Promise => { 71 | await channel.send( 72 | constructEmbedMessage("How many seconds for each question?") 73 | ); 74 | const secondsMsg = await collectMessage(user, channel); 75 | if (!secondsMsg) { 76 | await cancelQuiz(user, channel); 77 | return null; 78 | } 79 | 80 | const questionTime = parseInt(secondsMsg.content, 10) * 1000; 81 | if (questionTime / 1000 <= 5) { 82 | const errEmbed = new MessageEmbed() 83 | .setColor("RED") 84 | .setDescription("Questions must be at least 5 seconds long."); 85 | await channel.send({ embeds: [errEmbed] }); 86 | await querySeconds(user, channel); 87 | } 88 | 89 | return questionTime; 90 | }; 91 | 92 | const constructQuestionOptions = async ( 93 | user: User, 94 | channel: TextChannel, 95 | questionNumber: number, 96 | options: string[] = [] 97 | ): Promise => { 98 | await channel.send( 99 | constructEmbedMessage( 100 | `Write option (${ 101 | options.length + 1 102 | }) for question (${questionNumber})` 103 | ) 104 | ); 105 | 106 | const optionMessage = await collectMessage(user, channel); 107 | if (!optionMessage) { 108 | await cancelQuiz(user, channel); 109 | return null; 110 | } 111 | 112 | if (optionMessage.content === "next") return options; 113 | 114 | options.push(optionMessage.content); 115 | return constructQuestionOptions(user, channel, questionNumber, options); 116 | }; 117 | 118 | const constructQuestions = async ( 119 | user: User, 120 | channel: TextChannel, 121 | questions: IQuestion[] = [] 122 | ): Promise => { 123 | await channel.send( 124 | constructEmbedMessage(`Write question (${questions.length + 1}).`) 125 | ); 126 | const questionMessage = await collectMessage(user, channel); 127 | if (!questionMessage) { 128 | await cancelQuiz(user, channel); 129 | return null; 130 | } 131 | 132 | if (questionMessage.content === "finish") { 133 | return questions; 134 | } 135 | 136 | const questionOptions = await constructQuestionOptions( 137 | user, 138 | channel, 139 | questions.length + 1 140 | ); 141 | if (!questionOptions) return null; 142 | 143 | await channel.send( 144 | constructEmbedMessage( 145 | `Write the correct option number for question (${ 146 | questions.length + 1 147 | })` 148 | ) 149 | ); 150 | const correctOptionMsg = await collectMessage(user, channel); 151 | if (!correctOptionMsg) { 152 | await cancelQuiz(user, channel); 153 | return null; 154 | } 155 | 156 | const correctOptionIdx = Number(correctOptionMsg.content) - 1; 157 | if (correctOptionIdx > questionOptions.length - 1 || correctOptionIdx < 0) { 158 | const errEmbed = new MessageEmbed() 159 | .setTitle("Quiz cancelled") 160 | .setDescription("That option does not exist.") 161 | .setColor("RED"); 162 | await channel.send({ embeds: [errEmbed] }); 163 | return null; 164 | } 165 | 166 | questions.push({ 167 | question: questionMessage.content, 168 | options: questionOptions, 169 | correctOptionIdx, 170 | }); 171 | return constructQuestions(user, channel, questions); 172 | }; 173 | 174 | class Quiz extends BotCommand { 175 | constructor() { 176 | super( 177 | new SlashCommandBuilder() 178 | .setName("quiz") 179 | .setDescription("Create a quiz for server members to play..") 180 | .addChannelOption((option) => 181 | option 182 | .setName("channel") 183 | .setDescription("Channel to host the quiz.") 184 | .setRequired(true) 185 | ) 186 | .toJSON(), 187 | { requiredPerms: ["ADMINISTRATOR"] } 188 | ); 189 | } 190 | 191 | public async execute( 192 | interaction: CommandInteraction<"cached">, 193 | client: Bot 194 | ): Promise { 195 | const quizChannel = interaction.options.getChannel( 196 | "channel" 197 | ) as TextChannel; 198 | 199 | const replyEmbed = new MessageEmbed() 200 | .setColor("ORANGE") 201 | .setDescription("Setting up quiz.."); 202 | await interaction.reply({ embeds: [replyEmbed], ephemeral: true }); 203 | 204 | interaction.channel?.send( 205 | constructEmbedMessage("Please provide a quiz title.") 206 | ); 207 | 208 | const titleMsg = await collectMessage( 209 | interaction.user, 210 | interaction.channel as TextChannel 211 | ); 212 | if (!titleMsg) { 213 | await cancelQuiz( 214 | interaction.user, 215 | interaction.channel as TextChannel 216 | ); 217 | return; 218 | } 219 | 220 | const quizTitle = titleMsg.content; 221 | const questionTime = await querySeconds( 222 | interaction.user, 223 | interaction.channel as TextChannel 224 | ); 225 | if (!questionTime) return; 226 | 227 | const questions = await constructQuestions( 228 | interaction.user, 229 | interaction.channel as TextChannel 230 | ); 231 | if (!questions) return; 232 | 233 | await quizChannel.send( 234 | constructEmbedMessage( 235 | `Quiz starting in <#${interaction.channel?.id}>` 236 | ) 237 | ); 238 | 239 | const quizEmbed = new MessageEmbed() 240 | .setColor("ORANGE") 241 | .setTitle("Quiz") 242 | .setDescription( 243 | `Title: ${quizTitle}\nQuestions: ${questions.length}\nBy: <@${interaction.user.id}>` 244 | ); 245 | 246 | await quizChannel.send({ 247 | embeds: [quizEmbed], 248 | }); 249 | 250 | const questionChoices: IQuestionChoice[][] = []; 251 | 252 | // NOTE(dylhack): this code will eventually all get replaced so linters 253 | // can be disabled here. 254 | // eslint-disable-next-line no-async-promise-executor 255 | await new Promise(async (resolve) => { 256 | let questionIdx = 0; 257 | const loopQuestion = async () => { 258 | const question = questions[(questionIdx += 1)]; 259 | 260 | questionChoices.push([]); 261 | 262 | let questionMsgContent = `**${question.question}**\n\n`; 263 | 264 | question.options.forEach((option, idx) => { 265 | questionMsgContent += `${numberEmojis[idx]}: ${option}\n`; 266 | }); 267 | 268 | const questionMsg = await quizChannel.send(questionMsgContent); 269 | 270 | for (let i = 0; i < question.options.length; i += 1) { 271 | questionMsg?.react(numberEmojis[i]); 272 | } 273 | 274 | const collector = questionMsg?.createReactionCollector({ 275 | filter: (reaction: MessageReaction, user: User) => 276 | Object.values(numberEmojis).indexOf( 277 | reaction.emoji.name || "" 278 | ) !== -1 && 279 | // user.id !== interaction.user.id && 280 | user.id !== client.user?.id, 281 | time: questionTime, 282 | }); 283 | 284 | await new Promise((res) => { 285 | collector.on("collect", (reaction, user) => { 286 | const choiceIdx = Object.values(numberEmojis).indexOf( 287 | reaction.emoji.name || "" 288 | ); 289 | questionChoices[questionChoices.length - 1].push({ 290 | userId: user.id, 291 | correct: choiceIdx === question.correctOptionIdx, 292 | }); 293 | }); 294 | 295 | collector.on("end", () => { 296 | res(undefined); 297 | }); 298 | }); 299 | 300 | if (questionIdx === questions.length - 1) resolve(undefined); 301 | await loopQuestion(); 302 | }; 303 | 304 | await loopQuestion(); 305 | }); 306 | 307 | const userCorrectCount: Record = {}; 308 | 309 | questionChoices.forEach((question) => { 310 | question.forEach((answer) => { 311 | if (answer.correct) { 312 | if (userCorrectCount[answer.userId]) { 313 | userCorrectCount[answer.userId] += 1; 314 | } else { 315 | userCorrectCount[answer.userId] = 1; 316 | } 317 | } 318 | }); 319 | }); 320 | 321 | const sortable: [string, number][] = []; 322 | // eslint-disable-next-line guard-for-in,no-restricted-syntax 323 | for (const user in userCorrectCount) { 324 | sortable.push([user, userCorrectCount[user]]); 325 | } 326 | 327 | sortable.sort((a, b) => a[1] - b[1]); 328 | const winnerId = sortable[sortable.length - 1][0]; 329 | 330 | const finishEmbed = new MessageEmbed() 331 | .setColor("GREEN") 332 | .setTitle("Quiz Ended") 333 | .setDescription( 334 | `Congratulations <@${winnerId}>, you won the quiz!` 335 | ); 336 | 337 | await quizChannel.send({ embeds: [finishEmbed] }); 338 | } 339 | } 340 | 341 | export default new Quiz(); 342 | -------------------------------------------------------------------------------- /src/commands/repeat.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { CommandInteraction, MessageEmbed } from "discord.js"; 3 | 4 | import { BotCommand } from "../structures"; 5 | 6 | class Repeat extends BotCommand { 7 | constructor() { 8 | super( 9 | new SlashCommandBuilder() 10 | .setName("repeat") 11 | .setDescription("Repeats a given message.") 12 | .addStringOption((option) => 13 | option 14 | .setName("message") 15 | .setDescription("Message to repeat.") 16 | .setRequired(true) 17 | ) 18 | .toJSON(), 19 | { timeout: 60000, requiredPerms: ["ADMINISTRATOR"] } 20 | ); 21 | } 22 | 23 | public async execute( 24 | interaction: CommandInteraction<"cached"> 25 | ): Promise { 26 | await interaction.channel?.send( 27 | interaction.options.getString("message", true) 28 | ); 29 | 30 | const successEmbed = new MessageEmbed() 31 | .setColor("GREEN") 32 | .setDescription("Repeated your message."); 33 | await interaction.reply({ embeds: [successEmbed], ephemeral: true }); 34 | } 35 | } 36 | 37 | export default new Repeat(); 38 | -------------------------------------------------------------------------------- /src/commands/rolemenu.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { 3 | CommandInteraction, 4 | MessageActionRow, 5 | MessageSelectMenu, 6 | MessageSelectOptionData, 7 | } from "discord.js"; 8 | 9 | import { getRoleLists } from "../database"; 10 | import { BotCommand } from "../structures"; 11 | 12 | class RoleMe extends BotCommand { 13 | constructor() { 14 | super( 15 | new SlashCommandBuilder() 16 | .setName("rolemenu") 17 | .setDescription("Give yourself a role.") 18 | .toJSON(), 19 | {} 20 | ); 21 | } 22 | 23 | public async execute(inter: CommandInteraction<"cached">): Promise { 24 | const { guildId } = inter; 25 | if (guildId === null) { 26 | await inter.reply({ 27 | content: "This command belongs in a server.", 28 | ephemeral: true, 29 | }); 30 | return; 31 | } 32 | const roleLists = await getRoleLists(guildId); 33 | if (roleLists.length === 0) { 34 | await inter.reply({ 35 | content: "There are no roles to list.", 36 | ephemeral: true, 37 | }); 38 | return; 39 | } 40 | const components: MessageActionRow[] = []; 41 | roleLists.forEach((list) => { 42 | const row = new MessageActionRow(); 43 | if (list.choices.length === 0) { 44 | return; 45 | } 46 | const options: MessageSelectOptionData[] = list.choices 47 | .sort((cA, cB) => { 48 | if (cA > cB) { 49 | return 1; 50 | } 51 | if (cA < cB) { 52 | return -1; 53 | } 54 | return 0; 55 | }) 56 | .map((c) => { 57 | const isDefault = inter.member.roles.cache.has(c.id); 58 | return { 59 | value: c.id, 60 | label: c.name, 61 | default: isDefault, 62 | }; 63 | }); 64 | const component = new MessageSelectMenu({ 65 | type: "SELECT_MENU", 66 | customId: list.title, 67 | minValues: 0, 68 | maxValues: list.choices.length, 69 | placeholder: list.title, 70 | }); 71 | component.addOptions(options); 72 | row.addComponents(component); 73 | components.push(row); 74 | }); 75 | 76 | await inter.reply({ 77 | ephemeral: true, 78 | components, 79 | }); 80 | } 81 | } 82 | 83 | export default new RoleMe(); 84 | -------------------------------------------------------------------------------- /src/commands/selfrole.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { CommandInteraction } from "discord.js"; 3 | 4 | import { 5 | addRoleChoice, 6 | createRoleList, 7 | getRoleLists, 8 | removeRoleChoice, 9 | removeRoleList, 10 | } from "../database"; 11 | import { BotCommand } from "../structures"; 12 | 13 | class SelfRole extends BotCommand { 14 | constructor() { 15 | super( 16 | new SlashCommandBuilder() 17 | .setName("selfrole") 18 | .setDescription("Setup self-role lists.") 19 | .addSubcommand((sub) => 20 | sub 21 | .setName("createlist") 22 | .setDescription( 23 | "Create a list of roles that users" + 24 | " can assign themselves." 25 | ) 26 | .addStringOption((opt) => 27 | opt 28 | .setName("label") 29 | .setDescription("A name for the list.") 30 | .setRequired(true) 31 | ) 32 | ) 33 | .addSubcommand((sub) => 34 | sub 35 | .setName("deletelist") 36 | .setDescription("Delete a role list.") 37 | .addStringOption((opt) => 38 | opt 39 | .setName("label") 40 | .setDescription("A name for the list.") 41 | .setRequired(true) 42 | ) 43 | ) 44 | .addSubcommand((sub) => 45 | sub 46 | .setName("addchoice") 47 | .setDescription("Add a role to a role list.") 48 | .addStringOption((opt) => 49 | opt 50 | .setName("label") 51 | .setDescription("The name of the list.") 52 | .setRequired(true) 53 | ) 54 | .addRoleOption((opt) => 55 | opt 56 | .setName("role") 57 | .setDescription("The role to add") 58 | .setRequired(true) 59 | ) 60 | ) 61 | .addSubcommand((sub) => 62 | sub.setName("list").setDescription("Show all of the lists.") 63 | ) 64 | .addSubcommand((sub) => 65 | sub 66 | .setName("remchoice") 67 | .setDescription("Remove a role from a role list.") 68 | .addStringOption((opt) => 69 | opt 70 | .setName("label") 71 | .setDescription("The name of the list.") 72 | .setRequired(true) 73 | ) 74 | .addRoleOption((opt) => 75 | opt 76 | .setName("role") 77 | .setDescription("The role to remove") 78 | .setRequired(true) 79 | ) 80 | ) 81 | .toJSON(), 82 | { requiredPerms: ["ADMINISTRATOR"] } 83 | ); 84 | } 85 | 86 | private static async showLists(guildId: string, inter: CommandInteraction) { 87 | const lists = await getRoleLists(guildId); 88 | let content = "**Here are the Role Lists**"; 89 | if (lists.length > 0) { 90 | content += "\n"; 91 | } 92 | lists.forEach((list) => { 93 | content += ` - "${list.title}"\n`; 94 | }); 95 | 96 | await inter.reply({ content, ephemeral: true }); 97 | } 98 | 99 | private static async createRoleList( 100 | guildId: string, 101 | inter: CommandInteraction 102 | ) { 103 | const label = inter.options.getString("label", true); 104 | await createRoleList(guildId, label); 105 | } 106 | 107 | private static async deleteRoleList( 108 | guildId: string, 109 | inter: CommandInteraction 110 | ) { 111 | const label = inter.options.getString("label", true); 112 | await removeRoleList(guildId, label); 113 | } 114 | 115 | private static async addChoice(guildId: string, inter: CommandInteraction) { 116 | const label = inter.options.getString("label", true); 117 | const role = inter.options.getRole("role", true); 118 | await addRoleChoice(guildId, label, role.id); 119 | } 120 | 121 | private static async remChoice(guildId: string, inter: CommandInteraction) { 122 | const label = inter.options.getString("label", true); 123 | const role = inter.options.getRole("role", true); 124 | await removeRoleChoice(guildId, label, role.id); 125 | } 126 | 127 | public async execute(interaction: CommandInteraction): Promise { 128 | const subCommand = interaction.options.getSubcommand(); 129 | const { guildId } = interaction; 130 | if (guildId === null) { 131 | await interaction.reply("This command belongs in a server."); 132 | return; 133 | } 134 | switch (subCommand) { 135 | case "createlist": 136 | await SelfRole.createRoleList(guildId, interaction); 137 | break; 138 | case "deletelist": 139 | await SelfRole.deleteRoleList(guildId, interaction); 140 | break; 141 | case "addchoice": 142 | await SelfRole.addChoice(guildId, interaction); 143 | break; 144 | case "remchoice": 145 | await SelfRole.remChoice(guildId, interaction); 146 | break; 147 | case "list": 148 | await SelfRole.showLists(guildId, interaction); 149 | return; 150 | default: 151 | await interaction.reply("How did we get here?"); 152 | return; 153 | } 154 | 155 | await interaction.reply("Done."); 156 | } 157 | } 158 | 159 | export default new SelfRole(); 160 | -------------------------------------------------------------------------------- /src/commands/user.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { CommandInteraction, MessageEmbed } from "discord.js"; 3 | 4 | import { Badges, getBadge } from "../database"; 5 | import { BotCommand } from "../structures"; 6 | 7 | class Profile extends BotCommand { 8 | constructor() { 9 | super( 10 | new SlashCommandBuilder() 11 | .setName("user") 12 | .setDescription("Displays user's profile info.") 13 | .addUserOption((option) => 14 | option 15 | .setName("target") 16 | .setDescription("The user to display.") 17 | .setRequired(false) 18 | ) 19 | .toJSON(), 20 | {} 21 | ); 22 | } 23 | 24 | public async execute( 25 | interaction: CommandInteraction<"cached"> 26 | ): Promise { 27 | const userId = 28 | interaction.options.getUser("target", false)?.id || 29 | interaction.user.id; 30 | const member = await interaction.guild.members 31 | .fetch(userId) 32 | .catch(() => null); 33 | if (member === null) { 34 | await interaction.reply({ 35 | embeds: [ 36 | new MessageEmbed() 37 | .setTitle("User not found") 38 | .setColor("RED"), 39 | ], 40 | ephemeral: true, 41 | }); 42 | return; 43 | } 44 | // NOTE(Kall7): Fetching user to get their banner 45 | await member.user.fetch(); 46 | const userBadges: string[] = []; 47 | const tasks: Promise[] = []; 48 | member.user.flags?.toArray().forEach((flag) => { 49 | const task = getBadge(interaction.guildId, flag as Badges) 50 | .then((emoji) => userBadges.push(emoji)) 51 | .catch(console.log); 52 | tasks.push(task); 53 | }); 54 | await Promise.all(tasks); 55 | 56 | const avatar = 57 | member.user.avatarURL({ 58 | dynamic: true, 59 | size: 4096, 60 | }) || ""; 61 | const banner = 62 | member.user.bannerURL({ dynamic: true, size: 4096 }) || ""; 63 | const embed = new MessageEmbed() 64 | .setTitle(`${member.user.username}'s profile`) 65 | .addFields( 66 | { 67 | name: "**Basic Informations**", 68 | value: `**User's ID:** ${userId}\n**Account Created At:** \n**User Badges:** ${ 71 | userBadges.length > 0 ? userBadges.join(" ") : "None" 72 | } 73 | `, 74 | }, 75 | { 76 | name: "**Server Informations**", 77 | value: `**Server Nickname:** ${ 78 | member.nickname ? member.nickname : "None" 79 | }\n**Joined At:** \n**Highest Role:** ${ 82 | member.roles.highest.id !== interaction.guild.id 83 | ? `<@&${member.roles.highest.id}>` 84 | : "No Roles" 85 | }`, 86 | } 87 | ) 88 | .setThumbnail(avatar) 89 | .setImage(banner) 90 | .setTimestamp(Date.now()) 91 | .setColor( 92 | member.roles.highest.id !== interaction.guildId 93 | ? member.roles.highest.color 94 | : "BLUE" 95 | ); 96 | await interaction.reply({ embeds: [embed], ephemeral: true }); 97 | } 98 | } 99 | 100 | export default new Profile(); 101 | -------------------------------------------------------------------------------- /src/commands/warn.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { 3 | CommandInteraction, 4 | MessageActionRow, 5 | MessageButton, 6 | MessageEmbed, 7 | TextChannel, 8 | } from "discord.js"; 9 | 10 | import { getSpecialChannel } from "../database"; 11 | import { BotCommand } from "../structures"; 12 | 13 | class Warnings extends BotCommand { 14 | constructor() { 15 | super( 16 | new SlashCommandBuilder() 17 | .setName("warn") 18 | .setDescription( 19 | "Warn members in a warnings channel about rule violations." 20 | ) 21 | .addUserOption((option) => 22 | option 23 | .setName("user") 24 | .setDescription("User receiving the warning.") 25 | .setRequired(true) 26 | ) 27 | .addStringOption((option) => 28 | option 29 | .setName("reason") 30 | .setDescription("Reason for the warning.") 31 | .setRequired(true) 32 | ) 33 | .toJSON(), 34 | { requiredPerms: ["MANAGE_MESSAGES"] } 35 | ); 36 | } 37 | 38 | public async execute( 39 | interaction: CommandInteraction<"cached"> 40 | ): Promise { 41 | const member = interaction.options.getMember("user", true); 42 | const reason = interaction.options.getString("reason", true); 43 | 44 | const warnEmbed = new MessageEmbed().setColor("RED").setDescription(` 45 | User: ${member} 46 | Reason: \`${reason}\` 47 | Moderator: <@${interaction.member.user.id}> 48 | `); 49 | 50 | const dmEmbed = new MessageEmbed() 51 | .setColor("RED") 52 | .setTitle("You have received a warning").setDescription(` 53 | Reason: ${reason} 54 | Moderator: ${interaction.member} 55 | 56 | If you believe this warning is unjustified, appeal using the button below. 57 | `); 58 | const appealButton = new MessageButton() 59 | .setLabel("Appeal warning") 60 | .setEmoji("📜") 61 | .setCustomId("appeal_warning") 62 | .setStyle("PRIMARY"); 63 | const actionRow = new MessageActionRow(); 64 | actionRow.addComponents(appealButton); 65 | const components = [actionRow]; 66 | const dm = await member 67 | .send({ embeds: [dmEmbed], components }) 68 | .catch(() => null); 69 | const close = async () => { 70 | await interaction.reply({ 71 | embeds: [warnEmbed], 72 | }); 73 | }; 74 | if (!dm) { 75 | await close(); 76 | return; 77 | } 78 | const collector = dm.createMessageComponentCollector({ 79 | componentType: "BUTTON", 80 | time: 600_000, 81 | }); 82 | collector.once("end", async () => { 83 | appealButton.setDisabled(true); 84 | await dm.edit({ components }); 85 | }); 86 | collector.on("collect", async (i) => { 87 | await i.showModal({ 88 | customId: `appeal_${i.id}`, 89 | title: "Appeal warning", 90 | components: [ 91 | { 92 | type: "ACTION_ROW", 93 | components: [ 94 | { 95 | type: "TEXT_INPUT", 96 | label: "Elaborate", 97 | placeholder: 98 | "Explain why you think your warning was unjustified", 99 | style: "PARAGRAPH", 100 | customId: "content", 101 | required: true, 102 | }, 103 | ], 104 | }, 105 | ], 106 | }); 107 | const int = await i 108 | .awaitModalSubmit({ 109 | filter: (inte) => inte.customId === `appeal_${i.id}`, 110 | time: 600_000, 111 | }) 112 | .catch(() => null); 113 | if (!int) { 114 | await i.followUp({ 115 | content: "Modal timed out", 116 | ephemeral: true, 117 | }); 118 | return; 119 | } 120 | const appealEmbed = new MessageEmbed() 121 | .setColor("YELLOW") 122 | .setAuthor({ 123 | name: `${member.user.username} appealed their warning`, 124 | iconURL: member.user.displayAvatarURL({ 125 | dynamic: true, 126 | }), 127 | }) 128 | .setDescription(int.fields.getTextInputValue("content")) 129 | .setTimestamp() 130 | .addField("Offender", member.toString(), true) 131 | .addField("Moderator", interaction.user.toString(), true) 132 | .addField("Warning reason", reason); 133 | const optAppeal = await getSpecialChannel( 134 | interaction.guild.id, 135 | "appeals" 136 | ); 137 | if (!optAppeal) { 138 | throw new Error("There is not an appeals channel yet."); 139 | } 140 | const appealsChannel = optAppeal as TextChannel; 141 | await appealsChannel.send({ embeds: [appealEmbed] }); 142 | appealButton.setDisabled(true); 143 | await int.update({ components }); 144 | collector.stop(); 145 | }); 146 | await close(); 147 | 148 | const warnsChannel = await getSpecialChannel(interaction.guild.id, "warnings") as TextChannel; 149 | warnsChannel.send({ embeds: [warnEmbed] }); 150 | } 151 | } 152 | 153 | export default new Warnings(); 154 | -------------------------------------------------------------------------------- /src/database/badges.ts: -------------------------------------------------------------------------------- 1 | import { getClient } from "./index"; 2 | 3 | /** 4 | * @const defaults 5 | * Badges are used for profile breakdowns. See commands/user.ts 6 | * These are default assets that represent Discord granted badges. Optionally 7 | * the bot owner or administrator can update them. This is helpful if the 8 | * emojis aren't available anymore. 9 | * @example 10 | ┌───────────────────────────────────────────────────────────────┐ 11 | │ ┌────────────────────┐ │ 12 | │ LordHawk's Profile │ Profile Picture │ │ 13 | │ User's ID: 227173841095491584 │ │ │ 14 | │ Account Created At: │ │ │ 15 | │ User's Badges: [Discord Badges] │ │ │ 16 | │ └────────────────────┘ │ 17 | └───────────────────────────────────────────────────────────────┘ 18 | */ 19 | export const DEFAULT_BADGES = { 20 | DISCORD_EMPLOYEE: "<:discord_staff:585598614521511948>", 21 | PARTNERED_SERVER_OWNER: "<:discord_partner:585598614685089792>", 22 | HYPESQUAD_EVENTS: "<:discord_hypesquad:971698541313556491> ", 23 | BUGHUNTER_LEVEL_1: "<:discord_bughunterlv1:971698294743007253>", 24 | BUGHUNTER_LEVEL_2: "<:discord_bughunterlv2:971698415438274570> ", 25 | HOUSE_BRAVERY: "<:bravery:889966063100493914>", 26 | HOUSE_BRILLIANCE: "<:brilliance:889966063377317908>", 27 | HOUSE_BALANCE: "<:balance:889966062962094090>", 28 | EARLY_SUPPORTER: "<:discord_earlysupporter:971698655495082004>", 29 | EARLY_VERIFIED_BOT_DEVELOPER: "<:verified:710970919736311942>", 30 | DISCORD_CERTIFIED_MODERATOR: "<:certified_moderator:971699462072303656>", 31 | // NOTE(dylhack): probably shouldn't be left empty 32 | TEAM_USER: "", 33 | }; 34 | 35 | export type Badges = 36 | | "DISCORD_EMPLOYEE" 37 | | "PARTNERED_SERVER_OWNER" 38 | | "HYPESQUAD_EVENTS" 39 | | "BUGHUNTER_LEVEL_1" 40 | | "BUGHUNTER_LEVEL_2" 41 | | "HOUSE_BRAVERY" 42 | | "HOUSE_BRILLIANCE" 43 | | "HOUSE_BALANCE" 44 | | "EARLY_SUPPORTER" 45 | | "EARLY_VERIFIED_BOT_DEVELOPER" 46 | | "DISCORD_CERTIFIED_MODERATOR" 47 | | "TEAM_USER"; 48 | 49 | /** 50 | * Get an emoji for a specific badge. 51 | * @param {string} guildId 52 | * @param {string} badgeName 53 | */ 54 | export async function getBadge( 55 | guildId: string, 56 | badgeName: Badges 57 | ): Promise { 58 | const client = getClient(); 59 | const result = await client.badge.findFirst({ 60 | where: { 61 | guildId, 62 | badgeName, 63 | }, 64 | }); 65 | if (result === null) { 66 | if (!(badgeName in DEFAULT_BADGES)) { 67 | throw new Error(`The badge "${badgeName}" is not in defaults.`); 68 | } 69 | return DEFAULT_BADGES[badgeName] as string; 70 | } 71 | return result.emoji; 72 | } 73 | 74 | export async function setBadge( 75 | guildId: string, 76 | badgeName: Badges, 77 | emoji: string 78 | ): Promise { 79 | const client = getClient(); 80 | await client.badge.create({ 81 | data: { 82 | guildId, 83 | badgeName, 84 | emoji, 85 | }, 86 | }); 87 | } 88 | -------------------------------------------------------------------------------- /src/database/channels.ts: -------------------------------------------------------------------------------- 1 | import { AnyChannel } from "discord.js"; 2 | 3 | import { Bot } from "../structures"; 4 | import { getClient } from "./index"; 5 | 6 | /** 7 | * Here is a brief description of each special channel. 8 | * * Announcements -> Posting messages as the bot for an announcements channel 9 | * * Information -> Like a "rules" channel 10 | * * Log -> A logger channel 11 | * * Modmail -> For communicating between a community member and staff 12 | * * Roles -> A channel to role yourself (for every user) 13 | * * Warn -> A warnings report channel 14 | * * Welcome -> Welcome new users via message in a specific channel 15 | * @type {string} SpecialChannel 16 | */ 17 | export type SpecialChannel = 18 | | "announcements" 19 | | "information" 20 | | "suggestions" 21 | | "welcomes" 22 | | "logs" 23 | | "roles" 24 | | "appeals" 25 | | "modmail" 26 | | "warnings" 27 | | "help"; 28 | 29 | /** 30 | * Utility function of getSpecialChannel 31 | * @param {string} guildId 32 | * @param {SpecialChannel} label 33 | */ 34 | async function getChannelId( 35 | guildId: string, 36 | label: SpecialChannel 37 | ): Promise { 38 | const client = getClient(); 39 | const result = await client.specialChannel.findFirst({ 40 | select: { channelId: true }, 41 | where: { guildId, label }, 42 | }); 43 | return result?.channelId || null; 44 | } 45 | 46 | /** 47 | * The bot has special channels that it interacts with either based on an 48 | * event or a command execution. Check out the SpecialChannel type to see 49 | * the possible channels. Most of them will speak for themselves, but the code 50 | * that utilizes them is scattered (mostly in commands and events). 51 | * @param {string} guildId 52 | * @param {string} label 53 | * @returns {Promise} 54 | */ 55 | export async function getSpecialChannel( 56 | guildId: string, 57 | label: SpecialChannel 58 | ): Promise { 59 | const bot = Bot.getInstance(); 60 | const channelId = await getChannelId(guildId, label); 61 | if (channelId === null) { 62 | return null; 63 | } 64 | const channel = await bot.channels.fetch(channelId); 65 | if (!channel) { 66 | return null; 67 | } 68 | return channel as T; 69 | } 70 | 71 | export async function setSpecialChannel( 72 | guildId: string, 73 | label: SpecialChannel, 74 | channelId: string 75 | ): Promise { 76 | const client = getClient(); 77 | const res = await getChannelId(guildId, label); 78 | if (res === null) { 79 | await client.specialChannel.create({ 80 | data: { 81 | channelId, 82 | guildId, 83 | label, 84 | }, 85 | }); 86 | } else { 87 | await client.specialChannel.updateMany({ 88 | where: { 89 | guildId, 90 | label, 91 | }, 92 | data: { 93 | channelId, 94 | guildId, 95 | label, 96 | }, 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/database/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const client = new PrismaClient(); 4 | 5 | /** 6 | * This function should only be used in this directory. 7 | * @returns {PrismaClient} 8 | */ 9 | export function getClient(): PrismaClient { 10 | return client; 11 | } 12 | 13 | export const connectToDatabase = () => client.$connect(); 14 | 15 | export * from "./channels"; 16 | export * from "./selfroles"; 17 | export * from "./badges"; 18 | export * from "./roles"; 19 | -------------------------------------------------------------------------------- /src/database/roles.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "discord.js"; 2 | 3 | import { Bot } from "../structures"; 4 | import { getClient } from "./index"; 5 | 6 | export type SpecialRole = "announcements" | "members"; 7 | 8 | export async function getSpecialRole( 9 | guildId: string, 10 | label: SpecialRole 11 | ): Promise { 12 | const bot = Bot.getInstance(); 13 | const client = getClient(); 14 | const specialRole = await client.specialRole.findFirst({ 15 | where: { 16 | guildId, 17 | label, 18 | }, 19 | }); 20 | if (specialRole === null) { 21 | return null; 22 | } 23 | const guild = await bot.guilds.fetch(guildId); 24 | return guild.roles.fetch(specialRole.roleId); 25 | } 26 | 27 | export async function setSpecialRole( 28 | guildId: string, 29 | label: SpecialRole, 30 | roleId: string 31 | ): Promise { 32 | const client = getClient(); 33 | await client.specialRole.create({ 34 | data: { 35 | guildId, 36 | label, 37 | roleId, 38 | }, 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/database/selfroles.ts: -------------------------------------------------------------------------------- 1 | import { RoleChoice, SelfRoleList } from "@prisma/client"; 2 | import { Role } from "discord.js"; 3 | 4 | import { Bot } from "../structures"; 5 | import { getClient } from "./index"; 6 | 7 | type FullRoleChoice = Role; 8 | 9 | /** 10 | * These are the self-role drop down menus that appear in Discord. 11 | ┌───────────────────────┐ <- Role List (see getRoleList) 12 | │ v Language Roles │ 13 | ├───────────────────────┤ 14 | │ * Ruby │ <- Choices (roles) (see getChoices) 15 | │ * Kotlin │ 16 | │ * JavaScript │ 17 | └───────────────────────┘ 18 | */ 19 | interface FullSelfRoleList extends SelfRoleList { 20 | choices: FullRoleChoice[]; 21 | } 22 | 23 | /** 24 | * Utility function for getRolelist 25 | * @param {string} listId 26 | * @returns {Promise} 27 | */ 28 | function getChoices(listId: string): Promise { 29 | const client = getClient(); 30 | return client.roleChoice.findMany({ 31 | where: { listId }, 32 | }); 33 | } 34 | 35 | async function listAll(guildId: string): Promise { 36 | const client = getClient(); 37 | const lists = await client.selfRoleList.findMany({ 38 | where: { guildId }, 39 | }); 40 | let result = lists.length > 0 ? "Here are your options:\n" : ""; 41 | lists.forEach((list) => { 42 | result += ` - "${list.title}"\n`; 43 | }); 44 | return result; 45 | } 46 | 47 | /** 48 | * These are the self-role drop down menus that appear in Discord. 49 | * @param {string} guildId 50 | * @param {string} title 51 | * @returns {Promise} 52 | */ 53 | export async function getRoleList( 54 | guildId: string, 55 | title: string 56 | ): Promise { 57 | const client = getClient(); 58 | const roleList = await client.selfRoleList.findFirst({ 59 | where: { guildId, title }, 60 | }); 61 | if (roleList === null) { 62 | return null; 63 | } 64 | const partChoices = await getChoices(roleList.id); 65 | const choices: FullRoleChoice[] = []; 66 | const bot = Bot.getInstance(); 67 | const tasks: Promise[] = []; 68 | partChoices.forEach((choice) => { 69 | const iTask = bot.guilds 70 | .fetch(guildId) 71 | .then((guild) => { 72 | const jTask = guild.roles.fetch(choice.roleId); 73 | tasks.push(jTask); 74 | return jTask; 75 | }) 76 | .then((role) => { 77 | if (role !== null) { 78 | choices.push(role); 79 | } 80 | }); 81 | tasks.push(iTask); 82 | }); 83 | 84 | await Promise.all(tasks); 85 | 86 | return { 87 | guildId, 88 | choices, 89 | title: roleList.title, 90 | id: roleList.id, 91 | }; 92 | } 93 | 94 | /** 95 | * These are the self-role drop down menus that appear in Discord. 96 | * This function will resolve all the lists of one server. 97 | * @param {string} guildId 98 | * @returns {Promise} 99 | */ 100 | export async function getRoleLists( 101 | guildId: string 102 | ): Promise { 103 | const result: FullSelfRoleList[] = []; 104 | const client = getClient(); 105 | const roleLists = await client.selfRoleList.findMany({ 106 | where: { guildId }, 107 | }); 108 | const tasks: Promise[] = []; 109 | const bot = Bot.getInstance(); 110 | const guild = await bot.guilds.fetch(guildId); 111 | if (guild === null) { 112 | throw new Error("Guild unresolvable, how did we get here?"); 113 | } 114 | 115 | for (let i = 0; i < roleLists.length; i += 1) { 116 | const roleList = roleLists[i]; 117 | const fullList: FullSelfRoleList = { 118 | ...roleList, 119 | choices: [], 120 | }; 121 | const iTask = getChoices(roleList.id); 122 | iTask.then((choices) => { 123 | choices.forEach((choice) => { 124 | const jTask = guild.roles.fetch(choice.roleId); 125 | jTask.then((role) => { 126 | if (role !== null) { 127 | fullList.choices.push(role); 128 | } 129 | }); 130 | tasks.push(jTask); 131 | }); 132 | }); 133 | result.push(fullList); 134 | tasks.push(iTask); 135 | } 136 | 137 | await Promise.all(tasks); 138 | 139 | return result; 140 | } 141 | 142 | class ListNotFoundError extends Error { 143 | public static async newError(guildId: string): Promise { 144 | const lists = await listAll(guildId); 145 | let message = "That role list doesn't exist."; 146 | message += `${lists}`; 147 | 148 | return new ListNotFoundError(message); 149 | } 150 | } 151 | 152 | /** 153 | * Add a choice to a role list. 154 | * @param {string} guildId 155 | * @param {string} label 156 | * @param {string} roleId 157 | */ 158 | export async function addRoleChoice( 159 | guildId: string, 160 | label: string, 161 | roleId: string 162 | ): Promise { 163 | const client = getClient(); 164 | const roleList = await getRoleList(guildId, label); 165 | if (roleList === null) { 166 | throw await ListNotFoundError.newError(guildId); 167 | } 168 | await client.roleChoice.create({ 169 | data: { 170 | roleId, 171 | listId: roleList.id, 172 | }, 173 | }); 174 | } 175 | 176 | /** 177 | * Remove a choice from a list. 178 | * @param guildId 179 | * @param label 180 | * @param roleId 181 | */ 182 | export async function removeRoleChoice( 183 | guildId: string, 184 | label: string, 185 | roleId: string 186 | ): Promise { 187 | const client = getClient(); 188 | // NOTE(dylhack): This is intentional to prevent a user from tampering 189 | // with a role list of another server. 190 | const roleList = await getRoleList(guildId, label); 191 | if (roleList === null) { 192 | throw await ListNotFoundError.newError(guildId); 193 | } 194 | 195 | await client.roleChoice.delete({ where: { roleId } }); 196 | } 197 | 198 | export async function removeRoleList( 199 | guildId: string, 200 | label: string 201 | ): Promise { 202 | const client = getClient(); 203 | const roleList = await getRoleList(guildId, label); 204 | if (roleList === null) { 205 | throw await ListNotFoundError.newError(guildId); 206 | } 207 | 208 | await client.selfRoleList.delete({ where: { id: roleList.id } }); 209 | } 210 | 211 | export async function createRoleList( 212 | guildId: string, 213 | label: string 214 | ): Promise { 215 | const client = getClient(); 216 | const res = await client.selfRoleList.findFirst({ 217 | where: { 218 | guildId, 219 | title: label, 220 | }, 221 | }); 222 | if (res === null) { 223 | await client.selfRoleList.create({ 224 | data: { 225 | guildId, 226 | title: label, 227 | }, 228 | }); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/events/error.ts: -------------------------------------------------------------------------------- 1 | import { Bot } from "../structures"; 2 | import { TypedEvent } from "../types"; 3 | 4 | export default TypedEvent({ 5 | eventName: "error", 6 | run: (bot: Bot, err: Error) => { 7 | bot.logger.console.error(err); 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/events/guildMemberAdd.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GuildMember, 3 | MessageEmbed, 4 | PartialGuildMember, 5 | TextChannel, 6 | } from "discord.js"; 7 | 8 | import { getSpecialChannel } from "../database"; 9 | import { Bot } from "../structures"; 10 | import { TypedEvent } from "../types"; 11 | 12 | export default TypedEvent({ 13 | eventName: "guildMemberAdd", 14 | run: async (client: Bot, member: GuildMember | PartialGuildMember) => { 15 | if (member.partial) return; 16 | 17 | const welcomeMessageEmbed = new MessageEmbed() 18 | .setColor("ORANGE") 19 | .setTitle("New Member") 20 | .setDescription( 21 | `Welcome ${member.user.username} to the conaticus server\n` + 22 | "Use `/rolemenu` to choose your pings and languages roles\n" + 23 | "Enjoy your stay!" 24 | ); 25 | 26 | const welcomeChannel = await getSpecialChannel( 27 | member.guild.id, 28 | "welcomes" 29 | ); 30 | if (welcomeChannel !== null) { 31 | const txt = welcomeChannel as TextChannel; 32 | await txt.send({ 33 | content: `<@${member.user.id}>`, 34 | embeds: [welcomeMessageEmbed], 35 | }); 36 | } 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /src/events/guildMemberRemove.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember, MessageEmbed, PartialGuildMember } from "discord.js"; 2 | 3 | import { Bot } from "../structures"; 4 | import { TypedEvent } from "../types"; 5 | 6 | async function memberRemoveEvent(member: GuildMember, client: Bot) { 7 | const createdTimestamp = Math.floor(member.user.createdTimestamp / 1000); 8 | const joinedTimestamp = Math.floor((member.joinedTimestamp || 0) / 1000); 9 | const embed = new MessageEmbed() 10 | .setAuthor({ 11 | name: member.user.tag, 12 | iconURL: member.displayAvatarURL(), 13 | }) 14 | .setDescription("Member left") 15 | .setColor("RED") 16 | .addField( 17 | "• Account Created", 18 | ` ()`, 19 | false 20 | ) 21 | .addField( 22 | "• Joined", 23 | ` ()`, 24 | false 25 | ) 26 | .addField("• Account ID", member.id, false) 27 | .setTimestamp() 28 | .setFooter({ 29 | text: "Boolean", 30 | iconURL: client.user?.displayAvatarURL(), 31 | }) 32 | .setThumbnail(member.guild?.iconURL() || ""); 33 | 34 | await client.logger.channel(member.guild.id, embed); 35 | client.logger.console.info(`User ${member.user.tag} has left the server.`); 36 | } 37 | 38 | export default TypedEvent({ 39 | eventName: "guildMemberRemove", 40 | run: async (client: Bot, member: GuildMember | PartialGuildMember) => { 41 | if (member.partial) return; 42 | await memberRemoveEvent(member, client); 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /src/events/guildMemberUpdate.ts: -------------------------------------------------------------------------------- 1 | // NOTE(dylhack): eventually we need to deal with the any's 2 | /* eslint-disable @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-assertion */ 3 | import { 4 | GuildMember, 5 | MessageActionRow, 6 | MessageEmbed, 7 | PartialGuildMember, 8 | User, 9 | TextChannel, 10 | MessageButton, 11 | } from "discord.js"; 12 | 13 | import { Bot } from "../structures"; 14 | import { TypedEvent } from "../types"; 15 | import { getSpecialChannel } from "../database"; 16 | 17 | function memberRoleAddEvent( 18 | client: Bot, 19 | target: User, 20 | executor: User, 21 | role: any 22 | ): MessageEmbed { 23 | const targetTag = target.tag; 24 | const execTag = executor.tag; 25 | const desc = `${execTag}(<@${executor?.id}>) added <@&${role[0].id}> to ${targetTag}(<@${target.id}>)`; 26 | const embed = new MessageEmbed() 27 | .setTitle(`• Role added to ${targetTag}`) 28 | .setDescription(desc) 29 | .setColor("ORANGE") 30 | .setTimestamp() 31 | .setFooter({ 32 | text: "Boolean", 33 | iconURL: client.user?.displayAvatarURL(), 34 | }); 35 | 36 | client.logger.console.info(desc); 37 | return embed; 38 | } 39 | 40 | function memberRoleRemoveEvent( 41 | client: Bot, 42 | target: User, 43 | executor: User, 44 | role: any 45 | ): MessageEmbed { 46 | const execTag = executor.tag; 47 | const targetTag = target.tag; 48 | const desc = `${execTag}(<@${executor?.id}>) removed <@&${role[0].id}> from ${targetTag}(<@${target.id}>)`; 49 | const embed = new MessageEmbed() 50 | .setTitle(`• Role removed from ${targetTag}`) 51 | .setDescription(desc) 52 | .setColor("RED") 53 | .setTimestamp() 54 | .setFooter({ 55 | text: "Boolean", 56 | iconURL: client.user?.displayAvatarURL(), 57 | }); 58 | 59 | client.logger.console.info(embed); 60 | return embed; 61 | } 62 | 63 | function nicknameUpdateEvent( 64 | member: GuildMember, 65 | oldMemberNickname: string | null, 66 | newMemberNickname: string | null, 67 | client: Bot 68 | ): MessageEmbed { 69 | const desc = `${member.user.tag} changed their nickname from ${oldMemberNickname} to ${newMemberNickname}`; 70 | const embed = new MessageEmbed() 71 | .setTitle("Nickname was updated!") 72 | .setDescription(desc) 73 | .setAuthor({ 74 | name: member.user.tag, 75 | iconURL: member.displayAvatarURL(), 76 | }) 77 | .setColor("ORANGE") 78 | .addField("New Nickname", newMemberNickname || "NULL", true) 79 | .addField("Old Nickname", oldMemberNickname || "NULL", true) 80 | .setTimestamp() 81 | .setFooter({ 82 | text: "Boolean", 83 | iconURL: client.user?.displayAvatarURL(), 84 | }); 85 | 86 | client.logger.console.info(desc); 87 | return embed; 88 | } 89 | 90 | export default TypedEvent({ 91 | eventName: "guildMemberUpdate", 92 | run: async ( 93 | client: Bot, 94 | oldMember: GuildMember | PartialGuildMember, 95 | newMember: GuildMember | PartialGuildMember 96 | ) => { 97 | // Fetch the latest audit log 98 | const AuditLogs = await oldMember.guild.fetchAuditLogs({ 99 | limit: 1, 100 | }); 101 | const lastLog = AuditLogs.entries.first(); 102 | if (lastLog === undefined) { 103 | return; 104 | } 105 | lastLog.changes = lastLog.changes || []; 106 | let embed: MessageEmbed | null = null; 107 | 108 | // Role update events 109 | if (lastLog && (lastLog.action as string) === "MEMBER_ROLE_UPDATE") { 110 | if (lastLog.executor?.bot) { 111 | return; 112 | } 113 | 114 | // Role add 115 | if (lastLog.changes?.at(0)?.key === "$add") { 116 | embed = memberRoleAddEvent( 117 | client, 118 | lastLog.target! as User, 119 | lastLog.executor!, 120 | lastLog.changes?.at(0)?.new 121 | ); 122 | // Role Remove 123 | } else if (lastLog.changes?.at(0)?.key === "$remove") { 124 | embed = await memberRoleRemoveEvent( 125 | client, 126 | lastLog.target! as User, 127 | lastLog.executor!, 128 | lastLog.changes?.at(0)?.new 129 | ); 130 | } 131 | if (embed !== null) { 132 | await client.logger.channel(oldMember.guild.id, embed); 133 | } 134 | return; 135 | } 136 | 137 | // Nickname updates 138 | if (oldMember.nickname !== newMember.nickname) { 139 | embed = await nicknameUpdateEvent( 140 | newMember as GuildMember, 141 | oldMember.nickname!, 142 | newMember.nickname!, 143 | client 144 | ); 145 | await client.logger.channel(oldMember.guild.id, embed); 146 | return; 147 | } 148 | 149 | const isDisabled = 150 | lastLog.changes.some( 151 | (e) => e.key === "communication_disabled_until" 152 | ) && newMember.isCommunicationDisabled(); 153 | 154 | // Timeout event (this doesn't go-to a log channel, instead their DM's) 155 | if (isDisabled) { 156 | const durationMs = 157 | newMember.communicationDisabledUntilTimestamp - 158 | lastLog.createdTimestamp; 159 | const durationMinutes = Math.round(durationMs / 60000); 160 | const durationUnits = [ 161 | [Math.floor(durationMinutes / 1440), "d"], 162 | [Math.floor((durationMinutes % 1440) / 60), "h"], 163 | [Math.floor(durationMinutes % 60), "m"], 164 | ]; 165 | const durationFormatted = durationUnits 166 | .filter((e) => e[0]) 167 | .flat() 168 | .join(""); 169 | const reason = lastLog.reason?.slice(0, 500); 170 | const reasonField = reason ? `Reason: *${reason}*` : ""; 171 | const dmEmbed = new MessageEmbed() 172 | .setColor("RED") 173 | .setTitle("You have received a time out").setDescription(` 174 | ${reasonField} 175 | Moderator: ${lastLog.executor} 176 | Duration: ${durationFormatted} () 179 | 180 | **If you believe this time out is unjustified, appeal using the button below.** 181 | `); 182 | const appealButton = new MessageButton() 183 | .setLabel("Appeal time out") 184 | .setStyle("PRIMARY") 185 | .setCustomId("appeal_timeout") 186 | .setEmoji("📜"); 187 | const appealRow = new MessageActionRow(); 188 | appealRow.addComponents(appealButton); 189 | const components = [appealRow]; 190 | const dm = await newMember 191 | .send({ embeds: [dmEmbed], components }) 192 | .catch(() => null); 193 | if (!dm) return; 194 | const collector = dm.createMessageComponentCollector({ 195 | componentType: "BUTTON", 196 | time: durationMs > 600_000 ? 600_000 : durationMs, 197 | }); 198 | collector.on("collect", async (i) => { 199 | await i.showModal({ 200 | customId: `appeal_${i.id}`, 201 | title: "Appeal time out", 202 | components: [ 203 | { 204 | type: "ACTION_ROW", 205 | components: [ 206 | { 207 | type: "TEXT_INPUT", 208 | label: "Elaborate", 209 | placeholder: 210 | "Explain why you think your time out was unjustified", 211 | style: "PARAGRAPH", 212 | customId: "content", 213 | required: true, 214 | }, 215 | ], 216 | }, 217 | ], 218 | }); 219 | const int = await i 220 | .awaitModalSubmit({ 221 | filter: (inte) => inte.customId === `appeal_${i.id}`, 222 | time: 600_000, 223 | }) 224 | .catch(() => null); 225 | if (!int) { 226 | await i.followUp({ 227 | content: "Modal timed out", 228 | ephemeral: true, 229 | }); 230 | return; 231 | } 232 | collector.stop(); 233 | const appealEmbed = new MessageEmbed() 234 | .setColor("ORANGE") 235 | .setAuthor({ 236 | name: `${newMember.user.username} appealed their time out`, 237 | iconURL: newMember.user.displayAvatarURL({ 238 | dynamic: true, 239 | }), 240 | }) 241 | .setDescription(int.fields.getTextInputValue("content")) 242 | .setTimestamp() 243 | .addField("Offender", newMember.toString(), true) 244 | .addField("Moderator", lastLog.executor!.toString(), true); 245 | if (reason) appealEmbed.addField("Time out reason", reason); 246 | const optAppeal = await getSpecialChannel( 247 | newMember.guild.id, 248 | "appeals" 249 | ); 250 | if (!optAppeal) 251 | throw new Error("There is not an appeals channel yet."); 252 | const appealsChannel = optAppeal as TextChannel; 253 | await appealsChannel.send({ embeds: [appealEmbed] }); 254 | appealButton.setDisabled(true); 255 | await int.update({ components }); 256 | }); 257 | collector.on("end", async () => { 258 | appealButton.setDisabled(true); 259 | await dm.edit({ components }); 260 | }); 261 | } 262 | }, 263 | }); 264 | -------------------------------------------------------------------------------- /src/events/interactionCreate.ts: -------------------------------------------------------------------------------- 1 | import { Interaction, MessageEmbed } from "discord.js"; 2 | 3 | import { getRoleLists } from "../database"; 4 | import { Bot } from "../structures"; 5 | import { TypedEvent } from "../types"; 6 | 7 | export default TypedEvent({ 8 | eventName: "interactionCreate", 9 | run: async (client: Bot, interaction: Interaction) => { 10 | if (interaction.isCommand() || interaction.isContextMenu()) { 11 | const command = client.commands.get(interaction.commandName); 12 | if (!command) { 13 | return; 14 | } 15 | 16 | if (command.requiredPerms) { 17 | if (!interaction.inCachedGuild()) { 18 | return; 19 | } 20 | const hasPerms = interaction.member.permissions.has( 21 | command.requiredPerms 22 | ); 23 | if (!hasPerms) { 24 | const invalidPermissionsEmbed = new MessageEmbed() 25 | .setColor("RED") 26 | .setTitle("Command Failed") 27 | .setDescription( 28 | "You have insufficient permissions to use" + 29 | " this command." 30 | ); 31 | await interaction.reply({ 32 | embeds: [invalidPermissionsEmbed], 33 | ephemeral: true, 34 | }); 35 | return; 36 | } 37 | } 38 | 39 | try { 40 | await command.execute(interaction, client); 41 | } catch (e) { 42 | let msg = "NULL"; 43 | if (e instanceof Error) { 44 | msg = e.message; 45 | } else if (typeof e === "object" && e !== null) { 46 | msg = e.toString(); 47 | } 48 | 49 | console.error(e); 50 | const errorEmbed = new MessageEmbed() 51 | .setColor("RED") 52 | .setDescription( 53 | "❌ An error occurred while executing the command." + 54 | `\`\`\`\n${msg}\`\`\`` 55 | ); 56 | 57 | if (interaction.deferred) { 58 | await interaction.editReply({ 59 | content: " ", 60 | embeds: [errorEmbed], 61 | }); 62 | } else if (interaction.replied) { 63 | await interaction.followUp({ 64 | content: " ", 65 | embeds: [errorEmbed], 66 | }); 67 | } else { 68 | await interaction.reply({ 69 | content: " ", 70 | embeds: [errorEmbed], 71 | ephemeral: true, 72 | }); 73 | } 74 | } 75 | } else if (interaction.isSelectMenu()) { 76 | const roleLists = await getRoleLists(interaction.guildId || ""); 77 | 78 | if (roleLists.every((e) => e.title !== interaction.customId)) { 79 | return; 80 | } 81 | if (!interaction.inCachedGuild()) { 82 | return; 83 | } 84 | 85 | await interaction.member.roles.set( 86 | interaction.member.roles.cache 87 | .map((e) => e.id) 88 | .filter((e) => 89 | interaction.component.options.every( 90 | (el) => el.value !== e 91 | ) 92 | ) 93 | .concat(interaction.values) 94 | ); 95 | await interaction.reply({ 96 | content: "Your roles were updated", 97 | ephemeral: true, 98 | }); 99 | } 100 | }, 101 | }); 102 | -------------------------------------------------------------------------------- /src/events/messageCreate.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel } from "discord.js"; 2 | import { v4 as uuid } from "uuid"; 3 | import { getSpecialChannel } from "../database"; 4 | import { Bot } from "../structures"; 5 | import { TypedEvent } from "../types"; 6 | 7 | async function helpChan(message: Message): Promise { 8 | if (!message.content || message.author.bot || !message.guild) { 9 | return; 10 | } 11 | const helpChannelOpt = await getSpecialChannel( 12 | message.guild.id, 13 | "help" 14 | ).catch(() => null); 15 | if (helpChannelOpt === null) { 16 | return; 17 | } 18 | const helpChannel = helpChannelOpt as TextChannel; 19 | 20 | const id = uuid().split("-"); 21 | const thId = id[id.length - 1]; 22 | const threadName = `Thread #${thId}`; 23 | 24 | if (message.channel.id === helpChannel.id) { 25 | message.startThread({ 26 | name: threadName, 27 | autoArchiveDuration: "MAX", 28 | }); 29 | } 30 | } 31 | 32 | async function massPingCheck(message: Message): Promise { 33 | if ( 34 | message.mentions.users.size > 5 && 35 | message.inGuild() && 36 | !message.member?.permissions.has("MENTION_EVERYONE") 37 | ) { 38 | await message.member?.timeout(600_000, "Mass mentions"); 39 | } 40 | } 41 | 42 | export default TypedEvent({ 43 | eventName: "messageCreate", 44 | run: async (client: Bot, message: Message) => { 45 | helpChan(message); 46 | massPingCheck(message); 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /src/events/messageDelete.ts: -------------------------------------------------------------------------------- 1 | import { GuildAuditLogs, Message, PartialMessage } from "discord.js"; 2 | 3 | import { Bot } from "../structures"; 4 | import { TypedEvent } from "../types"; 5 | import { handleAssets, newEmbed } from "../utils"; 6 | 7 | async function log(message: Message, client: Bot) { 8 | if (!message.guild) return; 9 | const audits = await message.guild.fetchAuditLogs({ 10 | type: GuildAuditLogs.Actions.MESSAGE_DELETE, 11 | limit: 1, 12 | }); 13 | const lastEntry = audits?.entries.first(); 14 | let executor; 15 | const lastLoggedDeletion = client.getLastLoggedDeletion(message.guild.id); 16 | if ( 17 | lastEntry && 18 | lastLoggedDeletion && 19 | (lastEntry.id !== lastLoggedDeletion.id || 20 | lastEntry.extra.count !== lastLoggedDeletion.extra.count) 21 | ) 22 | executor = lastEntry.executor; 23 | client.setLastLoggedDeletion(message.guild.id, lastEntry); 24 | if (!["DEFAULT", "REPLY"].includes(message.type)) return; 25 | const embed = newEmbed(message); 26 | if (executor) { 27 | embed 28 | .addField("\u200B", "\u200B", true) 29 | .addField("Executor", executor.toString(), true) 30 | .addField( 31 | "Sent at", 32 | ``, 33 | true 34 | ) 35 | .addField("\u200B", "\u200B", true); 36 | } else { 37 | embed.addField( 38 | "Sent at", 39 | ``, 40 | true 41 | ); 42 | } 43 | handleAssets(message, embed); 44 | 45 | await client.logger.channel(message?.guildId || "", embed); 46 | client.logger.console.info( 47 | `${message.author.tag} has deleted the message "${message.content}"` 48 | ); 49 | } 50 | 51 | export default TypedEvent({ 52 | eventName: "messageDelete", 53 | run: async (client: Bot, message: Message | PartialMessage) => { 54 | // Check if the message is partial 55 | if (message.partial) return; 56 | 57 | // Check if the author of the deleted messaage is the bot 58 | if (message.author.bot) return; 59 | 60 | await log(message, client); 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /src/events/messageUpdate.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageEmbed, PartialMessage } from "discord.js"; 2 | 3 | import { Bot } from "../structures"; 4 | import { TypedEvent } from "../types"; 5 | import * as utils from "../utils"; 6 | 7 | async function log(oldMessage: Message, newMessage: Message, client: Bot) { 8 | const embed = new MessageEmbed() 9 | .setAuthor({ 10 | name: newMessage.author.tag, 11 | iconURL: newMessage.author.displayAvatarURL(), 12 | }) 13 | .setDescription( 14 | `Message sent in <#${newMessage.channelId}> [Jump to Message](${newMessage.url})` 15 | ) 16 | .setColor("ORANGE"); 17 | 18 | // Old Message 19 | if (oldMessage.content !== "") { 20 | if (oldMessage.attachments.size >= 1) { 21 | embed.addField( 22 | "• Old Message", 23 | oldMessage.content + 24 | "\n".concat( 25 | utils.formatAttachmentsURL(newMessage.attachments) 26 | ), 27 | false 28 | ); 29 | } else { 30 | embed.addField("• Old Message", oldMessage.content, false); 31 | } 32 | } else { 33 | embed.addField( 34 | "• Old Message", 35 | utils.formatAttachmentsURL(oldMessage.attachments), 36 | false 37 | ); 38 | } 39 | 40 | // New Message 41 | if (newMessage.content !== "") { 42 | if (newMessage.attachments.size >= 1) 43 | embed.addField( 44 | "• New Message", 45 | newMessage.content + 46 | "\n".concat( 47 | utils.formatAttachmentsURL(newMessage.attachments) 48 | ), 49 | false 50 | ); 51 | else embed.addField("• New Message", newMessage.content, false); 52 | } else { 53 | embed.addField( 54 | "• New Message", 55 | utils.formatAttachmentsURL(newMessage.attachments), 56 | false 57 | ); 58 | } 59 | 60 | embed.setTimestamp(); 61 | embed.setFooter({ 62 | text: "Boolean", 63 | iconURL: client.user?.displayAvatarURL(), 64 | }); 65 | embed.setThumbnail(newMessage.guild?.iconURL() || ""); 66 | 67 | client.logger.console.info( 68 | `${oldMessage.author.tag} has edited the message "${oldMessage.content}" to "${newMessage.content}"` 69 | ); 70 | await client.logger.channel(newMessage?.guildId || "", embed); 71 | } 72 | 73 | export default TypedEvent({ 74 | eventName: "messageUpdate", 75 | run: async ( 76 | client: Bot, 77 | oldMessage: Message | PartialMessage, 78 | newMessage: Message | PartialMessage 79 | ) => { 80 | if (newMessage.partial) return; 81 | 82 | if (oldMessage.partial) { 83 | return; 84 | } 85 | 86 | // Check if the old message is present in the cache 87 | // Throws an exception if the author is null 88 | if (oldMessage.author == null) { 89 | return; 90 | } 91 | 92 | if ( 93 | newMessage.author.bot || 94 | oldMessage.content === newMessage.content 95 | ) { 96 | return; 97 | } 98 | 99 | await log(oldMessage, newMessage, client); 100 | }, 101 | }); 102 | -------------------------------------------------------------------------------- /src/events/ready.ts: -------------------------------------------------------------------------------- 1 | import { GuildAuditLogs } from "discord.js"; 2 | import { getCommandFiles } from "../files"; 3 | import { Bot, BotCommand } from "../structures"; 4 | import { TypedEvent } from "../types"; 5 | import modmailCmds from "../services/modmail"; 6 | 7 | export default TypedEvent({ 8 | eventName: "ready", 9 | once: true, 10 | run: async (client: Bot) => { 11 | client.logger.console.info(`Logged in as ${client.user?.tag}.`); 12 | 13 | // register our slash commands 14 | const commandFiles = getCommandFiles(); 15 | const commandArr: BotCommand[] = [ 16 | // NOTE(dylhack): this is a little hack to get modmail up and 17 | // working. it's possibly not a preferable way of 18 | // doing this. 19 | ...modmailCmds(), 20 | ]; 21 | 22 | const modules = await Promise.all( 23 | commandFiles.map((file) => import(file)) 24 | ); 25 | modules.forEach((module) => { 26 | const command = module.default as BotCommand; 27 | if (command === undefined) { 28 | return; 29 | } else { 30 | commandArr.push(command); 31 | } 32 | }); 33 | 34 | await client.register(commandArr); 35 | 36 | // do some audit log stuff 37 | const tasks: Promise[] = []; 38 | client.guilds.cache.forEach((guild) => { 39 | const task = guild 40 | .fetchAuditLogs({ 41 | type: GuildAuditLogs.Actions.MESSAGE_DELETE, 42 | limit: 1, 43 | }) 44 | .then((audits) => { 45 | client.setLastLoggedDeletion( 46 | guild.id, 47 | audits?.entries.first() 48 | ); 49 | }) 50 | .catch(() => null); 51 | tasks.push(task); 52 | }); 53 | await Promise.all(tasks); 54 | client.logger.console.info("Ready"); 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /src/files.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | 4 | const walk = ( 5 | pathLike: fs.PathLike, 6 | options?: 7 | | { 8 | encoding: BufferEncoding | null; 9 | } 10 | | BufferEncoding 11 | | null 12 | | undefined 13 | ): string[] => { 14 | let results: string[] = []; 15 | const fileList = fs.readdirSync(pathLike, options); 16 | for (let i = 0; i < fileList.length; i += 1) { 17 | const file = fileList[i]; 18 | const stat = fs.statSync(path.join(pathLike.toString(), file)); 19 | results = [ 20 | ...results, 21 | ...(stat && stat.isDirectory() 22 | ? walk(path.join(pathLike.toString(), file)) 23 | : [path.join(pathLike.toString(), file)]), 24 | ]; 25 | } 26 | return results; 27 | }; 28 | 29 | export const getCommandFiles = () => 30 | walk(path.join(__dirname, "commands")).filter((file) => 31 | [".ts", ".js"].some((ext) => file.endsWith(ext)) 32 | ); 33 | 34 | export const getEventFiles = () => 35 | walk(path.join(__dirname, "events")).filter((file) => 36 | [".ts", ".js"].some((ext) => file.endsWith(ext)) 37 | ); 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import "dotenv/config"; 3 | 4 | import { connectToDatabase } from "./database"; 5 | import { Bot } from "./structures"; 6 | 7 | const bot = new Bot(); 8 | 9 | async function main() { 10 | await connectToDatabase(); 11 | await bot.start(); 12 | } 13 | 14 | main().catch(console.error); 15 | -------------------------------------------------------------------------------- /src/services/modmail/commands/DeleteCtxMenu.ts: -------------------------------------------------------------------------------- 1 | import { ContextMenuCommandBuilder } from "@discordjs/builders"; 2 | import { ApplicationCommandType } from "discord-api-types/v10"; 3 | import { MessageContextMenuInteraction } from "discord.js"; 4 | import { getMessageByAuthor } from "../util"; 5 | import { BotCommand } from "../../../structures"; 6 | import { syncDelete } from "../sync"; 7 | 8 | export default class ModmailDeleteContext extends BotCommand { 9 | constructor() { 10 | super( 11 | new ContextMenuCommandBuilder() 12 | .setName("Delete Modmail") 13 | .setType(ApplicationCommandType.Message) 14 | .toJSON() 15 | ); 16 | } 17 | 18 | public async execute(int: MessageContextMenuInteraction): Promise { 19 | const [modmail, msg] = await getMessageByAuthor(int); 20 | await syncDelete(modmail, msg); 21 | await int.reply({ content: "Deleted.", ephemeral: true }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/services/modmail/commands/EditCtxMenu.ts: -------------------------------------------------------------------------------- 1 | import { ContextMenuCommandBuilder } from "@discordjs/builders"; 2 | import { ApplicationCommandType } from "discord-api-types/v10"; 3 | import { 4 | MessageActionRow, 5 | MessageContextMenuInteraction, 6 | Modal, 7 | ModalSubmitInteraction, 8 | TextInputComponent, 9 | } from "discord.js"; 10 | import { getMessageByAuthor } from "../util"; 11 | import { BotCommand } from "../../../structures"; 12 | import { syncEdit } from "../sync"; 13 | 14 | export default class ModmailEditContext extends BotCommand { 15 | constructor() { 16 | super( 17 | new ContextMenuCommandBuilder() 18 | .setName("Edit Modmail") 19 | .setType(ApplicationCommandType.Message) 20 | .toJSON() 21 | ); 22 | } 23 | 24 | public async execute(int: MessageContextMenuInteraction): Promise { 25 | const [modmail, msg] = await getMessageByAuthor(int); 26 | const textC = new TextInputComponent() 27 | .setCustomId("new_content") 28 | .setLabel("What is your new message?") 29 | .setValue(msg.content) 30 | .setStyle("PARAGRAPH"); 31 | const actionRow = new MessageActionRow({ components: [textC] }); 32 | const modal = new Modal() 33 | .setTitle("New Message") 34 | .addComponents(actionRow) 35 | .setCustomId(int.id); 36 | await int.showModal(modal); 37 | const res = await int.awaitModalSubmit({ 38 | filter: (i: ModalSubmitInteraction) => i.customId === int.id, 39 | time: 600_000, 40 | }); 41 | const newContent = res.fields.getTextInputValue("new_content"); 42 | await syncEdit(modmail, msg, newContent); 43 | await res.reply({ content: "Edited.", ephemeral: true }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/services/modmail/commands/ModmailCmd.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseGuildTextChannel, 3 | CommandInteraction, 4 | Guild, 5 | MessageAttachment, 6 | TextChannel, 7 | User, 8 | } from "discord.js"; 9 | import { 10 | SlashCommandBuilder, 11 | SlashCommandSubcommandBuilder, 12 | } from "@discordjs/builders"; 13 | import { Modmail } from "@prisma/client"; 14 | import { Bot, BotCommand } from "../../../structures"; 15 | import { 16 | closeModmail, 17 | countOpenModmails, 18 | hasActiveModmail, 19 | openModmail, 20 | storeAttachment, 21 | storeMsg, 22 | } from "../database"; 23 | import { 24 | getEmbed, 25 | getModmailByInt, 26 | getStaffEmbed, 27 | getSystemEmbed, 28 | } from "../util"; 29 | import { getSpecialChannel } from "../../../database"; 30 | import { maxModmails } from "../constants"; 31 | 32 | export default class ModmailCommand extends BotCommand { 33 | constructor() { 34 | super( 35 | new SlashCommandBuilder() 36 | .setName("modmail") 37 | .setDescription("Modmail management.") 38 | .addSubcommand((sub: SlashCommandSubcommandBuilder) => 39 | sub 40 | .setName("open") 41 | .setDescription("Open a new thread.") 42 | .addStringOption((opt) => 43 | opt 44 | .setName("topic") 45 | .setDescription("The subject.") 46 | .setRequired(true) 47 | ) 48 | .addUserOption((opt) => 49 | opt 50 | .setName("member") 51 | .setDescription( 52 | "Open a modmail for this member" + 53 | " (staff only)." 54 | ) 55 | .setRequired(false) 56 | ) 57 | ) 58 | .addSubcommand((sub: SlashCommandSubcommandBuilder) => 59 | sub 60 | .setName("close") 61 | .setDescription("Close the thread.") 62 | .addStringOption((opt) => 63 | opt 64 | .setName("reason") 65 | .setDescription("The reason for closing.") 66 | .setRequired(false) 67 | ) 68 | ) 69 | .addSubcommand((sub: SlashCommandSubcommandBuilder) => 70 | sub 71 | .setName("reply") 72 | .setDescription("Send a message in the thread.") 73 | .addStringOption((opt) => 74 | opt 75 | .setName("content") 76 | .setDescription("The message replying with.") 77 | .setRequired(true) 78 | ) 79 | .addAttachmentOption((opt) => 80 | opt 81 | .setName("attachment") 82 | .setDescription("Add an attachment.") 83 | .setRequired(false) 84 | ) 85 | ) 86 | .toJSON(), 87 | {} 88 | ); 89 | } 90 | 91 | private async reply(int: CommandInteraction, ctx: Modmail): Promise { 92 | const bot = Bot.getInstance(); 93 | const user = await bot.users.fetch(ctx.memberId); 94 | const dmChannel = await user.createDM(); 95 | const optChannel = await bot.channels.fetch(ctx.channelId); 96 | if (optChannel === null) { 97 | throw new Error("There is no active modmail."); 98 | } 99 | 100 | const mmChannel = optChannel as BaseGuildTextChannel; 101 | const content = int.options.getString("content", true); 102 | const attachment = int.options.getAttachment("attachment", false); 103 | const attachments: MessageAttachment[] = 104 | attachment !== null ? [attachment] : []; 105 | const isStaff = int.user.id !== ctx.memberId; 106 | let memberCopy; 107 | let staffCopy; 108 | 109 | if (!isStaff) { 110 | const embed = getEmbed( 111 | mmChannel.guild, 112 | int.user, 113 | content, 114 | isStaff, 115 | attachments 116 | ); 117 | memberCopy = await dmChannel.send({ 118 | embeds: [embed], 119 | }); 120 | staffCopy = await mmChannel.send({ 121 | embeds: [embed], 122 | }); 123 | } else { 124 | const [regular, anonymous] = getStaffEmbed( 125 | mmChannel.guild, 126 | int.user, 127 | content, 128 | isStaff, 129 | attachments 130 | ); 131 | memberCopy = await dmChannel.send({ 132 | embeds: [anonymous], 133 | }); 134 | staffCopy = await mmChannel.send({ 135 | embeds: [regular], 136 | }); 137 | } 138 | 139 | const msg = await storeMsg( 140 | ctx, 141 | int.user.id, 142 | content, 143 | staffCopy.id, 144 | memberCopy.id 145 | ); 146 | await int.editReply({ content: "Message sent." }); 147 | if (attachment !== null) { 148 | await storeAttachment(msg, attachment); 149 | } 150 | } 151 | 152 | private async checkUp(user: User, guild: Guild): Promise { 153 | const count = await countOpenModmails(guild.id); 154 | if (count >= maxModmails) { 155 | throw new Error("This server has met their maximum modmails."); 156 | } 157 | const inModmail = await hasActiveModmail(user.id); 158 | if (inModmail) { 159 | throw new Error( 160 | "User can not participate in more than one modmails" 161 | ); 162 | } 163 | } 164 | 165 | private async open(int: CommandInteraction): Promise { 166 | const { guild, user } = int; 167 | if (guild === null) { 168 | throw new Error("This command belongs in a server."); 169 | } 170 | const member = int.options.getUser("member", false); 171 | const target = member || user; 172 | await this.checkUp(target, guild); 173 | 174 | const topic = int.options.getString("topic", false); 175 | const dmChannel = await target.createDM(); 176 | const modmailChan = await getSpecialChannel( 177 | guild.id, 178 | "modmail" 179 | ); 180 | const parent = modmailChan?.parent; 181 | if (modmailChan === null || !parent) { 182 | throw new Error("Modmail has not be setup here yet."); 183 | } 184 | 185 | if (member !== null && modmailChan.id !== int.channelId) { 186 | throw new Error( 187 | "Staff can only open modmails for other users. If you are a staff" + 188 | " then please run this in the modmail channel." 189 | ); 190 | } 191 | 192 | const channel = await parent.createChannel( 193 | `${target.username}-${target.discriminator}`, 194 | { type: "GUILD_TEXT" } 195 | ); 196 | const modmail = await openModmail( 197 | guild.id, 198 | channel.id, 199 | int.user.id, 200 | target.id 201 | ); 202 | await int.reply({ content: "Modmail opened.", ephemeral: true }); 203 | const dmMessage = getSystemEmbed( 204 | "New Modmail", 205 | "You can reply by typing `/modmail reply`" 206 | ); 207 | const mmMessage = getSystemEmbed( 208 | "New Modmail", 209 | `\nTopic: \`${topic}\`` + 210 | `\nModmail ID: \`${modmail.id}\`` + 211 | `\nUser ID: \`${modmail.memberId}\`` + 212 | `\nOpened By: \`${modmail.authorId}\`` 213 | ); 214 | await channel.setTopic( 215 | `Topic: "${topic || "Not set."}"\nModmail ID: \`${modmail.id}\`` + 216 | `\nUser ID: ${target.id}` 217 | ); 218 | await channel.send({ embeds: [mmMessage] }); 219 | await dmChannel.send({ embeds: [dmMessage] }); 220 | } 221 | 222 | private async close(int: CommandInteraction, ctx: Modmail): Promise { 223 | const bot = Bot.getInstance(); 224 | const reason = int.options.getString("reason", false) || "No reason."; 225 | const user = await bot.users.fetch(ctx.memberId); 226 | const mmChannel = await bot.channels.fetch(ctx.channelId); 227 | const dmChannel = await user.createDM(); 228 | const sysMessage = getSystemEmbed("Modmail closed", reason); 229 | await int.editReply("Closed."); 230 | 231 | if (mmChannel !== null) { 232 | await (mmChannel as TextChannel).send({ embeds: [sysMessage] }); 233 | await mmChannel.delete(); 234 | } 235 | await closeModmail(ctx.id); 236 | await dmChannel.send({ embeds: [sysMessage] }); 237 | } 238 | 239 | public async execute(interaction: CommandInteraction): Promise { 240 | const subCmd = interaction.options.getSubcommand(true); 241 | if (subCmd === "open") { 242 | await this.open(interaction); 243 | return; 244 | } 245 | await interaction.deferReply({ ephemeral: true }); 246 | const ctx = await getModmailByInt(interaction); 247 | if (ctx === null) { 248 | throw new Error("This is not an active modmail channel."); 249 | } 250 | 251 | switch (subCmd) { 252 | case "close": 253 | await this.close(interaction, ctx); 254 | break; 255 | case "reply": 256 | await this.reply(interaction, ctx); 257 | break; 258 | default: 259 | // NOTE(dylhack): this should never be a resolvable clause. 260 | // sub commands added should be added to this 261 | // switch. 262 | throw new Error("How did we get here? (sub cmd unresolvable)"); 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/services/modmail/constants.ts: -------------------------------------------------------------------------------- 1 | // This color is used in embeds that are sent by a member of staff 2 | export const modColor = 0x009dff; 3 | // This color is used in embeds that are sent by a community member 4 | export const userColor = 0xfb7c0c; 5 | // This color is used in embeds that are sent by the bot 6 | export const systemColor = 0xfdb200; 7 | // Maximum modmails that can be open for a Discord guild. 8 | export const maxModmails = 15; 9 | -------------------------------------------------------------------------------- /src/services/modmail/database.ts: -------------------------------------------------------------------------------- 1 | import { Modmail, ModmailMessage, Prisma } from "@prisma/client"; 2 | import { MessageAttachment } from "discord.js"; 3 | import { getClient } from "../../database"; 4 | 5 | /** 6 | * This will store an attachment that is associated with a ModmailMessage 7 | * @param {ModmailMessage} ctx 8 | * @param {MessageAttachment} attachment 9 | * @returns {Promise} 10 | */ 11 | export async function storeAttachment( 12 | ctx: ModmailMessage, 13 | attachment: MessageAttachment 14 | ): Promise { 15 | const client = getClient(); 16 | await client.modmailAttachment.create({ 17 | data: { 18 | url: attachment.url, 19 | name: attachment.name || "", 20 | message: { 21 | connect: { id: ctx.id }, 22 | }, 23 | }, 24 | }); 25 | } 26 | 27 | /** 28 | * We do not edit our original contents of a message, instead we 29 | * store the edit in an edits table for historical purposes. 30 | * @param {string} msgId 31 | * @param {string} newContent 32 | * @returns {Promise} 33 | */ 34 | export async function editMessage( 35 | msgId: string, 36 | newContent: string 37 | ): Promise { 38 | const client = getClient(); 39 | const iteration = await client.modmailEdit.count({ 40 | where: { messageId: msgId }, 41 | }); 42 | await client.modmailEdit.create({ 43 | data: { 44 | content: newContent, 45 | iteration: iteration + 1, 46 | message: { 47 | connect: { id: msgId }, 48 | }, 49 | }, 50 | }); 51 | } 52 | 53 | /** 54 | * We don't delete messages. Instead we mark the message in our database as 55 | * deleted. 56 | * @param {string} msgId 57 | * @returns {Promise} 58 | */ 59 | export async function deleteMessage(msgId: string): Promise { 60 | const client = getClient(); 61 | await client.modmailMessage.update({ 62 | where: { 63 | id: msgId, 64 | }, 65 | data: { deleted: true }, 66 | }); 67 | } 68 | 69 | type FullMessage = Prisma.ModmailMessageGetPayload<{ 70 | include: { attachments: true; edits: true }; 71 | }>; 72 | 73 | /** 74 | * This will include the message's attachments and edits. If the message 75 | * queried doesn't resolve then null is returned. 76 | * @returns {Promise 77 | */ 78 | export async function getMessage( 79 | where: Prisma.ModmailMessageWhereInput 80 | ): Promise { 81 | const client = getClient(); 82 | const raw = await client.modmailMessage.findFirst({ 83 | where, 84 | include: { 85 | attachments: true, 86 | edits: true, 87 | }, 88 | }); 89 | if (raw === null) { 90 | return raw; 91 | } 92 | return raw; 93 | } 94 | 95 | export type FullModmail = Prisma.ModmailGetPayload<{ 96 | include: { messages: true }; 97 | }>; 98 | 99 | /** 100 | * This will resolve all of the messages with the modmail. 101 | * @param {Prisma.ModmailWhereInput} where Prisma SQL query 102 | * @returns {Promise} 103 | */ 104 | export async function getModmail( 105 | where: Prisma.ModmailWhereInput 106 | ): Promise { 107 | const client = getClient(); 108 | const modmail = await client.modmail.findFirst({ 109 | where: { 110 | closed: false, 111 | ...where, 112 | }, 113 | include: { 114 | messages: { 115 | include: { 116 | attachments: true, 117 | edits: true, 118 | }, 119 | }, 120 | }, 121 | }); 122 | 123 | return modmail; 124 | } 125 | 126 | export async function openModmail( 127 | guildId: string, 128 | channelId: string, 129 | authorId: string, 130 | memberId: string 131 | ): Promise { 132 | const client = getClient(); 133 | const modmail = await client.modmail.create({ 134 | data: { 135 | guildId, 136 | authorId, 137 | channelId, 138 | memberId, 139 | }, 140 | }); 141 | 142 | return modmail; 143 | } 144 | 145 | export async function hasActiveModmail(userId: string): Promise { 146 | const client = getClient(); 147 | const count = await client.modmail.count({ 148 | where: { 149 | memberId: userId, 150 | closed: false, 151 | }, 152 | }); 153 | return count > 0; 154 | } 155 | 156 | export function countOpenModmails(guildId: string): Promise { 157 | const client = getClient(); 158 | return client.modmail.count({ 159 | where: { 160 | guildId, 161 | closed: false, 162 | }, 163 | }); 164 | } 165 | 166 | export async function closeModmail(modmailId: string): Promise { 167 | const client = getClient(); 168 | await client.modmail.update({ 169 | where: { id: modmailId }, 170 | data: { closed: true }, 171 | }); 172 | } 173 | 174 | export async function getParticipants(modmailId: string): Promise { 175 | const client = getClient(); 176 | const result = await client.modmailMessage.findMany({ 177 | where: { 178 | modmailId, 179 | }, 180 | select: { senderId: true }, 181 | distinct: ["senderId"], 182 | }); 183 | 184 | return result.map(({ senderId }) => senderId); 185 | } 186 | 187 | export async function storeMsg( 188 | modmail: Modmail, 189 | authorId: string, 190 | content: string, 191 | staffId: string, 192 | memberId: string 193 | ): Promise { 194 | const client = getClient(); 195 | const data = { 196 | guildId: modmail.guildId, 197 | channelId: modmail.channelId, 198 | senderId: authorId, 199 | staffCopyId: staffId, 200 | memberCopyId: memberId, 201 | content, 202 | }; 203 | const result = await client.modmailMessage.create({ 204 | data: { 205 | ...data, 206 | deleted: false, 207 | modmail: { 208 | connect: { id: modmail.id }, 209 | }, 210 | }, 211 | }); 212 | 213 | return { 214 | ...data, 215 | id: result.id, 216 | modmailId: modmail.id, 217 | deleted: false, 218 | }; 219 | } 220 | -------------------------------------------------------------------------------- /src/services/modmail/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * NOTE(dylhack): 3 | * Service Breakdown 4 | * 5 | * Member <-> Bot <-> Staff 6 | * 7 | * Modmail allows community members to communicate with the moderators in a 8 | * safe private manner.The importance of this MiTM model is to keep the 9 | * staff protected from the user incase of any harassment. 10 | * 11 | * For this to work there are three copies of the same message sent by either 12 | * party (Member or Staff); for instance if the user sends a message then 13 | * that message needs to be stored in our database AND send to the staff so 14 | * that they can see it. 15 | * 16 | * This means whenever something happens to one message it needs to be synced 17 | * with the other two copies (ie deleted, edited, reacted, etc.). 18 | * 19 | * Rules 20 | * 21 | * - Remember that Boolean is multiserver supported. 22 | * - A community member can only participate in one modmail at a time. 23 | * - As declared in the neighboring constants.ts there is a maximum amount 24 | * of modmails that can be opened at a time. This number should never be 25 | * near 30 since that is the maximum amount of channels that can be held 26 | * in category. 27 | * 28 | * Types 29 | * 30 | * All of the following types are defined in the prisma/schema.prisma file. 31 | * For more details go view that file. 32 | * 33 | * - Modmail: A "Modmail" is an active communication between *a* 34 | * community member and the community's staff. 35 | * - ModmailMessage: The importance of a message is to make sure that the 36 | * staff's copy and member's copy are in sync. This type 37 | * holds the ID of both messages and our neighboring sync.ts 38 | * file will be responsible for syncing updates. 39 | * - staffCopyId: The ID of the message of the staff's copy 40 | * - memberCopyId: The ID of the message of the member's copy 41 | * - ModmailAttachment: An attachment is part of a ModmailMessage. 42 | * - ModmailEdit: An edit is when someone edit's their ModmailMessage. 43 | * We reserve all edits and deletions in our database. 44 | * 45 | * Commands 46 | * - /modmail open (topic: string) 47 | * - /modmail close (reason: string) 48 | * - /modmail reply (message: string) (attachment? Attachment) 49 | */ 50 | import ModmailDeleteContext from "./commands/DeleteCtxMenu"; 51 | import ModmailEditContext from "./commands/EditCtxMenu"; 52 | import ModmailCommand from "./commands/ModmailCmd"; 53 | import { BotCommand } from "../../structures"; 54 | 55 | export default function getCommands(): BotCommand[] { 56 | return [ 57 | new ModmailCommand(), 58 | new ModmailEditContext(), 59 | new ModmailDeleteContext(), 60 | ]; 61 | } 62 | -------------------------------------------------------------------------------- /src/services/modmail/sync.ts: -------------------------------------------------------------------------------- 1 | import { Modmail, ModmailMessage } from "@prisma/client"; 2 | import { AnyChannel, Message } from "discord.js"; 3 | import { editMessage, deleteMessage } from "./database"; 4 | import { Bot } from "../../structures"; 5 | 6 | type Copies = { 7 | staffCopy?: Message; 8 | memberCopy?: Message; 9 | }; 10 | 11 | async function resolveMsg( 12 | channel: AnyChannel | null, 13 | id: string 14 | ): Promise { 15 | if (channel !== null && channel.isText()) { 16 | const msg = await channel.messages.fetch(id); 17 | return msg; 18 | } 19 | return undefined; 20 | } 21 | 22 | export async function getCopies( 23 | ctx: Modmail, 24 | msg: ModmailMessage 25 | ): Promise { 26 | const bot = Bot.getInstance(); 27 | const user = await bot.users.fetch(ctx.memberId); 28 | const dmChannel = await user.createDM(); 29 | const mmChannel = await bot.channels.fetch(ctx.channelId); 30 | const memberCopy = await resolveMsg(dmChannel, msg.memberCopyId); 31 | const staffCopy = await resolveMsg(mmChannel, msg.staffCopyId); 32 | return { memberCopy, staffCopy }; 33 | } 34 | 35 | /** 36 | * Sync the deletion of a message between the member and staff copy. 37 | * @param {Modmail} ctx 38 | * @param {ModmailMessage} msg 39 | * @returns {Promise} 40 | */ 41 | export async function syncDelete( 42 | ctx: Modmail, 43 | msg: ModmailMessage 44 | ): Promise { 45 | await deleteMessage(msg.id); 46 | const c = await getCopies(ctx, msg); 47 | console.debug(c); 48 | if (c.memberCopy && c.memberCopy.deletable) { 49 | await c.memberCopy.delete(); 50 | } 51 | if (c.staffCopy && c.staffCopy.deletable) { 52 | await c.staffCopy.delete(); 53 | } 54 | } 55 | 56 | async function updateEmbed(msg: Message, newContent: string): Promise { 57 | const [embed] = msg.embeds; 58 | if (embed === undefined) { 59 | return; 60 | } 61 | embed.setDescription(newContent); 62 | await msg.edit({ embeds: [embed] }); 63 | } 64 | 65 | /** 66 | * Sync the edits of a message between the member and staff copy. 67 | * @param {Modmail} ctx 68 | * @param {ModmailMessage} msg 69 | * @param {string} newContent 70 | * @returns {Promise} 71 | */ 72 | export async function syncEdit( 73 | ctx: Modmail, 74 | msg: ModmailMessage, 75 | newContent: string 76 | ): Promise { 77 | await editMessage(msg.id, newContent); 78 | const c = await getCopies(ctx, msg); 79 | console.debug(c); 80 | if (c.memberCopy && c.memberCopy.editable) { 81 | await updateEmbed(c.memberCopy, newContent); 82 | } 83 | if (c.staffCopy && c.staffCopy.editable) { 84 | await updateEmbed(c.staffCopy, newContent); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/services/modmail/util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessageContextMenuInteraction, 3 | BaseCommandInteraction, 4 | MessageEmbed, 5 | User, 6 | Guild, 7 | MessageAttachment, 8 | } from "discord.js"; 9 | import { Modmail, ModmailMessage } from "@prisma/client"; 10 | import { modColor, systemColor, userColor } from "./constants"; 11 | import { FullModmail, getModmail } from "./database"; 12 | import { Bot } from "../../structures"; 13 | 14 | const IMAGE_REGEX = /\.|jpe?g|tiff?|png|gif|webp|bmp$/i; 15 | 16 | export function getEmbed( 17 | guild: Guild, 18 | author: User, 19 | content: string, 20 | isStaff: boolean, 21 | attachments: MessageAttachment[] = [] 22 | ): MessageEmbed { 23 | const guildIcon = guild.iconURL() || undefined; 24 | const embed = new MessageEmbed({ 25 | footer: { 26 | iconURL: guildIcon, 27 | }, 28 | }); 29 | let desc = content; 30 | for (let i = 0; i < attachments.length; i += 1) { 31 | const attachment = attachments[i]; 32 | const name = attachment.name || "untitled"; 33 | const ext = name.substring(name.lastIndexOf(".") + 1); 34 | const isImage = IMAGE_REGEX.test(ext); 35 | if (i === 0) { 36 | desc += "\n\n**Attachments**\n"; 37 | } 38 | if (attachment.height !== null) { 39 | if (isImage) { 40 | embed.setImage(attachment.url); 41 | } /* else { 42 | // NOTE(dylhack): This will work when videos work. 43 | embed.video = { 44 | url: attachment.url, 45 | height: attachment.height, 46 | width: attachment.width, 47 | }; 48 | } 49 | */ 50 | } 51 | desc += ` - [${name || "untitled"}](${attachment.url})\n`; 52 | } 53 | return embed 54 | .setTitle("Message") 55 | .setDescription(desc) 56 | .setColor(isStaff ? modColor : userColor) 57 | .setAuthor({ 58 | iconURL: !isStaff ? author.avatarURL() || undefined : guildIcon, 59 | name: !isStaff ? author.tag : `${guild.name} Staff`, 60 | }) 61 | .setTimestamp(); 62 | } 63 | 64 | export function getSystemEmbed(title: string, content: string): MessageEmbed { 65 | const bot = Bot.getInstance(); 66 | return new MessageEmbed() 67 | .setTitle(title) 68 | .setColor(systemColor) 69 | .setDescription(content) 70 | .setAuthor({ 71 | name: bot.user.tag, 72 | iconURL: bot.user.avatarURL() || bot.user.defaultAvatarURL, 73 | }) 74 | .setTimestamp(); 75 | } 76 | 77 | export function getStaffEmbed( 78 | guild: Guild, 79 | author: User, 80 | content: string, 81 | isStaff: boolean, 82 | attachments: MessageAttachment[] = [] 83 | ): [MessageEmbed, MessageEmbed] { 84 | const anonymous = getEmbed(guild, author, content, isStaff, attachments); 85 | const regular = getEmbed(guild, author, content, isStaff, attachments); 86 | regular.setAuthor({ 87 | name: author.tag, 88 | iconURL: author.avatarURL() || author.defaultAvatarURL, 89 | }); 90 | return [regular, anonymous]; 91 | } 92 | 93 | /** 94 | * Get an active Modmail based on the current interaction. 95 | * @param {BaseCommandInteraction} interaction 96 | * @returns {Promise} 97 | */ 98 | export async function getModmailByInt( 99 | interaction: BaseCommandInteraction 100 | ): Promise { 101 | let ctx: FullModmail | null = null; 102 | if (interaction.guildId === null) { 103 | ctx = await getModmail({ 104 | memberId: interaction.user.id, 105 | }); 106 | } else { 107 | ctx = await getModmail({ 108 | channelId: interaction.channelId, 109 | }); 110 | } 111 | return ctx; 112 | } 113 | 114 | export async function getMessageByAuthor( 115 | int: MessageContextMenuInteraction 116 | ): Promise<[Modmail, ModmailMessage]> { 117 | const modmail = await getModmailByInt(int); 118 | const targetId = int.targetMessage.id; 119 | if (modmail === null) { 120 | throw new Error("There isn't an active modmail here."); 121 | } 122 | 123 | let msg: ModmailMessage | null = null; 124 | for (let i = 0; i < modmail.messages.length; i += 1) { 125 | const message = modmail.messages[i]; 126 | if (message.senderId === int.user.id) { 127 | if ( 128 | message.staffCopyId === targetId || 129 | message.memberCopyId === targetId 130 | ) { 131 | msg = message; 132 | break; 133 | } 134 | } 135 | } 136 | 137 | if (msg === null) { 138 | throw new Error( 139 | "I could not resolve this message, was it sent by you?" 140 | ); 141 | } 142 | 143 | return [modmail, msg]; 144 | } 145 | -------------------------------------------------------------------------------- /src/structures/Bot.ts: -------------------------------------------------------------------------------- 1 | import { Client, Collection, GuildAuditLogsEntry, Intents } from "discord.js"; 2 | 3 | import { getEventFiles } from "../files"; 4 | import { BotCommand, Logger } from "../structures"; 5 | import { IBotEvent } from "../types"; 6 | 7 | export default class Bot extends Client { 8 | // eslint-disable-next-line no-use-before-define 9 | protected static instance: Bot; 10 | 11 | public commands = new Collection(); 12 | 13 | public logger = new Logger({ level: process.env.LOG_LEVEL || "info" }); 14 | 15 | // NOTE(HordLawk): This feels wrong, but I don't know TS and I need to 16 | // use this property 17 | // NOTE(hayper): I got you fam 18 | private lastLoggedDeletion: Map< 19 | string, 20 | GuildAuditLogsEntry<"MESSAGE_DELETE"> 21 | >; 22 | 23 | constructor() { 24 | super({ 25 | intents: [ 26 | Intents.FLAGS.GUILDS, 27 | Intents.FLAGS.GUILD_MESSAGES, 28 | Intents.FLAGS.GUILD_MESSAGE_REACTIONS, 29 | Intents.FLAGS.GUILD_MEMBERS, 30 | Intents.FLAGS.GUILD_PRESENCES, 31 | ], 32 | partials: ["MESSAGE", "CHANNEL", "REACTION"], 33 | }); 34 | this.lastLoggedDeletion = new Map(); 35 | Bot.instance = this; 36 | } 37 | 38 | static getInstance(): Bot { 39 | return Bot.instance; 40 | } 41 | 42 | getLastLoggedDeletion( 43 | guildId: string 44 | ): GuildAuditLogsEntry<"MESSAGE_DELETE"> | null { 45 | return this.lastLoggedDeletion.get(guildId) || null; 46 | } 47 | 48 | setLastLoggedDeletion( 49 | guildId: string, 50 | value?: GuildAuditLogsEntry<"MESSAGE_DELETE"> 51 | ) { 52 | // NOTE(dylhack): this allows for shorter syntax from outside usage. 53 | if (value !== undefined) { 54 | this.lastLoggedDeletion.set(guildId, value); 55 | } 56 | } 57 | 58 | async start() { 59 | await this.initModules(); 60 | await this.login(process.env.TOKEN || ""); 61 | } 62 | 63 | async initModules() { 64 | const tasks: Promise[] = []; 65 | const eventFiles = getEventFiles(); 66 | const modules = await Promise.all( 67 | eventFiles.map((file) => import(file)) 68 | ); 69 | modules.forEach((module) => { 70 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 71 | const event = module.default as IBotEvent; 72 | if (!event) { 73 | return; 74 | } else { 75 | if (event.once) { 76 | this.once(event.eventName, event.run.bind(null, this)); 77 | } else { 78 | this.on(event.eventName, event.run.bind(null, this)); 79 | } 80 | this.logger.console.debug( 81 | `Registered event ${event.eventName}` 82 | ); 83 | } 84 | }); 85 | 86 | await Promise.all(tasks); 87 | this.logger.console.info("Registering slash commands"); 88 | } 89 | 90 | async register(cmds: BotCommand[]): Promise { 91 | // Register to a testing server 92 | const payload = cmds.map((cmd) => { 93 | this.commands.set(cmd.data.name, cmd); 94 | return cmd.data; 95 | }); 96 | const devServer = process.env.DEV_SERVER; 97 | if (devServer !== undefined) { 98 | const guild = await this.guilds.fetch(devServer); 99 | await guild.commands.set(payload); 100 | this.logger.console.info(`Registered commands to ${devServer}`); 101 | return; 102 | } 103 | // else... register globally 104 | 105 | // clear dev commands 106 | const tasks: Promise[] = []; 107 | this.guilds.cache.forEach((guild) => { 108 | const task = guild.commands.set([]); 109 | tasks.push(task); 110 | }); 111 | await Promise.all(tasks).catch(() => null); 112 | // register global commands 113 | await this.application.commands.set(payload); 114 | this.logger.console.info("Registered commands globally"); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/structures/BotCommand.ts: -------------------------------------------------------------------------------- 1 | import { RESTPostAPIApplicationCommandsJSONBody } from "discord-api-types/v10"; 2 | import { BaseCommandInteraction, PermissionResolvable } from "discord.js"; 3 | 4 | import Bot from "./Bot"; 5 | 6 | export type BotCommandOpt = { 7 | requiredPerms?: PermissionResolvable; 8 | timeout?: number; 9 | }; 10 | 11 | export default abstract class BotCommand { 12 | public readonly data: RESTPostAPIApplicationCommandsJSONBody; 13 | 14 | public readonly timeout?: number; 15 | 16 | public readonly requiredPerms?: PermissionResolvable; 17 | 18 | protected constructor( 19 | data: RESTPostAPIApplicationCommandsJSONBody, 20 | opt?: BotCommandOpt 21 | ) { 22 | this.data = data; 23 | this.timeout = opt?.timeout; 24 | this.requiredPerms = opt?.requiredPerms; 25 | } 26 | 27 | public abstract execute( 28 | interaction: BaseCommandInteraction, 29 | client: Bot 30 | ): Promise; 31 | } 32 | -------------------------------------------------------------------------------- /src/structures/Logger.ts: -------------------------------------------------------------------------------- 1 | import { MessageEmbed, TextChannel } from "discord.js"; 2 | import pino, { Logger as PinoLogger, LoggerOptions } from "pino"; 3 | 4 | import { getSpecialChannel } from "../database"; 5 | 6 | export default class Logger { 7 | console: PinoLogger; 8 | 9 | constructor(options: LoggerOptions) { 10 | this.console = pino({ 11 | transport: { target: "pino-pretty" }, 12 | ...options, 13 | }); 14 | } 15 | 16 | async channel(guildId: string, embed: MessageEmbed | MessageEmbed[]) { 17 | const embeds = embed instanceof Array ? embed : [embed]; 18 | const logOpt = await getSpecialChannel(guildId, "logs"); 19 | if (logOpt !== null) { 20 | const logChannel = logOpt as TextChannel; 21 | await logChannel.send({ embeds }); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/structures/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Bot } from "./Bot"; 2 | export { default as BotCommand } from "./BotCommand"; 3 | export { default as Logger } from "./Logger"; 4 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents } from "discord.js"; 2 | 3 | import { Bot } from "./structures"; 4 | 5 | export interface IDataObject { 6 | reactionMessages: Record; 7 | } 8 | 9 | export type EventName = keyof ClientEvents; 10 | 11 | export type EventListener = ( 12 | _client: Bot, 13 | ...args: ClientEvents[T] 14 | ) => void; 15 | 16 | export interface IBotEvent { 17 | eventName: T; 18 | once?: boolean; 19 | run: EventListener; 20 | } 21 | 22 | export const TypedEvent = (event: IBotEvent) => event; 23 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Collection, 3 | CommandInteraction, 4 | Message, 5 | MessageAttachment, 6 | MessageEmbed, 7 | } from "discord.js"; 8 | 9 | interface QuestionOptions { 10 | ephemeral: boolean; 11 | } 12 | 13 | export function askQuestion( 14 | interaction: CommandInteraction<"cached">, 15 | question: string 16 | ): Promise; 17 | export function askQuestion( 18 | interaction: CommandInteraction<"cached">, 19 | question: string, 20 | options: Exclude & { noErr: true } 21 | ): Promise; 22 | 23 | export async function askQuestion( 24 | interaction: CommandInteraction<"cached">, 25 | question: string, 26 | { ephemeral }: QuestionOptions = { ephemeral: false } 27 | ) { 28 | const embed = new MessageEmbed() 29 | .setColor("ORANGE") 30 | .setDescription(question); 31 | 32 | if (ephemeral) 33 | await interaction.reply({ 34 | embeds: [embed], 35 | ephemeral, 36 | }); 37 | else 38 | await interaction.channel?.send({ 39 | embeds: [embed], 40 | }); 41 | 42 | try { 43 | const messages = await interaction.channel?.awaitMessages({ 44 | filter: (m) => m.author.id === interaction.user.id, 45 | time: 60_000, 46 | max: 1, 47 | }); 48 | const msg = messages?.first(); 49 | 50 | if (msg?.content) return msg.content; 51 | return null; 52 | } catch (err) { 53 | return null; 54 | } 55 | } 56 | 57 | export function newEmbed(msg: Message): MessageEmbed { 58 | return new MessageEmbed() 59 | .setAuthor({ 60 | name: "Deleted message", 61 | iconURL: msg.author.displayAvatarURL(), 62 | url: msg.url, 63 | }) 64 | .setDescription(msg.content) 65 | .setColor("RED") 66 | .setTimestamp() 67 | .setFooter({ 68 | text: "Boolean", 69 | iconURL: msg.client.user?.displayAvatarURL(), 70 | }) 71 | .addField("Author", msg.author.toString(), true) 72 | .addField("Channel", msg.channel.toString(), true); 73 | } 74 | 75 | export function formatAttachmentsURL( 76 | attachments: Collection 77 | ) { 78 | return [...attachments.values()] 79 | .map((e, i) => 80 | e.height 81 | ? `[\`Attachment-${i}-Media\`](${e.proxyURL})` 82 | : `[\`Attachment-${i}-File\`](${e.url})` 83 | ) 84 | .join("\n") 85 | .concat("\n") 86 | .slice(0, 1024) 87 | .split(/\n/g) 88 | .slice(0, -1) 89 | .join("\n"); 90 | } 91 | 92 | export function handleAssets(message: Message, embed: MessageEmbed) { 93 | // Add stickers 94 | const sticker = message.stickers.first(); 95 | if (sticker) { 96 | if (sticker.format === "LOTTIE") { 97 | embed.addField("Sticker", `[${sticker.name}](${sticker.url})`); 98 | } else { 99 | embed.setThumbnail(sticker.url); 100 | } 101 | } 102 | 103 | // Add attachments 104 | if (message.attachments.size) { 105 | embed.addField( 106 | "Attachments", 107 | formatAttachmentsURL(message.attachments) 108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "module": "commonjs" /* Specify what module code is generated. */, 5 | "rootDir": "./src" /* Specify the root folder within your source files. */, 6 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, 7 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 8 | "removeComments": true /* Disable emitting comments. */, 9 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 10 | "skipLibCheck": true, 11 | "strict": true /* Enable all strict type-checking options. */ 12 | }, 13 | "include": ["src/**/*"] 14 | } 15 | --------------------------------------------------------------------------------