From 763fb7d98c3accbb21adf035a7cf0a83cb9533c9 Mon Sep 17 00:00:00 2001 From: TymanWasTaken <32660892+tymanwastaken@users.noreply.github.com> Date: Tue, 27 Apr 2021 21:06:22 -0600 Subject: legit just copy utilibot v2 code --- .gitattributes | 1 + .github/workflows/nodejs.yml | 34 + .gitignore | 252 +++ .prettierignore | 2 + .vscode/launch.json | 18 + .vscode/settings.json | 12 + LICENSE | 21 + README.md | 28 + SETUP.md | 16 + package.json | 73 + src/bot.ts | 7 + src/commands/admin/PrefixCommand.ts | 30 + src/commands/info/BotInfoCommand.ts | 58 + src/commands/info/HelpCommand.ts | 79 + src/commands/info/PingCommand.ts | 42 + src/commands/moderation/BanCommand.ts | 137 ++ src/commands/moderation/KickCommand.ts | 72 + src/commands/moderation/ModlogCommand.ts | 143 ++ src/commands/moderation/WarnCommand.ts | 54 + src/commands/owner/EvalCommand.ts | 139 ++ src/commands/owner/ReloadCommand.ts | 34 + src/config/example-options.ts | 30 + src/inhibitors/blacklist/BlacklistInhibitor.ts | 14 + src/lib/extensions/BotClient.ts | 274 +++ src/lib/extensions/BotCommand.ts | 6 + src/lib/extensions/BotGuild.ts | 38 + src/lib/extensions/BotInhibitor.ts | 6 + src/lib/extensions/BotListener.ts | 6 + src/lib/extensions/BotMessage.ts | 50 + src/lib/extensions/Util.ts | 196 +++ src/lib/types/BaseModel.ts | 6 + src/lib/types/Models.ts | 102 ++ src/lib/utils/TopGG.ts | 110 ++ src/listeners/client/ReadyListener.ts | 16 + src/listeners/commands/CommandBlockedListener.ts | 34 + src/listeners/guild/Unban.ts | 25 + src/tasks.ts | 38 + tsconfig.json | 24 + yarn.lock | 1937 ++++++++++++++++++++++ 39 files changed, 4164 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/nodejs.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SETUP.md create mode 100644 package.json create mode 100644 src/bot.ts create mode 100644 src/commands/admin/PrefixCommand.ts create mode 100644 src/commands/info/BotInfoCommand.ts create mode 100644 src/commands/info/HelpCommand.ts create mode 100644 src/commands/info/PingCommand.ts create mode 100644 src/commands/moderation/BanCommand.ts create mode 100644 src/commands/moderation/KickCommand.ts create mode 100644 src/commands/moderation/ModlogCommand.ts create mode 100644 src/commands/moderation/WarnCommand.ts create mode 100644 src/commands/owner/EvalCommand.ts create mode 100644 src/commands/owner/ReloadCommand.ts create mode 100644 src/config/example-options.ts create mode 100644 src/inhibitors/blacklist/BlacklistInhibitor.ts create mode 100644 src/lib/extensions/BotClient.ts create mode 100644 src/lib/extensions/BotCommand.ts create mode 100644 src/lib/extensions/BotGuild.ts create mode 100644 src/lib/extensions/BotInhibitor.ts create mode 100644 src/lib/extensions/BotListener.ts create mode 100644 src/lib/extensions/BotMessage.ts create mode 100644 src/lib/extensions/Util.ts create mode 100644 src/lib/types/BaseModel.ts create mode 100644 src/lib/types/Models.ts create mode 100644 src/lib/utils/TopGG.ts create mode 100644 src/listeners/client/ReadyListener.ts create mode 100644 src/listeners/commands/CommandBlockedListener.ts create mode 100644 src/listeners/guild/Unban.ts create mode 100644 src/tasks.ts create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 0000000..e76a579 --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,34 @@ +name: Node.js CI + +on: + push: + branches: [v2] + pull_request: + branches: [v2] + + workflow_dispatch: + +jobs: + Test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x, 15.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install dependencies + run: yarn + - name: Fix config + run: cp src/config/example-options.ts src/config/options.ts + - name: ESLint + run: yarn lint + - name: Test Build + run: yarn build + - name: Test formatting + run: yarn prettier --check . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf44ab9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,252 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/node,yarn,vscode,webstorm +# Edit at https://www.toptal.com/developers/gitignore?templates=node,yarn,vscode,webstorm + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env*.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +### vscode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +### WebStorm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### WebStorm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +### yarn ### +# https://yarnpkg.com/advanced/qa#which-files-should-be-gitignored + +.yarn/* +!.yarn/releases +!.yarn/plugins +!.yarn/sdks +!.yarn/versions + +# if you are NOT using Zero-installs, then: +# comment the following lines +!.yarn/cache + +# and uncomment the following lines +# .pnp.* + +# End of https://www.toptal.com/developers/gitignore/api/node,yarn,vscode,webstorm + +# Options and credentials for the bot +src/config/options.ts + +# Unused sqlite database, uses postgresql now but I am keeping it in here just in case +data.db \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2b6411c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +dist +.git \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..24dee80 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-node", + "request": "launch", + "name": "Launch Program", + "skipFiles": ["/**"], + "program": "${workspaceFolder}\\src\\bot.ts", + "preLaunchTask": "tsc: build - tsconfig.json", + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "args": ["-r source-map-support/register"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c0a5640 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "node_modules": true + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3c4ac48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Tyman-productions + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..92074ce --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +

+ +
+ Utilibot discord bot +

+ +
+ + + +[![discord badge](https://img.shields.io/badge/Join%20the-Discord-blue?style=for-the-badge)](https://discord.gg/2pf4xfG) +[![uses badges](https://img.shields.io/badge/Uses-Badges-yellow?style=for-the-badge)](https://shields.io) +[![made with typescript](https://img.shields.io/badge/Made%20With-Typescript-orange?style=for-the-badge)](https://www.typescriptlang.org/) + +
+ +Utilibot is a discord bot meant to automate tasks in your discord server, and also have other assorted fun things. + +If you would like to set up for yourself, please see [SETUP.md](https://github.com/TymanWasTaken/Utilibot/blob/v2/SETUP.md) + +

Contributing

+ +You are free to report bugs or contribute to this project. Just open Issues or Pull Requests and the Developer team will look into them. + +

Credits

+ +- discord.js - The main library used to interface with discord +- discord-akairo - The framework the bot is built on diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..d4cabb1 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,16 @@ +# How to set up + +## Pre requisites + +1. Git +2. A discord bot on the dev portal +3. NodeJS +4. Yarn (npm also works but I strongly encourage to use yarn instead) + +## Main setup + +1. Clone this repository +2. Install all dependencies with `yarn` +3. Set up config by creating `src/config/options.ts` (Use `src/config/example-options.ts` as a guide) and adding all the options +4. Optional: Make sure everything is set with `yarn test` +5. Start the bot with `yarn start` diff --git a/package.json b/package.json new file mode 100644 index 0000000..e8b966b --- /dev/null +++ b/package.json @@ -0,0 +1,73 @@ +{ + "name": "utilibot", + "version": "2.0.0", + "description": "A utility bot for discord", + "main": "dist/bot.js", + "repository": "https://github.com/tyman-productions/utilibot", + "author": "TymanWasTaken ", + "license": "MIT", + "scripts": { + "start": "yarn build && node --trace-warnings -r source-map-support/register dist/bot.js", + "build": "yarn rimraf dist/ && yarn tsc", + "test": "yarn lint && yarn build", + "lint": "yarn eslint .", + "format": "yarn prettier --write ." + }, + "devDependencies": { + "@types/common-tags": "^1.8.0", + "@types/express": "^4.17.11", + "@types/node": "^14.14.22", + "@types/uuid": "^8.3.0", + "@typescript-eslint/eslint-plugin": "^4.14.1", + "@typescript-eslint/parser": "^4.14.1", + "eslint": "^7.18.0", + "eslint-config-prettier": "^8.1.0", + "prettier": "^2.2.1", + "rimraf": "^3.0.2", + "source-map-support": "^0.5.19", + "typescript": "^4.1.3" + }, + "dependencies": { + "@top-gg/sdk": "^3.0.9", + "body-parser": "^1.19.0", + "common-tags": "^1.8.0", + "discord-akairo": "^8.1.0", + "discord.js": "^12.5.1", + "express": "^4.17.1", + "got": "^11.8.1", + "moment": "^2.29.1", + "pg": "^8.5.1", + "pg-hstore": "^2.3.3", + "sequelize": "^6.5.0", + "uuid": "^8.3.2" + }, + "eslintConfig": { + "env": { + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint" + ], + "ignorePatterns": [ + "dist", + "node_modules" + ] + }, + "prettier": { + "useTabs": true, + "quoteProps": "consistent", + "singleQuote": true, + "trailingComma": "none" + } +} diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000..3d427e9 --- /dev/null +++ b/src/bot.ts @@ -0,0 +1,7 @@ +import { BotClient } from './lib/extensions/BotClient'; +import * as config from './config/options'; + +const client: BotClient = new BotClient(config); +client.start(); + +// πŸ¦€ diff --git a/src/commands/admin/PrefixCommand.ts b/src/commands/admin/PrefixCommand.ts new file mode 100644 index 0000000..8fb50f8 --- /dev/null +++ b/src/commands/admin/PrefixCommand.ts @@ -0,0 +1,30 @@ +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { BotMessage } from '../../lib/extensions/BotMessage'; + +export default class PrefixCommand extends BotCommand { + constructor() { + super('prefix', { + aliases: ['prefix'], + args: [ + { + id: 'prefix' + } + ], + userPermissions: ['MANAGE_GUILD'] + }); + } + async exec( + message: BotMessage, + { prefix }: { prefix?: string } + ): Promise { + if (prefix) { + await message.settings.setPrefix(prefix); + await message.util.send(`Sucessfully set prefix to \`${prefix}\``); + } else { + await message.settings.setPrefix(this.client.config.prefix); + await message.util.send( + `Sucessfully reset prefix to \`${this.client.config.prefix}\`` + ); + } + } +} diff --git a/src/commands/info/BotInfoCommand.ts b/src/commands/info/BotInfoCommand.ts new file mode 100644 index 0000000..27e14c4 --- /dev/null +++ b/src/commands/info/BotInfoCommand.ts @@ -0,0 +1,58 @@ +import { MessageEmbed } from 'discord.js'; +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { duration } from 'moment'; +import { BotMessage } from '../../lib/extensions/BotMessage'; + +export default class BotInfoCommand extends BotCommand { + constructor() { + super('botinfo', { + aliases: ['botinfo'], + description: { + content: 'Shows information about the bot', + usage: 'botinfo', + examples: ['botinfo'] + } + }); + } + + public async exec(message: BotMessage): Promise { + const owners = (await this.client.util.mapIDs(this.client.ownerID)) + .map((u) => u.tag) + .join('\n'); + const currentCommit = ( + await this.client.util.shell('git rev-parse HEAD') + ).stdout.replace('\n', ''); + const repoUrl = ( + await this.client.util.shell('git remote get-url origin') + ).stdout.replace('\n', ''); + const embed = new MessageEmbed() + .setTitle('Bot Info:') + .addFields([ + { + name: 'Owners', + value: owners, + inline: true + }, + { + name: 'Uptime', + value: this.client.util.capitalize( + duration(this.client.uptime, 'milliseconds').humanize() + ) + }, + { + name: 'User count', + value: this.client.users.cache.size, + inline: true + }, + { + name: 'Current commit', + value: `[${currentCommit.substring( + 0, + 7 + )}](${repoUrl}/commit/${currentCommit})` + } + ]) + .setTimestamp(); + await message.util.send(embed); + } +} diff --git a/src/commands/info/HelpCommand.ts b/src/commands/info/HelpCommand.ts new file mode 100644 index 0000000..4aa45e0 --- /dev/null +++ b/src/commands/info/HelpCommand.ts @@ -0,0 +1,79 @@ +import { Message, MessageEmbed } from 'discord.js'; +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { stripIndent } from 'common-tags'; +import { BotMessage } from '../../lib/extensions/BotMessage'; + +export default class HelpCommand extends BotCommand { + constructor() { + super('help', { + aliases: ['help'], + description: { + content: 'Shows the commands of the bot', + usage: 'help', + examples: ['help'] + }, + clientPermissions: ['EMBED_LINKS'], + args: [ + { + id: 'command', + type: 'commandAlias' + } + ] + }); + } + + public async exec( + message: BotMessage, + { command }: { command: BotCommand } + ): Promise { + const prefix = this.handler.prefix; + if (!command) { + const embed = new MessageEmbed() + .addField( + 'Commands', + stripIndent`A list of available commands. + For additional info on a command, type \`${prefix}help \` + ` + ) + .setFooter( + `For more information about a command use "${this.client.config.prefix}help "` + ) + .setTimestamp(); + for (const category of this.handler.categories.values()) { + embed.addField( + `${category.id.replace(/(\b\w)/gi, (lc): string => + lc.toUpperCase() + )}`, + `${category + .filter((cmd): boolean => cmd.aliases.length > 0) + .map((cmd): string => `\`${cmd.aliases[0]}\``) + .join(' ')}` + ); + } + return message.util.send(embed); + } + + const embed = new MessageEmbed() + .setColor([155, 200, 200]) + .setTitle( + `\`${command.description.usage ? command.description.usage : ''}\`` + ) + .addField( + 'Description', + `${command.description.content ? command.description.content : ''} ${ + command.ownerOnly ? '\n__Owner Only__' : '' + }` + ); + + if (command.aliases.length > 1) + embed.addField('Aliases', `\`${command.aliases.join('` `')}\``, true); + if (command.description.examples && command.description.examples.length) + embed.addField( + 'Examples', + `\`${command.description.examples.join('`\n`')}\``, + true + ); + + return message.util.send(embed); + } +} diff --git a/src/commands/info/PingCommand.ts b/src/commands/info/PingCommand.ts new file mode 100644 index 0000000..5a5b819 --- /dev/null +++ b/src/commands/info/PingCommand.ts @@ -0,0 +1,42 @@ +import { MessageEmbed } from 'discord.js'; +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { BotMessage } from '../../lib/extensions/BotMessage'; + +export default class PingCommand extends BotCommand { + constructor() { + super('ping', { + aliases: ['ping'], + description: { + content: 'Gets the latency of the bot', + usage: 'ping', + examples: ['ping'] + } + }); + } + + public async exec(message: BotMessage): Promise { + const sentMessage = await message.util.send('Pong!'); + const timestamp: number = message.editedTimestamp + ? message.editedTimestamp + : message.createdTimestamp; + const botLatency = `\`\`\`\n ${Math.floor( + sentMessage.createdTimestamp - timestamp + )}ms \`\`\``; + const apiLatency = `\`\`\`\n ${Math.round( + message.client.ws.ping + )}ms \`\`\``; + const embed = new MessageEmbed() + .setTitle('Pong! πŸ“') + .addField('Bot Latency', botLatency, true) + .addField('API Latency', apiLatency, true) + .setFooter( + message.author.username, + message.author.displayAvatarURL({ dynamic: true }) + ) + .setTimestamp(); + await sentMessage.edit({ + content: null, + embed + }); + } +} diff --git a/src/commands/moderation/BanCommand.ts b/src/commands/moderation/BanCommand.ts new file mode 100644 index 0000000..300101b --- /dev/null +++ b/src/commands/moderation/BanCommand.ts @@ -0,0 +1,137 @@ +import { User } from 'discord.js'; +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { BotMessage } from '../../lib/extensions/BotMessage'; +import { Ban, Modlog, ModlogType } from '../../lib/types/Models'; +import moment from 'moment'; + +const durationAliases: Record = { + weeks: ['w', 'weeks', 'week', 'wk', 'wks'], + days: ['d', 'days', 'day'], + hours: ['h', 'hours', 'hour', 'hr', 'hrs'], + minutes: ['m', 'min', 'mins', 'minutes', 'minute'], + months: ['mo', 'month', 'months'] +}; +const durationRegex = /(?:(\d+)(d(?:ays?)?|h(?:ours?|rs?)?|m(?:inutes?|ins?)?|mo(?:nths?)?|w(?:eeks?|ks?)?)(?: |$))/g; + +export default class PrefixCommand extends BotCommand { + constructor() { + super('ban', { + aliases: ['ban'], + args: [ + { + id: 'user', + type: 'user', + prompt: { + start: 'What user would you like to ban?', + retry: 'Invalid response. What user would you like to ban?' + } + }, + { + id: 'reason' + }, + { + id: 'time', + match: 'option', + flag: '--time' + } + ], + clientPermissions: ['BAN_MEMBERS'], + userPermissions: ['BAN_MEMBERS'] + }); + } + async exec( + message: BotMessage, + { user, reason, time }: { user: User; reason?: string; time?: string } + ): Promise { + const duration = moment.duration(); + let modlogEnry: Modlog; + let banEntry: Ban; + const translatedTime: string[] = []; + try { + try { + if (time) { + const parsed = [...time.matchAll(durationRegex)]; + if (parsed.length < 1) { + await message.util.send('Invalid time.'); + return; + } + for (const part of parsed) { + const translated = Object.keys(durationAliases).find((k) => + durationAliases[k].includes(part[2]) + ); + translatedTime.push(part[1] + ' ' + translated); + duration.add( + Number(part[1]), + translated as 'weeks' | 'days' | 'hours' | 'months' | 'minutes' + ); + } + modlogEnry = Modlog.build({ + user: user.id, + guild: message.guild.id, + reason, + type: ModlogType.TEMPBAN, + duration: duration.asMilliseconds(), + moderator: message.author.id + }); + banEntry = Ban.build({ + user: user.id, + guild: message.guild.id, + reason, + expires: new Date(new Date().getTime() + duration.asMilliseconds()), + modlog: modlogEnry.id + }); + } else { + modlogEnry = Modlog.build({ + user: user.id, + guild: message.guild.id, + reason, + type: ModlogType.BAN, + moderator: message.author.id + }); + banEntry = Ban.build({ + user: user.id, + guild: message.guild.id, + reason, + modlog: modlogEnry.id + }); + } + await modlogEnry.save(); + await banEntry.save(); + } catch (e) { + console.error(e); + await message.util.send( + 'Error saving to database. Please report this to a developer.' + ); + return; + } + try { + await user.send( + `You were banned in ${message.guild.name} ${ + translatedTime.length >= 1 + ? `for ${translatedTime.join(', ')}` + : 'permanently' + } with reason \`${reason || 'No reason given'}\`` + ); + } catch (e) { + await message.channel.send('Error sending message to user'); + } + await message.guild.members.ban(user, { + reason: `Banned by ${message.author.tag} with ${ + reason ? `reason ${reason}` : 'no reason' + }` + }); + await message.util.send( + `Banned <@!${user.id}> ${ + translatedTime.length >= 1 + ? `for ${translatedTime.join(', ')}` + : 'permanently' + } with reason \`${reason || 'No reason given'}\`` + ); + } catch { + await message.util.send('Error banning :/'); + await modlogEnry.destroy(); + await banEntry.destroy(); + return; + } + } +} diff --git a/src/commands/moderation/KickCommand.ts b/src/commands/moderation/KickCommand.ts new file mode 100644 index 0000000..0dc4276 --- /dev/null +++ b/src/commands/moderation/KickCommand.ts @@ -0,0 +1,72 @@ +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { BotMessage } from '../../lib/extensions/BotMessage'; +import { Modlog, ModlogType } from '../../lib/types/Models'; +import { GuildMember } from 'discord.js'; + +export default class PrefixCommand extends BotCommand { + constructor() { + super('kick', { + aliases: ['kick'], + args: [ + { + id: 'user', + type: 'member', + prompt: { + start: 'What user would you like to kick?', + retry: 'Invalid response. What user would you like to kick?' + } + }, + { + id: 'reason' + } + ], + clientPermissions: ['KICK_MEMBERS'], + userPermissions: ['KICK_MEMBERS'] + }); + } + async exec( + message: BotMessage, + { user, reason }: { user: GuildMember; reason?: string } + ): Promise { + let modlogEnry: Modlog; + try { + modlogEnry = Modlog.build({ + user: user.id, + guild: message.guild.id, + moderator: message.author.id, + type: ModlogType.KICK, + reason + }); + await modlogEnry.save(); + } catch (e) { + console.error(e); + await message.util.send( + 'Error saving to database. Please report this to a developer.' + ); + return; + } + try { + await user.send( + `You were kicked in ${message.guild.name} with reason \`${ + reason || 'No reason given' + }\`` + ); + } catch (e) { + await message.channel.send('Error sending message to user'); + } + try { + await user.kick( + `Kicked by ${message.author.tag} with ${ + reason ? `reason ${reason}` : 'no reason' + }` + ); + } catch { + await message.util.send('Error kicking :/'); + await modlogEnry.destroy(); + return; + } + await message.util.send( + `Kicked <@!${user.id}> with reason \`${reason || 'No reason given'}\`` + ); + } +} diff --git a/src/commands/moderation/ModlogCommand.ts b/src/commands/moderation/ModlogCommand.ts new file mode 100644 index 0000000..ea35198 --- /dev/null +++ b/src/commands/moderation/ModlogCommand.ts @@ -0,0 +1,143 @@ +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { Message } from 'discord.js'; +import { Modlog } from '../../lib/types/Models'; +import { MessageEmbed } from 'discord.js'; +import moment from 'moment'; +import { stripIndent } from 'common-tags'; +import { Argument } from 'discord-akairo'; + +export default class ModlogCommand extends BotCommand { + constructor() { + super('modlog', { + aliases: ['modlog', 'modlogs'], + args: [ + { + id: 'search', + prompt: { + start: 'What modlog id or user would you like to see?' + } + }, + { + id: 'page', + type: 'number' + } + ], + userPermissions: ['MANAGE_MESSAGES'] + }); + } + *args(): unknown { + const search = yield { + id: 'search', + type: Argument.union('user', 'string'), + prompt: { + start: 'What modlog id or user would you like to see?' + } + }; + if (typeof search === 'string') return { search, page: null }; + else { + const page = yield { + id: 'page', + type: 'number', + prompt: { + start: 'What page?', + retry: 'Not a number. What page?', + optional: true + } + }; + return { search, page }; + } + } + async exec( + message: Message, + { search, page }: { search: string; page: number } + ): Promise { + const foundUser = await this.client.util.resolveUserAsync(search); + if (foundUser) { + const logs = await Modlog.findAll({ + where: { + guild: message.guild.id, + user: foundUser.id + }, + order: [['createdAt', 'ASC']] + }); + const niceLogs: string[] = []; + for (const log of logs) { + niceLogs.push(stripIndent` + ID: ${log.id} + Type: ${log.type.toLowerCase()} + User: <@!${log.user}> (${log.user}) + Moderator: <@!${log.moderator}> (${log.moderator}) + Duration: ${ + log.duration + ? moment.duration(log.duration, 'milliseconds').humanize() + : 'N/A' + } + Reason: ${log.reason || 'None given'} + ${this.client.util.ordinal(logs.indexOf(log) + 1)} action + `); + } + const chunked: string[][] = this.client.util.chunk(niceLogs, 3); + const embedPages = chunked.map( + (e, i) => + new MessageEmbed({ + title: `Modlogs page ${i + 1}`, + description: e.join( + '\n-------------------------------------------------------\n' + ), + footer: { + text: `Page ${i + 1}/${chunked.length}` + } + }) + ); + if (page) { + await message.util.send(embedPages[page - 1]); + return; + } else { + await message.util.send(embedPages[0]); + return; + } + } else if (search) { + const entry = await Modlog.findByPk(search); + if (!entry) { + await message.util.send('That modlog does not exist.'); + return; + } + await message.util.send( + new MessageEmbed({ + title: `Modlog ${entry.id}`, + fields: [ + { + name: 'Type', + value: entry.type.toLowerCase(), + inline: true + }, + { + name: 'Duration', + value: `${ + entry.duration + ? moment.duration(entry.duration, 'milliseconds').humanize() + : 'N/A' + }`, + inline: true + }, + { + name: 'Reason', + value: `${entry.reason || 'None given'}`, + inline: true + }, + { + name: 'Moderator', + value: `<@!${entry.moderator}> (${entry.moderator})`, + inline: true + }, + { + name: 'User', + value: `<@!${entry.user}> (${entry.user})`, + inline: true + } + ] + }) + ); + } + } +} diff --git a/src/commands/moderation/WarnCommand.ts b/src/commands/moderation/WarnCommand.ts new file mode 100644 index 0000000..676615d --- /dev/null +++ b/src/commands/moderation/WarnCommand.ts @@ -0,0 +1,54 @@ +import { GuildMember } from 'discord.js'; +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { BotMessage } from '../../lib/extensions/BotMessage'; +import { Modlog, ModlogType } from '../../lib/types/Models'; + +export default class WarnCommand extends BotCommand { + public constructor() { + super('warn', { + aliases: ['warn'], + userPermissions: ['MANAGE_MESSAGES'], + args: [ + { + id: 'member', + type: 'member' + }, + { + id: 'reason', + match: 'rest' + } + ] + }); + } + public async exec( + message: BotMessage, + { member, reason }: { member: GuildMember; reason: string } + ): Promise { + try { + const entry = Modlog.build({ + user: member.id, + guild: message.guild.id, + moderator: message.author.id, + type: ModlogType.WARN, + reason + }); + await entry.save(); + } catch (e) { + await message.util.send( + 'Error saving to database, please contact the developers' + ); + return; + } + try { + await member.send( + `You were warned in ${message.guild.name} for reason "${reason}".` + ); + } catch (e) { + await message.util.send('Error messaging user, warning still saved.'); + return; + } + await message.util.send( + `${member.user.tag} was warned for reason "${reason}".` + ); + } +} diff --git a/src/commands/owner/EvalCommand.ts b/src/commands/owner/EvalCommand.ts new file mode 100644 index 0000000..f1ada89 --- /dev/null +++ b/src/commands/owner/EvalCommand.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { MessageEmbed, Message } from 'discord.js'; +import { inspect, promisify } from 'util'; +import { exec } from 'child_process'; +import { BotMessage } from '../../lib/extensions/BotMessage'; + +const clean = (text) => { + if (typeof text === 'string') + return text + .replace(/`/g, '`' + String.fromCharCode(8203)) + .replace(/@/g, '@' + String.fromCharCode(8203)); + else return text; +}; + +export default class EvalCommand extends BotCommand { + public constructor() { + super('eval', { + aliases: ['eval', 'ev'], + category: 'dev', + description: { + content: 'Use the command to eval stuff in the bot.', + usage: 'eval [--depth #] [--sudo] [--silent] [--delete]', + examples: ['eval message.guild.name', 'eval this.client.ownerID'] + }, + args: [ + { + id: 'depth', + match: 'option', + type: 'number', + flag: '--depth', + default: 0 + }, + { + id: 'silent', + match: 'flag', + flag: '--silent' + }, + { + id: 'code', + match: 'rest', + type: 'string', + prompt: { + start: 'What would you like to eval?', + retry: 'Invalid code to eval. What would you like to eval?' + } + } + ], + ownerOnly: true, + clientPermissions: ['EMBED_LINKS'] + }); + } + + public async exec( + message: BotMessage, + { depth, code, silent }: { depth: number; code: string; silent: boolean } + ): Promise { + const embed: MessageEmbed = new MessageEmbed(); + + try { + let output; + const me = message.member, + member = message.member, + bot = this.client, + guild = message.guild, + channel = message.channel, + config = this.client.config, + sh = promisify(exec), + models = this.client.db.models, + got = await import('got'); + output = eval(code); + output = await output; + if (typeof output !== 'string') output = inspect(output, { depth }); + output = output.replace( + new RegExp(this.client.token, 'g'), + '[token omitted]' + ); + output = clean(output); + embed + .setTitle('βœ… Evaled code successfully') + .addField( + 'πŸ“₯ Input', + code.length > 1012 + ? 'Too large to display. Hastebin: ' + + (await this.client.util.haste(code)) + : '```js\n' + code + '```' + ) + .addField( + 'πŸ“€ Output', + output.length > 1012 + ? 'Too large to display. Hastebin: ' + + (await this.client.util.haste(output)) + : '```js\n' + output + '```' + ) + .setColor('#66FF00') + .setFooter( + message.author.username, + message.author.displayAvatarURL({ dynamic: true }) + ) + .setTimestamp(); + } catch (e) { + embed + .setTitle('❌ Code was not able to be evaled') + .addField( + 'πŸ“₯ Input', + code.length > 1012 + ? 'Too large to display. Hastebin: ' + + (await this.client.util.haste(code)) + : '```js\n' + code + '```' + ) + .addField( + 'πŸ“€ Output', + e.length > 1012 + ? 'Too large to display. Hastebin: ' + + (await this.client.util.haste(e)) + : '```js\n' + + e + + '```Full stack:' + + (await this.client.util.haste(e.stack)) + ) + .setColor('#FF0000') + .setFooter( + message.author.username, + message.author.displayAvatarURL({ dynamic: true }) + ) + .setTimestamp(); + } + if (!silent) { + await message.util.send(embed); + } else { + try { + await message.author.send(embed); + await message.react(''); + } catch (e) { + await message.react('❌'); + } + } + } +} diff --git a/src/commands/owner/ReloadCommand.ts b/src/commands/owner/ReloadCommand.ts new file mode 100644 index 0000000..2311424 --- /dev/null +++ b/src/commands/owner/ReloadCommand.ts @@ -0,0 +1,34 @@ +import { BotCommand } from '../../lib/extensions/BotCommand'; +import { stripIndent } from 'common-tags'; +import { BotMessage } from '../../lib/extensions/BotMessage'; + +export default class ReloadCommand extends BotCommand { + constructor() { + super('reload', { + aliases: ['reload'], + description: { + content: 'Reloads the bot', + usage: 'reload', + examples: ['reload'] + }, + ownerOnly: true, + typing: true + }); + } + + public async exec(message: BotMessage): Promise { + try { + await this.client.util.shell('yarn rimraf dist/'); + await this.client.util.shell('yarn tsc'); + this.client.commandHandler.reloadAll(); + this.client.listenerHandler.reloadAll(); + this.client.inhibitorHandler.reloadAll(); + await message.util.send('πŸ” Successfully reloaded!'); + } catch (e) { + await message.util.send(stripIndent` + An error occured while reloading: + ${await this.client.util.haste(e.stack)} + `); + } + } +} diff --git a/src/config/example-options.ts b/src/config/example-options.ts new file mode 100644 index 0000000..ce204e9 --- /dev/null +++ b/src/config/example-options.ts @@ -0,0 +1,30 @@ +// Credentials +export const credentials = { + botToken: 'token here', + dblToken: 'token here', + dblWebhookAuth: 'auth here' +}; + +// Options +export const owners = [ + '487443883127472129', // Tyman#7318 + '642416218967375882' // πŸ’œClari#7744 +]; +export const prefix = 'u2!' as string; +export const dev = true as boolean; +export const channels = { + dblVote: 'id here', + log: 'id here', + error: 'id here', + dm: 'id here', + command: 'id here' +}; +export const topGGPort = 3849; + +// Database specific +export const db = { + host: 'localhost', + port: 5432, + username: 'username here', + password: 'password here' +}; diff --git a/src/inhibitors/blacklist/BlacklistInhibitor.ts b/src/inhibitors/blacklist/BlacklistInhibitor.ts new file mode 100644 index 0000000..82db4c2 --- /dev/null +++ b/src/inhibitors/blacklist/BlacklistInhibitor.ts @@ -0,0 +1,14 @@ +import { BotInhibitor } from '../../lib/extensions/BotInhibitor'; + +export default class BlacklistInhibitor extends BotInhibitor { + constructor() { + super('blacklist', { + reason: 'blacklist' + }); + } + + public exec(): boolean | Promise { + // This is just a placeholder for now + return false; + } +} diff --git a/src/lib/extensions/BotClient.ts b/src/lib/extensions/BotClient.ts new file mode 100644 index 0000000..4d1c31a --- /dev/null +++ b/src/lib/extensions/BotClient.ts @@ -0,0 +1,274 @@ +import { + AkairoClient, + CommandHandler, + InhibitorHandler, + ListenerHandler +} from 'discord-akairo'; +import { Guild } from 'discord.js'; +import * as path from 'path'; +import { DataTypes, Model, Sequelize } from 'sequelize'; +import * as Models from '../types/Models'; +import { BotGuild } from './BotGuild'; +import { BotMessage } from './BotMessage'; +import { Util } from './Util'; +import * as Tasks from '../../tasks'; +import { v4 as uuidv4 } from 'uuid'; +import { exit } from 'process'; +import { TopGGHandler } from '../utils/TopGG'; + +export interface BotConfig { + credentials: { + botToken: string; + dblToken: string; + dblWebhookAuth: string; + }; + owners: string[]; + prefix: string; + dev: boolean; + db: { + username: string; + password: string; + host: string; + port: number; + }; + topGGPort: number; + channels: { + dblVote: string; + log: string; + error: string; + dm: string; + command: string; + }; +} + +export class BotClient extends AkairoClient { + public config: BotConfig; + public listenerHandler: ListenerHandler; + public inhibitorHandler: InhibitorHandler; + public commandHandler: CommandHandler; + public topGGHandler: TopGGHandler; + public util: Util; + public ownerID: string[]; + public db: Sequelize; + constructor(config: BotConfig) { + super( + { + ownerID: config.owners + }, + { + allowedMentions: { parse: ['users'] } // No everyone or role mentions by default + } + ); + + // Set token + this.token = config.credentials.botToken; + + // Set config + this.config = config; + + // Create listener handler + this.listenerHandler = new ListenerHandler(this, { + directory: path.join(__dirname, '..', '..', 'listeners'), + automateCategories: true + }); + + // Create inhibitor handler + this.inhibitorHandler = new InhibitorHandler(this, { + directory: path.join(__dirname, '..', '..', 'inhibitors'), + automateCategories: true + }); + + // Create command handler + this.commandHandler = new CommandHandler(this, { + directory: path.join(__dirname, '..', '..', 'commands'), + prefix: async ({ guild }: { guild: Guild }) => { + const row = await Models.Guild.findByPk(guild.id); + if (!row) return this.config.prefix; + return row.prefix as string; + }, + allowMention: true, + handleEdits: true, + commandUtil: true, + commandUtilLifetime: 3e5, + argumentDefaults: { + prompt: { + timeout: 'Timed out.', + ended: 'Too many tries.', + cancel: 'Canceled.', + time: 3e4 + } + }, + ignorePermissions: this.config.owners, + ignoreCooldown: this.config.owners, + automateCategories: true + }); + + this.util = new Util(this); + this.db = new Sequelize( + this.config.dev ? 'utilibot-dev' : 'utilibot', + this.config.db.username, + this.config.db.password, + { + dialect: 'postgres', + host: this.config.db.host, + port: this.config.db.port, + logging: false + } + ); + this.topGGHandler = new TopGGHandler(this); + BotGuild.install(); + BotMessage.install(); + } + + // Initialize everything + private async _init(): Promise { + this.commandHandler.useListenerHandler(this.listenerHandler); + this.commandHandler.useInhibitorHandler(this.inhibitorHandler); + this.listenerHandler.setEmitters({ + commandHandler: this.commandHandler, + listenerHandler: this.listenerHandler, + process + }); + // loads all the handlers + const loaders = { + commands: this.commandHandler, + listeners: this.listenerHandler, + inhibitors: this.inhibitorHandler + }; + for (const loader of Object.keys(loaders)) { + try { + loaders[loader].loadAll(); + console.log('Successfully loaded ' + loader + '.'); + } catch (e) { + console.error('Unable to load loader ' + loader + ' with error ' + e); + } + } + await this.dbPreInit(); + Object.keys(Tasks).forEach((t) => { + setInterval(() => Tasks[t](this), 60000); + }); + this.topGGHandler.init(); + } + + public async dbPreInit(): Promise { + await this.db.authenticate(); + Models.Guild.init( + { + id: { + type: DataTypes.STRING, + primaryKey: true + }, + prefix: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: this.config.prefix + } + }, + { sequelize: this.db } + ); + Models.Modlog.init( + { + id: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + defaultValue: uuidv4 + }, + type: { + type: new DataTypes.ENUM( + 'BAN', + 'TEMPBAN', + 'MUTE', + 'TEMPMUTE', + 'KICK', + 'WARN' + ), + allowNull: false + }, + user: { + type: DataTypes.STRING, + allowNull: false + }, + moderator: { + type: DataTypes.STRING, + allowNull: false + }, + duration: { + type: DataTypes.STRING, + allowNull: true + }, + reason: { + type: DataTypes.STRING, + allowNull: true + }, + guild: { + type: DataTypes.STRING, + references: { + model: Models.Guild as typeof Model + } + } + }, + { sequelize: this.db } + ); + Models.Ban.init( + { + id: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + defaultValue: uuidv4 + }, + user: { + type: DataTypes.STRING, + allowNull: false + }, + guild: { + type: DataTypes.STRING, + allowNull: false, + references: { + model: Models.Guild as typeof Model, + key: 'id' + } + }, + expires: { + type: DataTypes.DATE, + allowNull: true + }, + reason: { + type: DataTypes.STRING, + allowNull: true + }, + modlog: { + type: DataTypes.STRING, + allowNull: false, + references: { + model: Models.Modlog as typeof Model + } + } + }, + { sequelize: this.db } + ); + try { + await this.db.sync({ alter: true }); // Sync all tables to fix everything if updated + } catch { + // Ignore error + } + } + + public async start(): Promise { + try { + await this._init(); + await this.login(this.token); + } catch (e) { + console.error(e.stack); + exit(2); + } + } + + public destroy(relogin = true): void | Promise { + super.destroy(); + if (relogin) { + return this.login(this.token); + } + } +} diff --git a/src/lib/extensions/BotCommand.ts b/src/lib/extensions/BotCommand.ts new file mode 100644 index 0000000..4f62714 --- /dev/null +++ b/src/lib/extensions/BotCommand.ts @@ -0,0 +1,6 @@ +import { Command } from 'discord-akairo'; +import { BotClient } from './BotClient'; + +export class BotCommand extends Command { + public client: BotClient; +} diff --git a/src/lib/extensions/BotGuild.ts b/src/lib/extensions/BotGuild.ts new file mode 100644 index 0000000..22d7834 --- /dev/null +++ b/src/lib/extensions/BotGuild.ts @@ -0,0 +1,38 @@ +import { Guild, Structures } from 'discord.js'; +import { BotClient } from './BotClient'; +import { Guild as GuildModel } from '../types/Models'; + +export class GuildSettings { + private guild: BotGuild; + constructor(guild: BotGuild) { + this.guild = guild; + } + public async getPrefix(): Promise { + return await GuildModel.findByPk(this.guild.id).then( + (gm) => gm?.prefix || this.guild.client.config.prefix + ); + } + public async setPrefix(value: string): Promise { + let entry = await GuildModel.findByPk(this.guild.id); + if (!entry) { + entry = GuildModel.build({ + id: this.guild.id, + prefix: value + }); + } else { + entry.prefix = value; + } + await entry.save(); + } +} + +export class BotGuild extends Guild { + constructor(client: BotClient, data: Record) { + super(client, data); + } + static install(): void { + Structures.extend('Guild', () => BotGuild); + } + public settings = new GuildSettings(this); + public client: BotClient; +} diff --git a/src/lib/extensions/BotInhibitor.ts b/src/lib/extensions/BotInhibitor.ts new file mode 100644 index 0000000..960aade --- /dev/null +++ b/src/lib/extensions/BotInhibitor.ts @@ -0,0 +1,6 @@ +import { Inhibitor } from 'discord-akairo'; +import { BotClient } from './BotClient'; + +export class BotInhibitor extends Inhibitor { + public client: BotClient; +} diff --git a/src/lib/extensions/BotListener.ts b/src/lib/extensions/BotListener.ts new file mode 100644 index 0000000..9ec17b2 --- /dev/null +++ b/src/lib/extensions/BotListener.ts @@ -0,0 +1,6 @@ +import { Listener } from 'discord-akairo'; +import { BotClient } from './BotClient'; + +export class BotListener extends Listener { + public client: BotClient; +} diff --git a/src/lib/extensions/BotMessage.ts b/src/lib/extensions/BotMessage.ts new file mode 100644 index 0000000..85c2721 --- /dev/null +++ b/src/lib/extensions/BotMessage.ts @@ -0,0 +1,50 @@ +import { + TextChannel, + NewsChannel, + DMChannel, + Message, + Structures +} from 'discord.js'; +import { BotClient } from './BotClient'; +import { Guild as GuildModel } from '../types/Models'; +import { BotGuild } from './BotGuild'; + +export class GuildSettings { + private message: BotMessage; + constructor(message: BotMessage) { + this.message = message; + } + public async getPrefix(): Promise { + return await GuildModel.findByPk(this.message.guild.id).then( + (gm) => gm?.prefix || this.message.client.config.prefix + ); + } + public async setPrefix(value: string): Promise { + let entry = await GuildModel.findByPk(this.message.guild.id); + if (!entry) { + entry = GuildModel.build({ + id: this.message.guild.id, + prefix: value + }); + } else { + entry.prefix = value; + } + await entry.save(); + } +} + +export class BotMessage extends Message { + constructor( + client: BotClient, + data: Record, + channel: TextChannel | DMChannel | NewsChannel + ) { + super(client, data, channel); + } + public guild: BotGuild; + public client: BotClient; + static install(): void { + Structures.extend('Message', () => BotMessage); + } + public settings = new GuildSettings(this); +} diff --git a/src/lib/extensions/Util.ts b/src/lib/extensions/Util.ts new file mode 100644 index 0000000..20bfd48 --- /dev/null +++ b/src/lib/extensions/Util.ts @@ -0,0 +1,196 @@ +import { ClientUtil } from 'discord-akairo'; +import { BotClient } from './BotClient'; +import { User } from 'discord.js'; +import { promisify } from 'util'; +import { exec } from 'child_process'; +import got from 'got'; +import { TextChannel } from 'discord.js'; + +interface hastebinRes { + key: string; +} + +export class Util extends ClientUtil { + /** + * The client of this ClientUtil + * @type {BotClient} + */ + public client: BotClient; + /** + * The hastebin urls used to post to hastebin, attempts to post in order + * @type {string[]} + */ + public hasteURLs = [ + 'https://hst.sh', + 'https://hasteb.in', + 'https://hastebin.com', + 'https://mystb.in', + 'https://haste.clicksminuteper.net', + 'https://paste.pythondiscord.com', + 'https://haste.unbelievaboat.com', + 'https://haste.tyman.tech' + ]; + /** + * A simple promise exec method + */ + private exec = promisify(exec); + + /** + * Creates this client util + * @param client The client to initialize with + */ + constructor(client: BotClient) { + super(client); + } + + /** + * Maps an array of user ids to user objects. + * @param ids The list of IDs to map + * @returns The list of users mapped + */ + public async mapIDs(ids: string[]): Promise { + return await Promise.all(ids.map((id) => this.client.users.fetch(id))); + } + + /** + * Capitalizes the first letter of the given text + * @param text The text to capitalize + * @returns The capitalized text + */ + public capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); + } + + /** + * Runs a shell command and gives the output + * @param command The shell command to run + * @returns The stdout and stderr of the shell command + */ + public async shell( + command: string + ): Promise<{ + stdout: string; + stderr: string; + }> { + return await this.exec(command); + } + + /** + * Posts text to hastebin + * @param content The text to post + * @returns The url of the posted text + */ + public async haste(content: string): Promise { + for (const url of this.hasteURLs) { + try { + const res: hastebinRes = await got + .post(`${url}/documents`, { body: content }) + .json(); + return `${url}/${res.key}`; + } catch (e) { + // pass + } + } + throw new Error('No urls worked. (wtf)'); + } + + /** + * Logs something but only in dev mode + * @param content The thing to log + */ + public devLog(content: unknown): void { + if (this.client.config.dev) console.log(content); + } + + /** + * Resolves a user-provided string into a user object, if possible + * @param text The text to try and resolve + * @returns The user resolved or null + */ + public async resolveUserAsync(text: string): Promise { + const idReg = /\d{17,19}/; + const idMatch = text.match(idReg); + if (idMatch) { + try { + const user = await this.client.users.fetch(text); + return user; + } catch { + // pass + } + } + const mentionReg = /<@!?(?\d{17,19})>/; + const mentionMatch = text.match(mentionReg); + if (mentionMatch) { + try { + const user = await this.client.users.fetch(mentionMatch.groups.id); + return user; + } catch { + // pass + } + } + const user = this.client.users.cache.find((u) => u.username === text); + if (user) return user; + return null; + } + + /** + * Appends the correct ordinal to the given number + * @param n The number to append an ordinal to + * @returns The number with the ordinal + */ + public ordinal(n: number): string { + const s = ['th', 'st', 'nd', 'rd'], + v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); + } + + /** + * Chunks an array to the specified size + * @param arr The array to chunk + * @param perChunk The amount of items per chunk + * @returns The chunked array + */ + public chunk(arr: T[], perChunk: number): T[][] { + return arr.reduce((all, one, i) => { + const ch = Math.floor(i / perChunk); + all[ch] = [].concat(all[ch] || [], one); + return all; + }, []); + } + + /** + * Logs a message to console and log channel as info + * @param message The message to send + */ + public async info(message: string): Promise { + console.log(`INFO: ${message}`); + const channel = (await this.client.channels.fetch( + this.client.config.channels.log + )) as TextChannel; + await channel.send(`INFO: ${message}`); + } + + /** + * Logs a message to console and log channel as a warning + * @param message The message to send + */ + public async warn(message: string): Promise { + console.warn(`WARN: ${message}`); + const channel = (await this.client.channels.fetch( + this.client.config.channels.log + )) as TextChannel; + await channel.send(`WARN: ${message}`); + } + + /** + * Logs a message to console and log channel as an error + * @param message The message to send + */ + public async error(message: string): Promise { + console.error(`ERROR: ${message}`); + const channel = (await this.client.channels.fetch( + this.client.config.channels.error + )) as TextChannel; + await channel.send(`ERROR: ${message}`); + } +} diff --git a/src/lib/types/BaseModel.ts b/src/lib/types/BaseModel.ts new file mode 100644 index 0000000..fdbd706 --- /dev/null +++ b/src/lib/types/BaseModel.ts @@ -0,0 +1,6 @@ +import { Model } from 'sequelize'; + +export abstract class BaseModel extends Model { + public readonly createdAt: Date; + public readonly updatedAt: Date; +} diff --git a/src/lib/types/Models.ts b/src/lib/types/Models.ts new file mode 100644 index 0000000..6ea890e --- /dev/null +++ b/src/lib/types/Models.ts @@ -0,0 +1,102 @@ +import { Optional } from 'sequelize'; +import { BaseModel } from './BaseModel'; + +export interface GuildModel { + id: string; + prefix: string; +} +export type GuildModelCreationAttributes = Optional; + +export class Guild + extends BaseModel + implements GuildModel { + id: string; + prefix: string; +} + +export interface BanModel { + id: string; + user: string; + guild: string; + reason: string; + expires: Date; + modlog: string; +} +export interface BanModelCreationAttributes { + id?: string; + user: string; + guild: string; + reason?: string; + expires?: Date; + modlog: string; +} + +export class Ban + extends BaseModel + implements BanModel { + /** + * The ID of this ban (no real use just for a primary key) + */ + id: string; + /** + * The user who is banned + */ + user: string; + /** + * The guild they are banned from + */ + guild: string; + /** + * The reason they are banned (optional) + */ + reason: string | null; + /** + * The date at which this ban expires and should be unbanned (optional) + */ + expires: Date | null; + /** + * The ref to the modlog entry + */ + modlog: string; +} + +export enum ModlogType { + BAN = 'BAN', + TEMPBAN = 'TEMPBAN', + KICK = 'KICK', + MUTE = 'MUTE', + TEMPMUTE = 'TEMPMUTE', + WARN = 'WARN' +} + +export interface ModlogModel { + id: string; + type: ModlogType; + user: string; + moderator: string; + reason: string; + duration: number; + guild: string; +} + +export interface ModlogModelCreationAttributes { + id?: string; + type: ModlogType; + user: string; + moderator: string; + reason?: string; + duration?: number; + guild: string; +} + +export class Modlog + extends BaseModel + implements ModlogModel { + id: string; + type: ModlogType; + user: string; + moderator: string; + guild: string; + reason: string | null; + duration: number | null; +} diff --git a/src/lib/utils/TopGG.ts b/src/lib/utils/TopGG.ts new file mode 100644 index 0000000..9c06816 --- /dev/null +++ b/src/lib/utils/TopGG.ts @@ -0,0 +1,110 @@ +import { Api } from '@top-gg/sdk'; +import { BotStats, WebhookPayload } from '@top-gg/sdk/dist/typings'; +import { BotClient } from '../extensions/BotClient'; +import { topGGPort, credentials, channels } from '../../config/options'; +import express, { Express } from 'express'; +import { TextChannel, MessageEmbed, WebhookClient } from 'discord.js'; +import { stripIndent } from 'common-tags'; +import { + json as bodyParserJSON, + urlencoded as bodyParserUrlEncoded +} from 'body-parser'; + +export class TopGGHandler { + public api = new Api(credentials.dblToken); + public client: BotClient; + public server: Express = express(); + public constructor(client: BotClient) { + this.client = client; + } + public init(): void { + setInterval(this.postGuilds.bind(this), 60000); + this.server.use(bodyParserJSON()); + this.server.use(bodyParserUrlEncoded({ extended: true })); + this.server.post('/dblwebhook', async (req, res) => { + if (req.headers.authorization !== credentials.dblWebhookAuth) { + res.status(403).send('Unauthorized'); + await this.client.util.warn( + `Unauthorized DBL webhook request πŸ‘€ ${await this.client.util.haste( + JSON.stringify( + { + 'Correct Auth': credentials.dblWebhookAuth, + 'Given Auth': req.headers.authorization, + 'Headers': req.headers, + 'Body': req.body + }, + null, + '\t' + ) + )}` + ); + return; + } else { + res.status(200).send('OK'); + } + const data = req.body as WebhookPayload; + await this.postVoteWebhook(data); + }); + this.server.listen(topGGPort, () => { + console.log(`Started express top.gg server at port ${topGGPort}`); + }); + } + public async postGuilds(): Promise { + if (this.client.config.dev) return; + return await this.api.postStats({ + serverCount: this.client.guilds.cache.size, + shardCount: this.client.shard ? this.client.shard.count : 1 + }); + } + public async postVoteWebhook(data: WebhookPayload): Promise { + try { + if (data.type === 'test') { + await this.client.util.info( + `Test vote webhook data recieved, ${await this.client.util.haste( + JSON.stringify(data, null, '\t') + )}` + ); + return; + } else { + const parsedData = { + user: await this.client.users.fetch(data.user), + type: data.type as 'upvote' | 'test', + isWeekend: data.isWeekend + }; + const channel = (await this.client.channels.fetch( + channels.dblVote + )) as TextChannel; + const webhooks = await channel.fetchWebhooks(); + const webhook = + webhooks.size < 1 + ? await channel.createWebhook('Utilibot Voting') + : webhooks.first(); + const webhookClient = new WebhookClient(webhook.id, webhook.token, { + allowedMentions: { parse: [] } + }); + await webhookClient.send(undefined, { + username: 'Utilibot Voting', + avatarURL: this.client.user.avatarURL({ dynamic: true }), + embeds: [ + new MessageEmbed() + .setTitle('Top.GG Vote') + // prettier-ignore + .setDescription( + stripIndent` + User: ${parsedData.user.tag} + Weekend (worth double): ${parsedData.isWeekend ? 'Yes' : 'No'} + ` + ) + .setAuthor( + parsedData.user.tag, + parsedData.user.avatarURL({ dynamic: true }) + ) + .setTimestamp() + ] + }); + } + } catch (e) { + console.error(e); + } + } +} diff --git a/src/listeners/client/ReadyListener.ts b/src/listeners/client/ReadyListener.ts new file mode 100644 index 0000000..ae510f6 --- /dev/null +++ b/src/listeners/client/ReadyListener.ts @@ -0,0 +1,16 @@ +import { BotListener } from '../../lib/extensions/BotListener'; + +export default class CommandBlockedListener extends BotListener { + public constructor() { + super('ready', { + emitter: 'client', + event: 'ready' + }); + } + + public async exec(): Promise { + await this.client.util.info( + `Sucessfully logged in as ${this.client.user.tag}` + ); + } +} diff --git a/src/listeners/commands/CommandBlockedListener.ts b/src/listeners/commands/CommandBlockedListener.ts new file mode 100644 index 0000000..82e53a9 --- /dev/null +++ b/src/listeners/commands/CommandBlockedListener.ts @@ -0,0 +1,34 @@ +import { BotListener } from '../../lib/extensions/BotListener'; +import { Command } from 'discord-akairo'; +import { Message } from 'discord.js'; + +export default class CommandBlockedListener extends BotListener { + public constructor() { + super('commandBlocked', { + emitter: 'commandHandler', + event: 'commandBlocked' + }); + } + + public async exec( + message: Message, + command: Command, + reason: string + ): Promise { + switch (reason) { + case 'owner': { + await messag