├── .dockerignore ├── .eslintrc.json ├── .github └── workflows │ ├── container.yml │ └── lint.yaml ├── .gitignore ├── CONFIGURATION.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── eslint.config.mjs ├── package-lock.json ├── package.json └── src ├── apis ├── CloudVision.js ├── YouTubePlaylist.js └── Zendesk.js ├── automod ├── AutoModManager.js ├── RepeatedMessage.js └── SafeSearch.js ├── bot ├── Bot.js ├── Cache.js ├── Config.js ├── Logger.js └── Request.js ├── commands ├── Command.js ├── CommandManager.js ├── ExecutableCommand.js ├── ParentCommand.js ├── SubCommand.js ├── SubCommandGroup.js ├── bot │ ├── ExportCommand.js │ ├── ImportCommand.js │ └── InfoCommand.js ├── external │ ├── ArticleCommand.js │ └── VideoCommand.js ├── guild │ ├── GuildInfoCommand.js │ ├── IDCommand.js │ ├── LockCommand.js │ ├── PurgeCommand.js │ ├── PurgeInvitesCommand.js │ ├── RoleInfoCommand.js │ └── UnlockCommand.js ├── moderation │ ├── CompletingModerationCommand.js │ ├── ModerationClearCommand.js │ ├── ModerationCommand.js │ ├── ModerationDeleteCommand.js │ ├── ModerationEditCommand.js │ ├── ModerationListCommand.js │ └── ModerationShowCommand.js ├── settings │ ├── AbstractChannelCommand.js │ ├── AttachmentCoolDownCommand.js │ ├── AutoResponseCommand.js │ ├── BadWordCommand.js │ ├── CapsCommand.js │ ├── HelpCenterCommand.js │ ├── InvitesCommandGroup.js │ ├── JoinLogCommand.js │ ├── LinkCoolDownCommand.js │ ├── LogChannelCommand.js │ ├── MessageLogCommand.js │ ├── MutedRoleCommandGroup.js │ ├── PlaylistCommand.js │ ├── ProtectedRolesCommandGroup.js │ ├── PunishmentsCommandGroup.js │ ├── SafeSearchCommand.js │ ├── SettingsCommand.js │ ├── SettingsOverviewCommand.js │ ├── SimilarMessagesCommand.js │ ├── SpamCommand.js │ ├── auto-response │ │ ├── AddAutoResponseCommand.js │ │ ├── CompletingAutoResponseCommand.js │ │ ├── DeleteAutoReponseCommand.js │ │ ├── EditAutoResponseCommand.js │ │ ├── ListAutoResponseCommand.js │ │ └── ShowAutoReponseCommand.js │ ├── bad-word │ │ ├── AddBadWordCommand.js │ │ ├── CompletingBadWordCommand.js │ │ ├── DeleteBadWordCommand.js │ │ ├── EditBadWordCommand.js │ │ ├── ListBadWordCommand.js │ │ └── ShowBadWordCommand.js │ ├── invites │ │ ├── SetInvitesCommand.js │ │ └── ShowInvitesCommand.js │ ├── muted-role │ │ ├── AbstractMutedRoleCommand.js │ │ ├── CreateMutedRoleCommand.js │ │ ├── DisableMutedRoleCommand.js │ │ └── SetMutedRoleCommand.js │ ├── protected-roles │ │ ├── AddProtectedRoleCommand.js │ │ ├── ListProtectedRolesCommand.js │ │ └── RemoveProtectedRoleCommand.js │ └── punishments │ │ ├── SetPunishmentsCommand.js │ │ └── ShowPunishmentsCommand.js └── user │ ├── AvatarCommand.js │ ├── BanCommand.js │ ├── KickCommand.js │ ├── MuteCommand.js │ ├── PardonCommand.js │ ├── SoftBanCommand.js │ ├── StrikeCommand.js │ ├── StrikePurgeCommand.js │ ├── UnbanCommand.js │ ├── UnmuteCommand.js │ ├── UserCommand.js │ └── UserInfoCommand.js ├── database ├── AutoResponse.js ├── BadWord.js ├── ChatTriggeredFeature.js ├── Confirmation.js ├── Database.js ├── Moderation.js ├── Punishment.js ├── WhereParameter.js ├── export │ ├── Exporter.js │ ├── Importer.js │ ├── ModBotImporter.js │ └── VortexImporter.js ├── migrations │ ├── AutoResponseVisionMigration.js │ ├── BadWordVisionMigration.js │ ├── CommentFieldMigration.js │ ├── DMMigration.js │ ├── IndexMigration.js │ ├── Migration.js │ └── VisionMigration.js ├── schema.sql └── triggers │ ├── IncludeTrigger.js │ ├── MatchTrigger.js │ ├── PhishingTrigger.js │ ├── RegexTrigger.js │ ├── Trigger.js │ └── Triggers.js ├── discord ├── ChannelWrapper.js ├── GuildWrapper.js ├── MemberWrapper.js ├── RateLimiter.js ├── UserWrapper.js └── permissions │ ├── SlashCommandPermissionManager.js │ ├── SlashCommandPermissionManagerV2.js │ ├── SlashCommandPermissionManagerV3.js │ ├── SlashCommandPermissionManagers.js │ └── SlashCommandPermissionOverrides.js ├── events ├── EventListener.js ├── EventManager.js ├── discord │ ├── BanRemoveEventListener.js │ ├── DiscordEventManager.js │ ├── ErrorEventListener.js │ ├── GuildAuditLogCreateEventListener.js │ ├── GuildDeleteEventListener.js │ ├── GuildMemberRemoveEventListener.js │ ├── MessageDeleteBulkEventListener.js │ ├── MessageDeleteEventListener.js │ ├── WarnEventListener.js │ ├── guildMemberAdd │ │ ├── GuildMemberAddEventListener.js │ │ ├── LogJoinEventListener.js │ │ └── RestoreMutedRoleEventListener.js │ ├── interactionCreate │ │ ├── CommandEventListener.js │ │ ├── DeleteConfirmationEventListener.js │ │ └── InteractionCreateEventListener.js │ ├── messageCreate │ │ ├── AutoModEventListener.js │ │ ├── AutoResponseEventListener.js │ │ └── MessageCreateEventListener.js │ └── messageUpdate │ │ ├── AutoModMessageEditEventListener.js │ │ ├── LogMessageUpdateEventListener.js │ │ └── MessageUpdateEventListener.js └── rest │ ├── RateLimitEventListener.js │ └── RestEventManager.js ├── formatting ├── MessageBuilder.js └── embeds │ ├── BetterButtonBuilder.js │ ├── ChatFeatureEmbed.js │ ├── ConfirmationEmbed.js │ ├── EmbedWrapper.js │ ├── ErrorEmbed.js │ ├── KeyValueEmbed.js │ ├── LineEmbed.js │ ├── MessageDeleteEmbed.js │ ├── ModerationEmbed.js │ ├── ModerationListEmbed.js │ ├── PurgeLogEmbed.js │ ├── UserActionEmbed.js │ └── UserEmbed.js ├── index.js ├── interval ├── CleanupConfirmationInterval.js ├── Interval.js ├── IntervalManager.js ├── TransferMuteToTimeoutInterval.js ├── UnbanInterval.js └── UnmuteInterval.js ├── modals ├── inputs │ ├── CommentInput.js │ ├── CountInput.js │ ├── DeleteMessageHistoryInput.js │ ├── DurationInput.js │ ├── ReasonInput.js │ └── TextInput.js └── rows │ └── SimpleActionRow.js ├── purge ├── PurgeAgeFilter.js ├── PurgeContentFilter.js ├── PurgeFilter.js ├── PurgeRegexFilter.js └── PurgeUserFilter.js ├── settings ├── ChannelSettings.js ├── GuildSettings.js ├── Settings.js ├── TypeChecker.js └── UserSettings.js ├── shard.js └── util ├── apiLimits.js ├── channels.js ├── colors.js ├── format.js ├── fsutils.js ├── icons.js ├── interaction.js ├── timeutils.js └── util.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .idea 4 | .eslintrc.json 5 | .gitignore 6 | config.json 7 | LICENSE 8 | *.md 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es2021": true, 5 | "node": true, 6 | "es6": true 7 | }, 8 | "extends": ["eslint:recommended","plugin:json/recommended"], 9 | "parserOptions": { 10 | "ecmaVersion": "latest", 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "indent": [ 15 | "error", 16 | 4, 17 | { 18 | "SwitchCase": 1 19 | } 20 | ], 21 | "linebreak-style": [ 22 | "error", 23 | "unix" 24 | ], 25 | "quotes": [ 26 | "error", 27 | "single" 28 | ], 29 | "no-unused-vars": [ 30 | "error", 31 | { 32 | "vars": "all", 33 | "args": "none", 34 | "ignoreRestSiblings": false 35 | } 36 | ], 37 | "semi": [ 38 | "error", 39 | "always" 40 | ] 41 | }, 42 | "plugins": [ 43 | "jsdoc", 44 | "json" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/container.yml: -------------------------------------------------------------------------------- 1 | name: Build and push Docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | branches: 8 | - master 9 | - dev 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | 22 | - name: Get last commit 23 | id: last_commit 24 | run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 25 | 26 | - name: Login to GitHub Container Registry 27 | uses: docker/login-action@v2 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Extract metadata (tags, labels) for Docker 34 | id: meta 35 | uses: docker/metadata-action@v4 36 | with: 37 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 38 | tags: | 39 | type=ref,event=branch 40 | type=ref,event=tag 41 | 42 | - name: Build and push 43 | uses: docker/build-push-action@v4 44 | with: 45 | context: . 46 | file: ./Dockerfile 47 | push: true 48 | tags: ${{ steps.meta.outputs.tags }} 49 | labels: ${{ steps.meta.outputs.labels }} 50 | build-args: | 51 | COMMIT_HASH=${{ steps.last_commit.outputs.sha_short }} -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Run ESLint 2 | 3 | on: 4 | push: 5 | branches: [ '**' ] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [ 22.x.x ] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm install 25 | - run: npm run lint 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config.json 2 | /node_modules/ 3 | .idea 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | ARG COMMIT_HASH 4 | 5 | # Set up files 6 | WORKDIR /app 7 | COPY . . 8 | 9 | # Install dependencies 10 | ENV NODE_ENV=production 11 | RUN npm ci 12 | 13 | # Environment 14 | ENV MODBOT_COMMIT_HASH=$COMMIT_HASH 15 | ENV MODBOT_USE_ENV=1 16 | CMD ["npm", "start"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2025 Aternos GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | Only the most recent version of ModBot is supported. 5 | 6 | We don't offer bug fixes or security updates for old versions. 7 | 8 | ## Valid Vulnerabilities 9 | Vulnerabilities in this project mostly fall into one of the following categories: 10 | - Being able to view or modify settings in another guild 11 | - Being able to view or modify moderations for another guild 12 | - Being able to modify moderations or settings without having access in a guild 13 | - Default Permissions that give users access to something that should probably be private 14 | - Injecting custom code into the bot 15 | - Crashing the entire ModBot instance 16 | - Overloading the instance and therefore making the bot unusable on other servers 17 | 18 | The following are explicitly not vulnerabilities inside ModBot: 19 | - Poorly configured slash command permissions which allow users to execute privileged commands 20 | - Issues otherwise specific to a server for example having a public log of deleted messages, moderations etc. 21 | 22 | ## Reporting a Vulnerability 23 | Please do not create a public issue about security vulnerabilities. To prevent abuse of the vulnerability 24 | before a fix is available please create a private report here: https://github.com/aternosorg/modbot/security/advisories -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import jsdoc from 'eslint-plugin-jsdoc'; 2 | import json from 'eslint-plugin-json'; 3 | import globals from 'globals'; 4 | import js from '@eslint/js'; 5 | 6 | export default [ 7 | js.configs.recommended, 8 | json.configs.recommended, 9 | jsdoc.configs['flat/recommended'], 10 | { 11 | files: ['**/*.js'], 12 | plugins: { 13 | jsdoc, 14 | }, 15 | rules: { 16 | 'jsdoc/require-param-description': 'off', 17 | 'jsdoc/require-returns-description': 'off', 18 | 'jsdoc/require-property-description': 'off', 19 | } 20 | }, 21 | { 22 | languageOptions: { 23 | globals: { 24 | ...globals.node, 25 | }, 26 | 27 | ecmaVersion: 'latest', 28 | sourceType: 'module', 29 | }, 30 | 31 | rules: { 32 | indent: ['error', 4, { 33 | SwitchCase: 1, 34 | }], 35 | 36 | 'linebreak-style': ['error', 'unix'], 37 | 38 | 'no-unused-vars': ['error', { 39 | vars: 'all', 40 | args: 'none', 41 | ignoreRestSiblings: false, 42 | }], 43 | 44 | semi: ['error', 'always'], 45 | }, 46 | } 47 | ]; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "modbot", 3 | "version": "3.6.2", 4 | "description": "Discord Bot for the Aternos Discord server", 5 | "main": "src/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "lint": "eslint", 9 | "start": "node src/index.js", 10 | "dev": "nodemon src/index.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/aternosorg/modbot.git" 15 | }, 16 | "author": "", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/aternosorg/modbot/issues" 20 | }, 21 | "homepage": "https://github.com/aternosorg/modbot#readme", 22 | "engines": { 23 | "node": ">=22.0.0" 24 | }, 25 | "dependencies": { 26 | "@google-cloud/logging": "^11.0.0", 27 | "@google-cloud/vision": "^5.1.0", 28 | "@googleapis/youtube": "^25.0.0", 29 | "diff": "^7.0.0", 30 | "discord-api-types": "^0.38.1", 31 | "discord.js": "^14.16.1", 32 | "fuse.js": "^7.0.0", 33 | "got": "^14.4.2", 34 | "mysql2": "^3.9.8", 35 | "string-similarity": "^4.0.4", 36 | "turndown": "^7.1.2" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "^22.5.2", 40 | "eslint": "^9.9.1", 41 | "eslint-plugin-jsdoc": "^50.2.2", 42 | "eslint-plugin-json": "^4.0.1", 43 | "nodemon": "^3.1.10" 44 | }, 45 | "lint-staged": { 46 | "*.js": [ 47 | "eslint --fix" 48 | ], 49 | "*.json": [ 50 | "eslint --fix" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/apis/CloudVision.js: -------------------------------------------------------------------------------- 1 | import config from '../bot/Config.js'; 2 | import vision from '@google-cloud/vision'; 3 | import logger from '../bot/Logger.js'; 4 | import {Collection} from 'discord.js'; 5 | import GuildSettings from '../settings/GuildSettings.js'; 6 | 7 | export class CloudVision { 8 | #imageAnnotatorClient = null; 9 | #imageTexts = new Collection(); 10 | 11 | get isEnabled() { 12 | return config.data.googleCloud.vision?.enabled; 13 | } 14 | 15 | get annotatorClient() { 16 | if (!this.isEnabled) { 17 | return null; 18 | } 19 | 20 | return this.#imageAnnotatorClient ??= new vision.ImageAnnotatorClient({ 21 | credentials: config.data.googleCloud.credentials 22 | }); 23 | } 24 | 25 | /** 26 | * Get all image attachments from a message 27 | * @param {import('discord.js').Message} message 28 | * @returns {import('discord.js').Collection} 29 | */ 30 | getImages(message) { 31 | return message.attachments.filter(attachment => attachment.contentType?.startsWith('image/')); 32 | } 33 | 34 | /** 35 | * Get text from images in a message 36 | * @param {import('discord.js').Message} message 37 | * @returns {Promise} 38 | */ 39 | async getImageText(message) { 40 | if (!this.isEnabled) { 41 | return []; 42 | } 43 | 44 | const guildSettings = await GuildSettings.get(message.guild.id); 45 | if (!guildSettings.isFeatureWhitelisted) { 46 | return []; 47 | } 48 | 49 | if (this.#imageTexts.has(message.id)) { 50 | return this.#imageTexts.get(message.id); 51 | } 52 | 53 | const texts = []; 54 | 55 | for (const image of this.getImages(message).values()) { 56 | try { 57 | const [{textAnnotations}] = await this.annotatorClient.textDetection(image.url); 58 | for (const annotation of textAnnotations) { 59 | texts.push(annotation.description); 60 | } 61 | } 62 | catch (error) { 63 | await logger.error(error); 64 | } 65 | } 66 | 67 | if (texts.length) { 68 | this.#imageTexts.set(message.id, texts); 69 | setTimeout(() => this.#imageTexts.delete(message.id), 5000); 70 | } 71 | 72 | return texts; 73 | } 74 | } 75 | 76 | export default new CloudVision(); 77 | -------------------------------------------------------------------------------- /src/apis/Zendesk.js: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | 3 | /** 4 | * @typedef {object} ZendeskArticle 5 | * @property {number} id article id 6 | * @property {string} url api url 7 | * @property {string} html_url url to public article page 8 | * @property {boolean} promoted 9 | * @property {string} title 10 | * @property {string} body raw html 11 | */ 12 | 13 | /** 14 | * @typedef {object} ZendeskArticleSuggestion 15 | * @property {string} title 16 | * @property {string} category_title 17 | * @property {string} url 18 | */ 19 | 20 | export default class Zendesk { 21 | constructor(identifier) { 22 | this.identifier = identifier; 23 | } 24 | 25 | async #request(endpoint) { 26 | return got.get(`https://${this.identifier}.zendesk.com/${endpoint}`).json(); 27 | } 28 | 29 | /** 30 | * search articles 31 | * @param {string} query 32 | * @returns {Promise<{count: number, results: ZendeskArticle[]}>} 33 | */ 34 | async searchArticles(query) { 35 | return this.#request(`api/v2/help_center/articles/search.json?query=${encodeURIComponent(query)}`); 36 | } 37 | 38 | /** 39 | * get article suggestions 40 | * @param {string} query 41 | * @returns {Promise} 42 | */ 43 | async getArticleSuggestions(query) { 44 | const data = await this.#request(`hc/api/internal/instant_search.json?query=${encodeURIComponent(query)}`); 45 | return data.results; 46 | } 47 | 48 | /** 49 | * get a single article 50 | * @param {string|number} id 51 | * @returns {Promise} 52 | */ 53 | async getArticle(id) { 54 | /** @type {{article: ZendeskArticle}} */ 55 | const article = await this.#request(`/api/v2/help_center/articles/${id}`); 56 | return article?.article; 57 | } 58 | 59 | /** 60 | * @param {number} [results] maximum number of articles that will be returned 61 | * @returns {Promise} 62 | */ 63 | async getArticles(results = 100) { 64 | /** @type {{articles: ZendeskArticle[]}} */ 65 | const articles = await this.#request(`api/v2/help_center/articles?per_page=${results}`); 66 | return articles.articles; 67 | } 68 | 69 | /** 70 | * get promoted articles 71 | * @returns {Promise} 72 | */ 73 | async getPromotedArticles() { 74 | const articles = await this.getArticles(); 75 | return articles.filter(article => article.promoted); 76 | } 77 | } -------------------------------------------------------------------------------- /src/bot/Cache.js: -------------------------------------------------------------------------------- 1 | import {Collection} from 'discord.js'; 2 | 3 | /** 4 | * @class 5 | * @template K,V 6 | */ 7 | export default class Cache { 8 | /** 9 | * @type {Collection>} 10 | */ 11 | #cache = new Collection(); 12 | 13 | constructor() { 14 | setInterval(this.checkCache.bind(this), 5000); 15 | } 16 | 17 | /** 18 | * get the value of a cache entry 19 | * @param {K} key 20 | * @returns {?V} 21 | */ 22 | get(key) { 23 | return this.getEntry(key)?.value; 24 | } 25 | 26 | /** 27 | * get a cache entry 28 | * @param {K} key 29 | * @returns {?CacheEntry} 30 | */ 31 | getEntry(key) { 32 | return this.#cache.get(key); 33 | } 34 | 35 | /** 36 | * set the value of this entry 37 | * @param {K} key 38 | * @param {V} value 39 | * @param {number} ttl cache duration in ms 40 | */ 41 | set(key, value, ttl) { 42 | this.#cache.set(key, new CacheEntry(value, ttl)); 43 | } 44 | 45 | /** 46 | * delete this key from the cache 47 | * @param {K} key 48 | */ 49 | delete(key) { 50 | this.#cache.delete(key); 51 | } 52 | 53 | checkCache() { 54 | for (const [key, entry] of this.#cache) { 55 | if (entry.isCacheTimeOver) { 56 | this.#cache.delete(key); 57 | } 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * @class 64 | * @template V 65 | */ 66 | export class CacheEntry { 67 | /** 68 | * @param {V} value 69 | * @param {number} ttl cache duration in ms 70 | */ 71 | constructor(value, ttl) { 72 | this.value = value; 73 | this.until = Date.now() + ttl; 74 | } 75 | 76 | get isCacheTimeOver() { 77 | return Date.now() > this.until; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/bot/Request.js: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | import {createHash} from 'crypto'; 3 | 4 | export default class Request { 5 | 6 | request; 7 | 8 | /** 9 | * @type {string} 10 | */ 11 | response; 12 | 13 | /** 14 | * parsed JSON response 15 | * @type {object} 16 | */ 17 | JSON; 18 | 19 | constructor(url, options = {}) { 20 | this.url = url; 21 | this.options = options; 22 | } 23 | 24 | /** 25 | * get raw data 26 | * @returns {Promise} 27 | */ 28 | async get() { 29 | this.request = await got.get(this.url, this.options); 30 | this.response = this.request.body.toString(); 31 | return this; 32 | } 33 | 34 | /** 35 | * get JSON data and parse it 36 | * @returns {Promise} 37 | */ 38 | async getJSON() { 39 | await this.get(); 40 | try { 41 | this.JSON = JSON.parse(this.response); 42 | } 43 | catch (e) { 44 | throw new Error(`Failed to parse JSON response of ${this.url}`, e); 45 | } 46 | return this; 47 | } 48 | 49 | /** 50 | * request this url and return a sha256 hash of the raw body 51 | * @returns {Promise} 52 | */ 53 | async getHash() { 54 | const response = await got.get(this.url, this.options); 55 | return createHash('sha256').update(new DataView(response.rawBody.buffer)).digest('hex').toString(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/commands/ExecutableCommand.js: -------------------------------------------------------------------------------- 1 | import {PermissionsBitField} from 'discord.js'; 2 | 3 | export default class ExecutableCommand { 4 | 5 | /** 6 | * @abstract 7 | * @returns {string} 8 | */ 9 | getName() { 10 | return 'unknown'; 11 | } 12 | 13 | /** 14 | * @abstract 15 | * @returns {string} 16 | */ 17 | getDescription() { 18 | return 'unknown'; 19 | } 20 | 21 | /** 22 | * get command cool down in seconds 23 | * @returns {number} 24 | */ 25 | getCoolDown() { 26 | return 0; 27 | } 28 | 29 | /** 30 | * is this command available in direct messages 31 | * @returns {boolean} 32 | */ 33 | isAvailableInDMs() { 34 | return false; 35 | } 36 | 37 | /** 38 | * @returns {import('discord.js').PermissionsBitField} 39 | */ 40 | getRequiredBotPermissions() { 41 | return new PermissionsBitField(); 42 | } 43 | 44 | buildOptions(builder) { 45 | return builder; 46 | } 47 | 48 | /** 49 | * @param {import('discord.js').AutocompleteInteraction} interaction 50 | * @returns {Promise} 51 | */ 52 | async complete(interaction) { 53 | return []; 54 | } 55 | 56 | /** 57 | * execute a slash command 58 | * @abstract 59 | * @param {import('discord.js').ChatInputCommandInteraction} interaction 60 | * @returns {Promise} 61 | */ 62 | async execute(interaction) { 63 | 64 | } 65 | 66 | /** 67 | * handle a button press 68 | * @param {import('discord.js').ButtonInteraction} interaction 69 | * @returns {Promise} 70 | */ 71 | async executeButton(interaction) { 72 | 73 | } 74 | 75 | /** 76 | * handle data submitted from a modal 77 | * @param {import('discord.js').ModalSubmitInteraction} interaction 78 | * @returns {Promise} 79 | */ 80 | async executeModal(interaction) { 81 | 82 | } 83 | 84 | /** 85 | * handle data submitted from a modal 86 | * @param {import('discord.js').AnySelectMenuInteraction} interaction 87 | * @returns {Promise} 88 | */ 89 | async executeSelectMenu(interaction) { 90 | 91 | } 92 | } -------------------------------------------------------------------------------- /src/commands/SubCommand.js: -------------------------------------------------------------------------------- 1 | import ExecutableCommand from './ExecutableCommand.js'; 2 | 3 | /** 4 | * @abstract 5 | */ 6 | export default class SubCommand extends ExecutableCommand { 7 | /** 8 | * add options to slash command builder 9 | * @param {import('discord.js').SlashCommandSubcommandBuilder} builder 10 | * @returns {import('discord.js').SlashCommandSubcommandBuilder} 11 | */ 12 | buildOptions(builder) { 13 | return super.buildOptions(builder); 14 | } 15 | 16 | /** 17 | * @param {import('discord.js').SlashCommandSubcommandBuilder} builder 18 | * @returns {import('discord.js').SlashCommandSubcommandBuilder} 19 | */ 20 | buildSubCommand(builder) { 21 | builder.setName(this.getName()); 22 | builder.setDescription(this.getDescription()); 23 | this.buildOptions(builder); 24 | 25 | return builder; 26 | } 27 | } -------------------------------------------------------------------------------- /src/commands/bot/ExportCommand.js: -------------------------------------------------------------------------------- 1 | import Command from '../Command.js'; 2 | import Exporter from '../../database/export/Exporter.js'; 3 | import {AttachmentBuilder, MessageFlags, PermissionFlagsBits, PermissionsBitField} from 'discord.js'; 4 | import {FILE_UPLOAD_LIMITS} from '../../util/apiLimits.js'; 5 | import {gzipSync} from 'zlib'; 6 | 7 | export default class ExportCommand extends Command { 8 | 9 | getDefaultMemberPermissions() { 10 | return new PermissionsBitField() 11 | .add(PermissionFlagsBits.ManageGuild); 12 | } 13 | 14 | getCoolDown() { 15 | return 60; 16 | } 17 | 18 | async execute(interaction) { 19 | await interaction.deferReply({flags: MessageFlags.Ephemeral}); 20 | const exporter = new Exporter(interaction.guild.id); 21 | let data = Buffer.from(await exporter.export()); 22 | 23 | let gzip = data.byteLength > FILE_UPLOAD_LIMITS.get(interaction.guild.premiumTier); 24 | if (gzip) { 25 | data = gzipSync(data); 26 | } 27 | 28 | if (data.byteLength > FILE_UPLOAD_LIMITS.get(interaction.guild.premiumTier)) { 29 | await interaction.editReply('Unable to upload exported data (file too large)!'); 30 | return; 31 | } 32 | 33 | await interaction.editReply({ 34 | files: [ 35 | new AttachmentBuilder(data, { 36 | name: `ModBot-data-${interaction.guild.id}.json${gzip ? '.gz' : ''}`, 37 | description: 'ModBot data for this guild. Use /import to import on another guild or ModBot instance' 38 | }), 39 | ] 40 | }); 41 | } 42 | 43 | getDescription() { 44 | return 'Export all data ModBot stores about this guild'; 45 | } 46 | 47 | getName() { 48 | return 'export'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/commands/guild/GuildInfoCommand.js: -------------------------------------------------------------------------------- 1 | import Command from '../Command.js'; 2 | import {time, TimestampStyles, userMention} from 'discord.js'; 3 | import KeyValueEmbed from '../../formatting/embeds/KeyValueEmbed.js'; 4 | import colors from '../../util/colors.js'; 5 | import {yesNo} from '../../util/format.js'; 6 | 7 | export default class GuildInfoCommand extends Command { 8 | 9 | async execute(interaction) { 10 | const guild = interaction.guild; 11 | const owner = await guild.fetchOwner(); 12 | 13 | const embed = new KeyValueEmbed() 14 | .setTitle(`${guild.name}`) 15 | .setColor(colors.RED) 16 | .setThumbnail(guild.iconURL({size: 2048})) 17 | .addPairIf(guild.description, 'Description', guild.description) 18 | .addPair('Owner', userMention(owner.id)) 19 | .addPair('Owner ID', owner.id) 20 | .addPair('Created', time(guild.createdAt, TimestampStyles.LongDateTime)) 21 | .addPair('Guild ID', guild.id) 22 | .newLine() 23 | .addPair('Members', guild.memberCount) 24 | .addPair('Member Limit', guild.maximumMembers) 25 | .addPair('Verified', yesNo(guild.verified)) 26 | .addPair('Partnered', yesNo(guild.partnered)) 27 | .addPair('Premium Tier', guild.premiumTier) 28 | .newLine(); 29 | 30 | if (guild.features.length) { 31 | embed.addListOrShortList('Features', guild.features); 32 | } 33 | else { 34 | embed.addPair('Features', 'None'); 35 | } 36 | 37 | await interaction.reply(embed.toMessage()); 38 | } 39 | 40 | getDescription() { 41 | return 'Show information about this server'; 42 | } 43 | 44 | getName() { 45 | return 'server'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/guild/RoleInfoCommand.js: -------------------------------------------------------------------------------- 1 | import Command from '../Command.js'; 2 | import {PermissionFlagsBits, time, TimestampStyles} from 'discord.js'; 3 | import KeyValueEmbed from '../../formatting/embeds/KeyValueEmbed.js'; 4 | 5 | export default class RoleInfoCommand extends Command { 6 | 7 | buildOptions(builder) { 8 | builder.addRoleOption(option => option 9 | .setName('role') 10 | .setDescription('The role you want to view') 11 | .setRequired(true)); 12 | return super.buildOptions(builder); 13 | } 14 | 15 | async execute(interaction) { 16 | const role = /** @type {import('discord.js').Role} */ 17 | interaction.options.getRole('role', true); 18 | 19 | const embed = new KeyValueEmbed() 20 | .setTitle(`Role ${role.name}`) 21 | .setColor(role.color) 22 | .setImage(role.iconURL()) 23 | .addPair('Name', role.name) 24 | .addPair('Created', time(role.createdAt, TimestampStyles.LongDateTime)) 25 | .addPair('Managed', role.managed ? 'Yes' : 'No') 26 | .addPair('Hoisted', role.hoist ? 'Yes' : 'No') 27 | .addPair('Color', `${role.hexColor}`); 28 | 29 | if (role.permissions.has(PermissionFlagsBits.Administrator)) { 30 | embed.addPair('Permissions', 'Administrator'); 31 | } else { 32 | const permissions = role.permissions.toArray(); 33 | if (permissions.length) { 34 | embed.addListOrShortList('Permissions', permissions); 35 | } 36 | else { 37 | embed.addPair('Permissions', 'None'); 38 | } 39 | } 40 | 41 | 42 | await interaction.reply(embed.toMessage()); 43 | } 44 | 45 | getDescription() { 46 | return 'Show information about a role'; 47 | } 48 | 49 | getName() { 50 | return 'role'; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/commands/moderation/CompletingModerationCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import WhereParameter from '../../database/WhereParameter.js'; 3 | import Moderation from '../../database/Moderation.js'; 4 | 5 | /** 6 | * @abstract 7 | */ 8 | export default class CompletingModerationCommand extends SubCommand { 9 | async complete(interaction) { 10 | const focussed = interaction.options.getFocused(true); 11 | switch (focussed.name) { 12 | case 'id': { 13 | const options = [], params = [ 14 | new WhereParameter('guildid', interaction.guild.id), 15 | ]; 16 | const value = parseInt(focussed.value); 17 | if (value) { 18 | options.unshift({name: value, value: value}); 19 | params.push(new WhereParameter('id', `%${value}%`, 'LIKE')); 20 | } 21 | else { 22 | params.push(new WhereParameter('moderator', interaction.user.id)); 23 | } 24 | 25 | const moderations = await Moderation.select(params, 5, false); 26 | 27 | for (const moderation of moderations) { 28 | const [user, moderator] = await Promise.all([ 29 | moderation.getUser(), 30 | moderation.getModerator(), 31 | ]); 32 | 33 | options.push({ 34 | name: `#${moderation.id} - ${moderation.action} ${user?.displayName ?? 'unknown'} by ${moderator?.displayName ?? 'unknown'}`, 35 | value: moderation.id 36 | }); 37 | } 38 | 39 | return options; 40 | } 41 | } 42 | 43 | return super.complete(interaction); 44 | } 45 | } -------------------------------------------------------------------------------- /src/commands/moderation/ModerationClearCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import Moderation from '../../database/Moderation.js'; 3 | import { 4 | ButtonStyle, 5 | escapeMarkdown, 6 | MessageFlags, 7 | } from 'discord.js'; 8 | import MemberWrapper from '../../discord/MemberWrapper.js'; 9 | import database from '../../database/Database.js'; 10 | import Confirmation from '../../database/Confirmation.js'; 11 | import {timeAfter} from '../../util/timeutils.js'; 12 | import ConfirmationEmbed from '../../formatting/embeds/ConfirmationEmbed.js'; 13 | 14 | export default class ModerationClearCommand extends SubCommand { 15 | 16 | buildOptions(builder) { 17 | builder.addUserOption(option => option 18 | .setName('user') 19 | .setDescription('The user who\'s moderations you want to delete.') 20 | .setRequired(true) 21 | ); 22 | return super.buildOptions(builder); 23 | } 24 | 25 | async execute(interaction) { 26 | const user = interaction.options.getUser('user', true); 27 | await interaction.deferReply({flags: MessageFlags.Ephemeral}); 28 | const moderationCount = (await Moderation.getAll(interaction.guildId, user.id)).length; 29 | if (moderationCount === 0) { 30 | await interaction.reply({flags: MessageFlags.Ephemeral, content: 'This user has no moderations.'}); 31 | return; 32 | } 33 | 34 | const confirmation = new Confirmation({user: user.id}, timeAfter('15 minutes')); 35 | await interaction.editReply(new ConfirmationEmbed('moderation:clear', await confirmation.save(), ButtonStyle.Danger) 36 | .setDescription(`Delete ${moderationCount} Moderations for ${escapeMarkdown(user.displayName)}?`) 37 | .toMessage()); 38 | } 39 | 40 | async executeButton(interaction) { 41 | const parts = interaction.customId.split(':'); 42 | if (parts[2] === 'confirm') { 43 | /** @type {Confirmation<{user: import('discord.js').Snowflake}>} */ 44 | const confirmation = await Confirmation.get(parts[3]); 45 | 46 | const member = await MemberWrapper.getMember(interaction, confirmation.data.user); 47 | 48 | if (!confirmation) { 49 | await interaction.update({content: 'This confirmation has expired.', embeds: [], components: []}); 50 | return; 51 | } 52 | 53 | /** @property {number} affectedRows */ 54 | const deletion = await database.queryAll('DELETE FROM moderations WHERE guildid = ? AND userid = ?', 55 | interaction.guildId, member.user.id); 56 | await interaction.update({ 57 | content: `Deleted ${deletion.affectedRows} ${deletion.affectedRows === 1 ? 'moderation' : 'moderations'}!`, 58 | embeds: [], components: [] 59 | }); 60 | } 61 | } 62 | 63 | getDescription() { 64 | return 'Delete all moderations for a user'; 65 | } 66 | 67 | getName() { 68 | return 'clear'; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/moderation/ModerationCommand.js: -------------------------------------------------------------------------------- 1 | import ModerationShowCommand from './ModerationShowCommand.js'; 2 | import ParentCommand from '../ParentCommand.js'; 3 | import ModerationClearCommand from './ModerationClearCommand.js'; 4 | import ModerationDeleteCommand from './ModerationDeleteCommand.js'; 5 | import ModerationListCommand from './ModerationListCommand.js'; 6 | import {PermissionFlagsBits, PermissionsBitField} from 'discord.js'; 7 | import ModerationEditCommand from './ModerationEditCommand.js'; 8 | 9 | export default class ModerationCommand extends ParentCommand { 10 | 11 | getDefaultMemberPermissions() { 12 | return new PermissionsBitField() 13 | .add(PermissionFlagsBits.ModerateMembers); 14 | } 15 | 16 | getChildren() { 17 | return [ 18 | new ModerationShowCommand(), 19 | new ModerationEditCommand(), 20 | new ModerationDeleteCommand(), 21 | new ModerationListCommand(), 22 | new ModerationClearCommand(), 23 | ]; 24 | } 25 | 26 | getDescription() { 27 | return 'View and manage moderations'; 28 | } 29 | 30 | getName() { 31 | return 'moderation'; 32 | } 33 | } -------------------------------------------------------------------------------- /src/commands/moderation/ModerationDeleteCommand.js: -------------------------------------------------------------------------------- 1 | import Moderation from '../../database/Moderation.js'; 2 | import ModerationEmbed from '../../formatting/embeds/ModerationEmbed.js'; 3 | import colors from '../../util/colors.js'; 4 | import ErrorEmbed from '../../formatting/embeds/ErrorEmbed.js'; 5 | import CompletingModerationCommand from './CompletingModerationCommand.js'; 6 | import {MessageFlags} from 'discord.js'; 7 | 8 | export default class ModerationDeleteCommand extends CompletingModerationCommand { 9 | 10 | buildOptions(builder) { 11 | builder.addIntegerOption(option => option 12 | .setName('id') 13 | .setDescription('The id of the moderation you want to view') 14 | .setMinValue(0) 15 | .setRequired(true) 16 | .setAutocomplete(true) 17 | ); 18 | return super.buildOptions(builder); 19 | } 20 | 21 | async execute(interaction) { 22 | const id = interaction.options.getInteger('id'); 23 | const moderation = await Moderation.get(interaction.guild.id, id); 24 | if (!moderation) { 25 | await interaction.reply(ErrorEmbed.message('Unknown Moderation!')); 26 | return; 27 | } 28 | 29 | await moderation.delete(); 30 | const user = await (await moderation.getMemberWrapper()).getMemberOrUser(); 31 | const embed = new ModerationEmbed(moderation, user) 32 | .setTitle(`Deleted Moderation #${moderation.id} | ${moderation.action.toUpperCase()}`) 33 | .setColor(colors.RED); 34 | await interaction.reply({ 35 | flags: MessageFlags.Ephemeral, 36 | embeds: [embed] 37 | }); 38 | } 39 | 40 | async executeButton(interaction) { 41 | const parts = interaction.customId.split(':'); 42 | const id = parts[2]; 43 | const moderation = await Moderation.get(interaction.guild.id, id); 44 | if (!moderation) { 45 | await interaction.update({ 46 | embeds: [new ErrorEmbed('Unknown Moderation!')], 47 | components: [] 48 | }); 49 | return; 50 | } 51 | 52 | await moderation.delete(); 53 | const user = await (await moderation.getMemberWrapper()).getMemberOrUser(); 54 | const embed = new ModerationEmbed(moderation, user) 55 | .setTitle(`Deleted Moderation #${moderation.id} | ${moderation.action.toUpperCase()}`) 56 | .setColor(colors.RED); 57 | await interaction.update({ 58 | embeds: [embed], 59 | components: [] 60 | }); 61 | } 62 | 63 | getDescription() { 64 | return 'Delete a single moderation'; 65 | } 66 | 67 | getName() { 68 | return 'delete'; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/moderation/ModerationShowCommand.js: -------------------------------------------------------------------------------- 1 | import Moderation from '../../database/Moderation.js'; 2 | import ModerationEmbed from '../../formatting/embeds/ModerationEmbed.js'; 3 | import ErrorEmbed from '../../formatting/embeds/ErrorEmbed.js'; 4 | import CompletingModerationCommand from './CompletingModerationCommand.js'; 5 | import {ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags} from 'discord.js'; 6 | 7 | export default class ModerationShowCommand extends CompletingModerationCommand { 8 | 9 | buildOptions(builder) { 10 | builder.addIntegerOption(option => option 11 | .setName('id') 12 | .setDescription('The id of the moderation you want to view') 13 | .setMinValue(0) 14 | .setRequired(true) 15 | .setAutocomplete(true) 16 | ); 17 | return super.buildOptions(builder); 18 | } 19 | 20 | async execute(interaction) { 21 | const id = interaction.options.getInteger('id'); 22 | const moderation = await Moderation.get(interaction.guild.id, id); 23 | if (!moderation) { 24 | await interaction.reply(ErrorEmbed.message('Unknown Moderation!')); 25 | return; 26 | } 27 | 28 | const user = await (await moderation.getMemberWrapper()).fetchMember() ?? await moderation.getUser(); 29 | await interaction.reply({ 30 | flags: MessageFlags.Ephemeral, 31 | embeds: [new ModerationEmbed(moderation, user)], 32 | components: [ 33 | new ActionRowBuilder() 34 | .addComponents( 35 | /** @type {*} */ 36 | new ButtonBuilder() 37 | .setLabel('Delete') 38 | .setStyle(ButtonStyle.Danger) 39 | .setCustomId(`moderation:delete:${id}`) 40 | ) 41 | ] 42 | }); 43 | } 44 | 45 | getDescription() { 46 | return 'Show a moderation'; 47 | } 48 | 49 | getName() { 50 | return 'show'; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/commands/settings/AbstractChannelCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import GuildWrapper from '../../discord/GuildWrapper.js'; 3 | import {PermissionFlagsBits} from 'discord.js'; 4 | import ErrorEmbed from '../../formatting/embeds/ErrorEmbed.js'; 5 | 6 | /** 7 | * @abstract 8 | */ 9 | export default class AbstractChannelCommand extends SubCommand { 10 | /** 11 | * @param {import('discord.js').Interaction} interaction 12 | * @returns {Promise} 13 | */ 14 | async getChannel(interaction) { 15 | const channelId = interaction.options.getChannel('channel')?.id; 16 | if (channelId) { 17 | const channel = await new GuildWrapper(interaction.guild).fetchChannel(channelId); 18 | 19 | if (!channel) { 20 | await interaction.reply(ErrorEmbed.message('I can\'t access that channel!')); 21 | return false; 22 | } 23 | 24 | if (!channel.permissionsFor(await interaction.guild.members.fetchMe()) 25 | .has(PermissionFlagsBits.SendMessages)) { 26 | await interaction.reply(ErrorEmbed.message('I can\'t send messages to that channel!')); 27 | return false; 28 | } 29 | return channel; 30 | } 31 | return null; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/commands/settings/AttachmentCoolDownCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import {formatTime, parseTime} from '../../util/timeutils.js'; 3 | import GuildSettings from '../../settings/GuildSettings.js'; 4 | import EmbedWrapper from '../../formatting/embeds/EmbedWrapper.js'; 5 | import colors from '../../util/colors.js'; 6 | 7 | export default class AttachmentCoolDownCommand extends SubCommand { 8 | 9 | buildOptions(builder) { 10 | builder.addStringOption(option => option 11 | .setName('cool-down') 12 | .setDescription('Cool-down for users sending attachements.') 13 | ); 14 | return super.buildOptions(builder); 15 | } 16 | 17 | async execute(interaction) { 18 | const coolDown = parseTime(interaction.options.getString('cool-down')), 19 | guildSettings = await GuildSettings.get(interaction.guildId); 20 | 21 | if (coolDown) { 22 | guildSettings.attachmentCooldown = coolDown; 23 | await guildSettings.save(); 24 | await interaction.reply(new EmbedWrapper() 25 | .setDescription(`Set attachment-cool-down to ${formatTime(coolDown)}`) 26 | .setColor(colors.GREEN) 27 | .toMessage()); 28 | } else { 29 | guildSettings.attachmentCooldown = -1; 30 | await guildSettings.save(); 31 | await interaction.reply(new EmbedWrapper() 32 | .setDescription('Disabled attachment-cool-down.') 33 | .setColor(colors.RED) 34 | .toMessage()); 35 | } 36 | } 37 | 38 | getDescription() { 39 | return 'Set a cool-down on sending attachments.'; 40 | } 41 | 42 | getName() { 43 | return 'attachment-cool-down'; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/settings/AutoResponseCommand.js: -------------------------------------------------------------------------------- 1 | import AddAutoResponseCommand from './auto-response/AddAutoResponseCommand.js'; 2 | import ListAutoResponseCommand from './auto-response/ListAutoResponseCommand.js'; 3 | import ShowAutoReponseCommand from './auto-response/ShowAutoReponseCommand.js'; 4 | import DeleteAutoReponseCommand from './auto-response/DeleteAutoReponseCommand.js'; 5 | import EditAutoResponseCommand from './auto-response/EditAutoResponseCommand.js'; 6 | import {PermissionFlagsBits, PermissionsBitField} from 'discord.js'; 7 | import ParentCommand from '../ParentCommand.js'; 8 | 9 | export default class AutoResponseCommand extends ParentCommand { 10 | 11 | getDefaultMemberPermissions() { 12 | return new PermissionsBitField() 13 | .add(PermissionFlagsBits.ManageGuild); 14 | } 15 | 16 | getChildren() { 17 | return [ 18 | new ListAutoResponseCommand(), 19 | new AddAutoResponseCommand(), 20 | new ShowAutoReponseCommand(), 21 | new DeleteAutoReponseCommand(), 22 | new EditAutoResponseCommand(), 23 | ]; 24 | } 25 | 26 | getDescription() { 27 | return 'Manage auto-responses'; 28 | } 29 | 30 | getName() { 31 | return 'auto-response'; 32 | } 33 | } -------------------------------------------------------------------------------- /src/commands/settings/BadWordCommand.js: -------------------------------------------------------------------------------- 1 | import AddBadWordCommand from './bad-word/AddBadWordCommand.js'; 2 | import EditBadWordCommand from './bad-word/EditBadWordCommand.js'; 3 | import ListBadWordCommand from './bad-word/ListBadWordCommand.js'; 4 | import ShowBadWordCommand from './bad-word/ShowBadWordCommand.js'; 5 | import DeleteBadWordCommand from './bad-word/DeleteBadWordCommand.js'; 6 | import ParentCommand from '../ParentCommand.js'; 7 | import {PermissionFlagsBits, PermissionsBitField} from 'discord.js'; 8 | 9 | export default class BadWordCommand extends ParentCommand { 10 | 11 | getDefaultMemberPermissions() { 12 | return new PermissionsBitField() 13 | .add(PermissionFlagsBits.ManageGuild); 14 | } 15 | 16 | getChildren() { 17 | return [ 18 | new ListBadWordCommand(), 19 | new AddBadWordCommand(), 20 | new ShowBadWordCommand(), 21 | new DeleteBadWordCommand(), 22 | new EditBadWordCommand(), 23 | ]; 24 | } 25 | 26 | getDescription() { 27 | return 'Manage bad-words'; 28 | } 29 | 30 | getName() { 31 | return 'bad-word'; 32 | } 33 | } -------------------------------------------------------------------------------- /src/commands/settings/CapsCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import GuildSettings from '../../settings/GuildSettings.js'; 3 | import EmbedWrapper from '../../formatting/embeds/EmbedWrapper.js'; 4 | import colors from '../../util/colors.js'; 5 | import {ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags} from 'discord.js'; 6 | import ErrorEmbed from '../../formatting/embeds/ErrorEmbed.js'; 7 | 8 | export default class CapsCommand extends SubCommand { 9 | 10 | buildOptions(builder) { 11 | builder.addBooleanOption(option => option 12 | .setName('enabled') 13 | .setDescription('Delete messages with more than 70% capital letters') 14 | .setRequired(true)); 15 | return super.buildOptions(builder); 16 | } 17 | 18 | async execute(interaction) { 19 | const enabled = interaction.options.getBoolean('enabled', true); 20 | 21 | const options = await this.change(interaction, enabled); 22 | options.flags ??= 0; 23 | options.flags |= MessageFlags.Ephemeral; 24 | await interaction.reply(options); 25 | } 26 | 27 | /** 28 | * 29 | * @param {import('discord.js').Interaction} interaction 30 | * @param {boolean} enabled 31 | * @returns {Promise<{formatting: ActionRowBuilder[], embeds: EmbedWrapper[]}>} 32 | */ 33 | async change(interaction, enabled) { 34 | const guildSettings = await GuildSettings.get(interaction.guild.id); 35 | guildSettings.caps = enabled; 36 | await guildSettings.save(); 37 | 38 | const embed = new EmbedWrapper(), button = new ButtonBuilder(); 39 | if (enabled) { 40 | embed.setDescription('Enabled caps moderation!') 41 | .setColor(colors.GREEN); 42 | button.setLabel('Disable') 43 | .setStyle(ButtonStyle.Danger) 44 | .setCustomId('settings:caps:disable'); 45 | } 46 | else { 47 | embed.setDescription('Disabled caps moderation!') 48 | .setColor(colors.RED); 49 | button.setLabel('Enable') 50 | .setStyle(ButtonStyle.Success) 51 | .setCustomId('settings:caps:enable'); 52 | } 53 | 54 | return { 55 | embeds: [embed], 56 | components: [ 57 | /** @type {ActionRowBuilder} */ 58 | new ActionRowBuilder() 59 | .addComponents(/** @type {*} */ button) 60 | ] 61 | }; 62 | } 63 | 64 | async executeButton(interaction) { 65 | let enabled; 66 | switch (interaction.customId.split(':')[2]) { 67 | case 'enable': 68 | enabled = true; 69 | break; 70 | 71 | case 'disable': 72 | enabled = false; 73 | break; 74 | 75 | default: 76 | await interaction.reply(ErrorEmbed.message('Unknown action!')); 77 | return; 78 | } 79 | 80 | await interaction.update(await this.change(interaction, enabled)); 81 | } 82 | 83 | getDescription() { 84 | return 'Manage the deletion of messages with too many capital letters'; 85 | } 86 | 87 | getName() { 88 | return 'caps'; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/commands/settings/InvitesCommandGroup.js: -------------------------------------------------------------------------------- 1 | import SubCommandGroup from '../SubCommandGroup.js'; 2 | import ShowInvitesCommand from './invites/ShowInvitesCommand.js'; 3 | import SetInvitesCommand from './invites/SetInvitesCommand.js'; 4 | 5 | export default class InvitesCommandGroup extends SubCommandGroup { 6 | 7 | getChildren() { 8 | return [ 9 | new ShowInvitesCommand(), 10 | new SetInvitesCommand(), 11 | ]; 12 | } 13 | 14 | getDescription() { 15 | return 'Prevent users from sending invites'; 16 | } 17 | 18 | getName() { 19 | return 'invites'; 20 | } 21 | } -------------------------------------------------------------------------------- /src/commands/settings/JoinLogCommand.js: -------------------------------------------------------------------------------- 1 | import GuildSettings from '../../settings/GuildSettings.js'; 2 | import AbstractChannelCommand from './AbstractChannelCommand.js'; 3 | import EmbedWrapper from '../../formatting/embeds/EmbedWrapper.js'; 4 | import colors from '../../util/colors.js'; 5 | import {channelMention} from 'discord.js'; 6 | 7 | export default class JoinLogCommand extends AbstractChannelCommand { 8 | 9 | buildOptions(builder) { 10 | builder.addChannelOption(option => option 11 | .setName('channel') 12 | .setDescription('Join log channel') 13 | .setRequired(false) 14 | ); 15 | return super.buildOptions(builder); 16 | } 17 | 18 | async execute(interaction) { 19 | const channel = await this.getChannel(interaction); 20 | 21 | if (channel === false) { 22 | return; 23 | } 24 | 25 | const guildSettings = await GuildSettings.get(interaction.guildId); 26 | guildSettings.joinLogChannel = channel ? channel.id : channel; 27 | await guildSettings.save(); 28 | const embed = new EmbedWrapper(); 29 | if (channel) { 30 | embed.setDescription(`Set join log to ${channelMention(channel.id)}.`) 31 | .setColor(colors.GREEN); 32 | } 33 | else { 34 | embed.setDescription('Disabled join log.') 35 | .setColor(colors.RED); 36 | } 37 | await interaction.reply(embed.toMessage()); 38 | } 39 | 40 | getDescription() { 41 | return 'Set the channel where joins and leaves messages are logged'; 42 | } 43 | 44 | getName() { 45 | return 'join-log'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/settings/LinkCoolDownCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import {formatTime, parseTime} from '../../util/timeutils.js'; 3 | import GuildSettings from '../../settings/GuildSettings.js'; 4 | import EmbedWrapper from '../../formatting/embeds/EmbedWrapper.js'; 5 | import colors from '../../util/colors.js'; 6 | 7 | export default class LinkCoolDownCommand extends SubCommand { 8 | 9 | buildOptions(builder) { 10 | builder.addStringOption(option => option 11 | .setName('cool-down') 12 | .setDescription('Cool-down for users sending links.') 13 | ); 14 | return super.buildOptions(builder); 15 | } 16 | 17 | async execute(interaction) { 18 | const coolDown = parseTime(interaction.options.getString('cool-down')), 19 | guildSettings = await GuildSettings.get(interaction.guildId); 20 | 21 | if (coolDown) { 22 | guildSettings.linkCooldown = coolDown; 23 | await guildSettings.save(); 24 | await interaction.reply(new EmbedWrapper() 25 | .setDescription(`Set link-cool-down to ${formatTime(coolDown)}`) 26 | .setColor(colors.GREEN) 27 | .toMessage()); 28 | } else { 29 | guildSettings.linkCooldown = -1; 30 | await guildSettings.save(); 31 | await interaction.reply(new EmbedWrapper() 32 | .setDescription('Disabled link-cool-down.') 33 | .setColor(colors.RED) 34 | .toMessage()); 35 | } 36 | } 37 | 38 | getDescription() { 39 | return 'Set a cool-down on sending links.'; 40 | } 41 | 42 | getName() { 43 | return 'link-cool-down'; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/settings/LogChannelCommand.js: -------------------------------------------------------------------------------- 1 | import GuildSettings from '../../settings/GuildSettings.js'; 2 | import EmbedWrapper from '../../formatting/embeds/EmbedWrapper.js'; 3 | import colors from '../../util/colors.js'; 4 | import AbstractChannelCommand from './AbstractChannelCommand.js'; 5 | import {channelMention} from 'discord.js'; 6 | 7 | export default class LogChannelCommand extends AbstractChannelCommand { 8 | 9 | buildOptions(builder) { 10 | builder.addChannelOption(option => option 11 | .setName('channel') 12 | .setDescription('Log channel') 13 | .setRequired(false) 14 | ); 15 | return super.buildOptions(builder); 16 | } 17 | 18 | async execute(interaction) { 19 | const channel = await this.getChannel(interaction); 20 | 21 | if (channel === false) { 22 | return; 23 | } 24 | 25 | const guildSettings = await GuildSettings.get(interaction.guildId); 26 | guildSettings.logChannel = channel ? channel.id : channel; 27 | await guildSettings.save(); 28 | const embed = new EmbedWrapper(); 29 | if (channel) { 30 | embed.setDescription(`Set log channel to ${channelMention(channel.id)}.`) 31 | .setColor(colors.GREEN); 32 | } 33 | else { 34 | embed.setDescription('Disabled log channel.') 35 | .setColor(colors.RED); 36 | } 37 | await interaction.reply(embed.toMessage()); 38 | } 39 | 40 | getDescription() { 41 | return 'Set the log channel'; 42 | } 43 | 44 | getName() { 45 | return 'log-channel'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/settings/MessageLogCommand.js: -------------------------------------------------------------------------------- 1 | import GuildSettings from '../../settings/GuildSettings.js'; 2 | import EmbedWrapper from '../../formatting/embeds/EmbedWrapper.js'; 3 | import colors from '../../util/colors.js'; 4 | import AbstractChannelCommand from './AbstractChannelCommand.js'; 5 | import {channelMention} from 'discord.js'; 6 | 7 | export default class MessageLogCommand extends AbstractChannelCommand { 8 | 9 | buildOptions(builder) { 10 | builder.addChannelOption(option => option 11 | .setName('channel') 12 | .setDescription('Message log channel') 13 | .setRequired(false) 14 | ); 15 | return super.buildOptions(builder); 16 | } 17 | 18 | async execute(interaction) { 19 | const channel = await this.getChannel(interaction); 20 | 21 | if (channel === false) { 22 | return; 23 | } 24 | 25 | const guildSettings = await GuildSettings.get(interaction.guildId); 26 | guildSettings.messageLogChannel = channel ? channel.id : channel; 27 | await guildSettings.save(); 28 | const embed = new EmbedWrapper(); 29 | if (channel) { 30 | embed.setDescription(`Set message log to ${channelMention(channel.id)}.`) 31 | .setColor(colors.GREEN); 32 | } 33 | else { 34 | embed.setDescription('Disabled message log.') 35 | .setColor(colors.RED); 36 | } 37 | await interaction.reply(embed.toMessage()); 38 | } 39 | 40 | getDescription() { 41 | return 'Set the channel where deleted/edited messages are logged'; 42 | } 43 | 44 | getName() { 45 | return 'message-log'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/settings/MutedRoleCommandGroup.js: -------------------------------------------------------------------------------- 1 | import SubCommandGroup from '../SubCommandGroup.js'; 2 | import CreateMutedRoleCommand from './muted-role/CreateMutedRoleCommand.js'; 3 | import SetMutedRoleCommand from './muted-role/SetMutedRoleCommand.js'; 4 | import DisableMutedRoleCommand from './muted-role/DisableMutedRoleCommand.js'; 5 | 6 | export default class MutedRoleCommandGroup extends SubCommandGroup { 7 | 8 | getChildren() { 9 | return [ 10 | new CreateMutedRoleCommand(), 11 | new SetMutedRoleCommand(), 12 | new DisableMutedRoleCommand(), 13 | ]; 14 | } 15 | 16 | getDescription() { 17 | return 'Manage the muted role (required for long or permanent mutes)'; 18 | } 19 | 20 | getName() { 21 | return 'muted-role'; 22 | } 23 | } -------------------------------------------------------------------------------- /src/commands/settings/PlaylistCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import ErrorEmbed from '../../formatting/embeds/ErrorEmbed.js'; 3 | import GuildSettings from '../../settings/GuildSettings.js'; 4 | import EmbedWrapper from '../../formatting/embeds/EmbedWrapper.js'; 5 | import colors from '../../util/colors.js'; 6 | import commandManager from '../CommandManager.js'; 7 | import config from '../../bot/Config.js'; 8 | import YouTubePlaylist from '../../apis/YouTubePlaylist.js'; 9 | import {MessageFlags} from 'discord.js'; 10 | 11 | const PLAYLIST_REGEX = /^(?:(?:https?:\/\/)?(?:www\.)?youtube\.com\/.*[&?]list=)?([a-zA-Z0-9\-_]+?)(?:&.*)?$/i; 12 | 13 | export default class PlaylistCommand extends SubCommand { 14 | 15 | buildOptions(builder) { 16 | builder.addStringOption(option => option 17 | .setName('playlist') 18 | .setDescription('YouTube playlist url or id.') 19 | .setRequired(false) 20 | ); 21 | return super.buildOptions(builder); 22 | } 23 | 24 | async execute(interaction) { 25 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 26 | const option = interaction.options.getString('playlist'); 27 | const guildSettings = await GuildSettings.get(interaction.guildId); 28 | 29 | if (!option) { 30 | guildSettings.playlist = null; 31 | await guildSettings.save(); 32 | await commandManager.updateCommandsForGuild(interaction.guild); 33 | return await interaction.editReply(new EmbedWrapper() 34 | .setDescription('Disabled playlist') 35 | .setColor(colors.RED) 36 | .toMessage() 37 | ); 38 | } 39 | 40 | if (!config.data.googleApiKey) { 41 | return await interaction.editReply(ErrorEmbed 42 | .message('There is no google API key configured for this instance of ModBot!')); 43 | } 44 | 45 | const id = this.getPlaylistId(option); 46 | if (!id) { 47 | return await interaction.editReply(ErrorEmbed.message('Invalid playlist URL!')); 48 | } 49 | 50 | if (!await YouTubePlaylist.isValidPlaylist(id)) { 51 | await interaction.editReply(ErrorEmbed 52 | .message('Playlist not found. Make sure the playlist is public or unlisted and the link is correct.')); 53 | return; 54 | } 55 | 56 | guildSettings.getPlaylist()?.clearCache(); 57 | guildSettings.playlist = id; 58 | const playlist = new YouTubePlaylist(id); 59 | 60 | await guildSettings.save(); 61 | await commandManager.updateCommandsForGuild(interaction.guild); 62 | await interaction.editReply(new EmbedWrapper() 63 | .setDescription(`Set playlist to ${playlist.getFormattedUrl()}`) 64 | .setColor(colors.GREEN) 65 | .toMessage() 66 | ); 67 | } 68 | 69 | /** 70 | * @param {string} url 71 | * @returns {?string} 72 | */ 73 | getPlaylistId(url) { 74 | const match = url.match(PLAYLIST_REGEX); 75 | if (!match) { 76 | return null; 77 | } 78 | 79 | return match[1]; 80 | } 81 | 82 | getDescription() { 83 | return 'Configure YouTube playlist used for the video command'; 84 | } 85 | 86 | getName() { 87 | return 'playlist'; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/commands/settings/ProtectedRolesCommandGroup.js: -------------------------------------------------------------------------------- 1 | import SubCommandGroup from '../SubCommandGroup.js'; 2 | import AddProtectedRoleCommand from './protected-roles/AddProtectedRoleCommand.js'; 3 | import RemoveProtectedRoleCommand from './protected-roles/RemoveProtectedRoleCommand.js'; 4 | import ListProtectedRolesCommand from './protected-roles/ListProtectedRolesCommand.js'; 5 | 6 | export default class ProtectedRolesCommandGroup extends SubCommandGroup { 7 | 8 | getChildren() { 9 | return [ 10 | new AddProtectedRoleCommand(), 11 | new RemoveProtectedRoleCommand(), 12 | new ListProtectedRolesCommand(), 13 | ]; 14 | } 15 | 16 | getDescription() { 17 | return 'Manage roles protected from moderations'; 18 | } 19 | 20 | getName() { 21 | return 'protected-roles'; 22 | } 23 | } -------------------------------------------------------------------------------- /src/commands/settings/PunishmentsCommandGroup.js: -------------------------------------------------------------------------------- 1 | import SubCommandGroup from '../SubCommandGroup.js'; 2 | import ShowPunishmentsCommand from './punishments/ShowPunishmentsCommand.js'; 3 | import SetPunishmentsCommand from './punishments/SetPunishmentsCommand.js'; 4 | 5 | export default class PunishmentsCommandGroup extends SubCommandGroup { 6 | 7 | getChildren() { 8 | return [ 9 | new ShowPunishmentsCommand(), 10 | new SetPunishmentsCommand(), 11 | ]; 12 | } 13 | 14 | getDescription() { 15 | return 'Show and manage punishments for reaching specific strike counts.'; 16 | } 17 | 18 | getName() { 19 | return 'punishments'; 20 | } 21 | } -------------------------------------------------------------------------------- /src/commands/settings/SettingsCommand.js: -------------------------------------------------------------------------------- 1 | import ParentCommand from '../ParentCommand.js'; 2 | import {PermissionFlagsBits, PermissionsBitField} from 'discord.js'; 3 | import SettingsOverviewCommand from './SettingsOverviewCommand.js'; 4 | import LogChannelCommand from './LogChannelCommand.js'; 5 | import MessageLogCommand from './MessageLogCommand.js'; 6 | import JoinLogCommand from './JoinLogCommand.js'; 7 | import SpamCommand from './SpamCommand.js'; 8 | import AutoResponseCommand from './AutoResponseCommand.js'; 9 | import CapsCommand from './CapsCommand.js'; 10 | import HelpCenterCommand from './HelpCenterCommand.js'; 11 | import PlaylistCommand from './PlaylistCommand.js'; 12 | import SimilarMessagesCommand from './SimilarMessagesCommand.js'; 13 | import PunishmentsCommandGroup from './PunishmentsCommandGroup.js'; 14 | import ProtectedRolesCommandGroup from './ProtectedRolesCommandGroup.js'; 15 | import MutedRoleCommandGroup from './MutedRoleCommandGroup.js'; 16 | import LinkCoolDownCommand from './LinkCoolDownCommand.js'; 17 | import InvitesCommandGroup from './InvitesCommandGroup.js'; 18 | import BadWordCommand from './BadWordCommand.js'; 19 | import AttachmentCoolDownCommand from './AttachmentCoolDownCommand.js'; 20 | 21 | export default class SettingsCommand extends ParentCommand { 22 | 23 | getDefaultMemberPermissions() { 24 | return new PermissionsBitField() 25 | .add(PermissionFlagsBits.ManageGuild); 26 | } 27 | 28 | getChildren() { 29 | return [ 30 | new SettingsOverviewCommand(), 31 | 32 | // Logging 33 | new LogChannelCommand(), 34 | new MessageLogCommand(), 35 | new JoinLogCommand(), 36 | 37 | new PunishmentsCommandGroup(), 38 | new ProtectedRolesCommandGroup(), 39 | new MutedRoleCommandGroup(), 40 | new AutoResponseCommand(), 41 | new BadWordCommand(), 42 | 43 | // Auto Moderation 44 | new SpamCommand(), 45 | new CapsCommand(), 46 | new SimilarMessagesCommand(), 47 | new LinkCoolDownCommand(), 48 | new AttachmentCoolDownCommand(), 49 | new InvitesCommandGroup(), 50 | 51 | // External 52 | new HelpCenterCommand(), 53 | new PlaylistCommand(), 54 | ]; 55 | } 56 | 57 | getDescription() { 58 | return 'View and change guild settings.'; 59 | } 60 | 61 | getName() { 62 | return 'settings'; 63 | } 64 | } -------------------------------------------------------------------------------- /src/commands/settings/SettingsOverviewCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import GuildSettings from '../../settings/GuildSettings.js'; 3 | import { 4 | ButtonStyle, 5 | MessageFlags, 6 | SeparatorSpacingSize, 7 | } from 'discord.js'; 8 | import {componentEmojiIfExists} from '../../util/format.js'; 9 | import icons from '../../util/icons.js'; 10 | import BetterButtonBuilder from '../../formatting/embeds/BetterButtonBuilder.js'; 11 | import MessageBuilder from '../../formatting/MessageBuilder.js'; 12 | 13 | /** 14 | * @import {ButtonBuilder} from 'discord.js'; 15 | */ 16 | 17 | export default class SettingsOverviewCommand extends SubCommand { 18 | 19 | async execute(interaction) { 20 | await interaction.reply(await this.buildMessage(interaction)); 21 | } 22 | 23 | async executeButton(interaction) { 24 | await interaction.update(await this.buildMessage(interaction)); 25 | } 26 | 27 | /** 28 | * 29 | * @param {import('discord.js').Interaction} interaction 30 | * @returns {Promise} 31 | */ 32 | async buildMessage(interaction) { 33 | const guildSettings = await GuildSettings.get(interaction.guildId); 34 | const builder = new MessageBuilder() 35 | .heading(`Settings for ${interaction.guild.name}`) 36 | .separator(false); 37 | 38 | guildSettings.getSettings(builder) 39 | .separator(false, SeparatorSpacingSize.Large) 40 | .button(new BetterButtonBuilder() 41 | .setLabel('Refresh') 42 | .setStyle(ButtonStyle.Secondary) 43 | .setCustomId('settings:overview') 44 | .setEmojiIfPresent(componentEmojiIfExists('refresh', icons.refresh)) 45 | ); 46 | 47 | return { 48 | components: [builder.endComponent()], 49 | flags: MessageFlags.IsComponentsV2 | MessageFlags.Ephemeral, 50 | }; 51 | } 52 | 53 | getDescription() { 54 | return 'Show all settings for this guild'; 55 | } 56 | 57 | getName() { 58 | return 'overview'; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/settings/SimilarMessagesCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import GuildSettings from '../../settings/GuildSettings.js'; 3 | import EmbedWrapper from '../../formatting/embeds/EmbedWrapper.js'; 4 | import colors from '../../util/colors.js'; 5 | 6 | export default class SimilarMessagesCommand extends SubCommand { 7 | buildOptions(builder) { 8 | builder.addIntegerOption(option => option 9 | .setName('value') 10 | .setDescription('Maximum amount of similar messages a user can send per minute.') 11 | .setMinValue(1) 12 | .setMaxValue(60)); 13 | return super.buildOptions(builder); 14 | } 15 | 16 | async execute(interaction) { 17 | const count = interaction.options.getInteger('value') ?? -1; 18 | 19 | const guildSettings = await GuildSettings.get(interaction.guild.id); 20 | guildSettings.similarMessages = count; 21 | await guildSettings.save(); 22 | const embed = new EmbedWrapper(); 23 | 24 | if (count === -1) { 25 | embed.setDescription('Disabled repeated message protection.') 26 | .setColor(colors.GREEN); 27 | } 28 | else { 29 | embed.setDescription(`Set repeated message protection to a maximum of ${count} similar messages per second.`) 30 | .setColor(colors.RED); 31 | } 32 | 33 | await interaction.reply(embed.toMessage()); 34 | } 35 | 36 | getDescription() { 37 | return 'Prevent users from repeating messages'; 38 | } 39 | 40 | getName() { 41 | return 'similar-messages'; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/settings/SpamCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../SubCommand.js'; 2 | import GuildSettings from '../../settings/GuildSettings.js'; 3 | import EmbedWrapper from '../../formatting/embeds/EmbedWrapper.js'; 4 | import colors from '../../util/colors.js'; 5 | 6 | export default class SpamCommand extends SubCommand { 7 | buildOptions(builder) { 8 | builder.addIntegerOption(option => option 9 | .setName('value') 10 | .setDescription('Maximum amount of messages a user can send per minute.') 11 | .setMinValue(1) 12 | .setMaxValue(60)); 13 | return super.buildOptions(builder); 14 | } 15 | 16 | async execute(interaction) { 17 | const count = interaction.options.getInteger('value') ?? -1; 18 | 19 | const guildSettings = await GuildSettings.get(interaction.guild.id); 20 | guildSettings.antiSpam = count; 21 | await guildSettings.save(); 22 | const embed = new EmbedWrapper(); 23 | 24 | if (count === -1) { 25 | embed.setDescription('Disabled spam protection.') 26 | .setColor(colors.GREEN); 27 | } 28 | else { 29 | embed.setDescription(`Set spam protection to a maximum of ${count} messages per second.`) 30 | .setColor(colors.RED); 31 | } 32 | 33 | await interaction.reply(embed.toMessage()); 34 | } 35 | 36 | getDescription() { 37 | return 'Prevent users from quickly sending lots of messages'; 38 | } 39 | 40 | getName() { 41 | return 'spam'; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/settings/auto-response/CompletingAutoResponseCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import AutoResponse from '../../../database/AutoResponse.js'; 3 | import {AUTOCOMPLETE_NAME_LIMIT} from '../../../util/apiLimits.js'; 4 | 5 | /** 6 | * @abstract 7 | */ 8 | export default class CompletingAutoResponseCommand extends SubCommand { 9 | async complete(interaction) { 10 | const focussed = interaction.options.getFocused(true); 11 | switch (focussed.name) { 12 | case 'id': { 13 | const options = []; 14 | 15 | /** @type {import('discord.js').Collection} */ 16 | let autoResponses = await AutoResponse.getAll(interaction.guildId); 17 | 18 | const value = parseInt(focussed.value); 19 | if (value) { 20 | options.unshift({name: value, value: value}); 21 | autoResponses = autoResponses.filter(response => response.id.toString().includes(focussed.value)); 22 | } 23 | 24 | for (const response of autoResponses.values()) { 25 | let name = `[${response.id}] `; 26 | if (response.global) { 27 | name += 'global'; 28 | } 29 | else { 30 | name += response.channels.map(channel => { 31 | channel = (/** @type {import('discord.js').Guild} */ interaction.guild) 32 | .channels.cache.get(channel); 33 | return '#' + (channel?.name ?? 'unknown'); 34 | }).join(', '); 35 | } 36 | name += ` ${response.trigger.type}: ${response.trigger.asContentString()}`; 37 | 38 | options.push({ 39 | name: name.slice(0, AUTOCOMPLETE_NAME_LIMIT), 40 | value: response.id 41 | }); 42 | } 43 | 44 | return options; 45 | } 46 | } 47 | 48 | return super.complete(interaction); 49 | } 50 | } -------------------------------------------------------------------------------- /src/commands/settings/auto-response/DeleteAutoReponseCommand.js: -------------------------------------------------------------------------------- 1 | import CompletingAutoResponseCommand from './CompletingAutoResponseCommand.js'; 2 | import AutoResponse from '../../../database/AutoResponse.js'; 3 | import ErrorEmbed from '../../../formatting/embeds/ErrorEmbed.js'; 4 | import colors from '../../../util/colors.js'; 5 | 6 | export default class DeleteAutoReponseCommand extends CompletingAutoResponseCommand { 7 | 8 | buildOptions(builder) { 9 | builder.addIntegerOption(option => option 10 | .setName('id') 11 | .setDescription('The id of the auto-response you want to delete') 12 | .setMinValue(0) 13 | .setRequired(true) 14 | .setAutocomplete(true) 15 | ); 16 | return super.buildOptions(builder); 17 | } 18 | 19 | async execute(interaction) { 20 | const autoResponse = /** @type {?AutoResponse} */ 21 | await AutoResponse.getByID(interaction.options.getInteger('id', true), interaction.guildId); 22 | 23 | if (!autoResponse) { 24 | await interaction.reply(ErrorEmbed.message('There is no auto-response with this id.')); 25 | return; 26 | } 27 | 28 | await autoResponse.delete(); 29 | await interaction.reply(autoResponse 30 | .embed('Deleted auto-response', colors.RED) 31 | .toMessage() 32 | ); 33 | } 34 | 35 | async executeButton(interaction) { 36 | const parts = interaction.customId.split(':'); 37 | const id = parts[2]; 38 | const autoResponse = /** @type {?AutoResponse} */ 39 | await AutoResponse.getByID(id, interaction.guildId); 40 | 41 | if (!autoResponse) { 42 | await interaction.update({ 43 | embeds: [new ErrorEmbed('There is no auto-response with this id.')], 44 | components: [] 45 | }); 46 | return; 47 | } 48 | 49 | await autoResponse.delete(); 50 | await interaction.update({ 51 | embeds: [autoResponse.embed('Deleted auto-response', colors.RED)], 52 | components: [], 53 | }); 54 | } 55 | 56 | getDescription() { 57 | return 'Delete an auto-response'; 58 | } 59 | 60 | getName() { 61 | return 'delete'; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/settings/auto-response/ListAutoResponseCommand.js: -------------------------------------------------------------------------------- 1 | import AutoResponse from '../../../database/AutoResponse.js'; 2 | import SubCommand from '../../SubCommand.js'; 3 | import {MessageFlags} from 'discord.js'; 4 | 5 | export default class ListAutoResponseCommand extends SubCommand { 6 | 7 | async execute(interaction) { 8 | const embeds = await AutoResponse.getGuildOverview(interaction.guild, 'Auto-response'); 9 | 10 | for (const [index, embed] of embeds.entries()) { 11 | const options = { flags: MessageFlags.Ephemeral, embeds: [embed] }; 12 | if (index === 0) { 13 | await interaction.reply(options); 14 | } 15 | else { 16 | await interaction.followUp(options); 17 | } 18 | } 19 | } 20 | 21 | getDescription() { 22 | return 'List all auto-responses in this guild'; 23 | } 24 | 25 | getName() { 26 | return 'list'; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/settings/auto-response/ShowAutoReponseCommand.js: -------------------------------------------------------------------------------- 1 | import AutoResponse from '../../../database/AutoResponse.js'; 2 | import ErrorEmbed from '../../../formatting/embeds/ErrorEmbed.js'; 3 | import {ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags} from 'discord.js'; 4 | import CompletingAutoResponseCommand from './CompletingAutoResponseCommand.js'; 5 | 6 | export default class ShowAutoReponseCommand extends CompletingAutoResponseCommand { 7 | 8 | buildOptions(builder) { 9 | builder.addIntegerOption(option => option 10 | .setName('id') 11 | .setDescription('The id of the auto-response you want to view') 12 | .setMinValue(0) 13 | .setRequired(true) 14 | .setAutocomplete(true) 15 | ); 16 | return super.buildOptions(builder); 17 | } 18 | 19 | async execute(interaction) { 20 | const autoResponse = /** @type {?AutoResponse} */ 21 | await AutoResponse.getByID(interaction.options.getInteger('id', true), interaction.guildId); 22 | 23 | if (!autoResponse) { 24 | await interaction.reply(ErrorEmbed.message('There is no auto-response with this id.')); 25 | return; 26 | } 27 | 28 | await interaction.reply({ 29 | flags: MessageFlags.Ephemeral, 30 | embeds: [autoResponse.embed()], 31 | components: [ 32 | new ActionRowBuilder() 33 | .addComponents( 34 | /** @type {*} */ 35 | new ButtonBuilder() 36 | .setLabel('Delete') 37 | .setStyle(ButtonStyle.Danger) 38 | .setCustomId(`auto-response:delete:${autoResponse.id}`), 39 | /** @type {*} */ 40 | new ButtonBuilder() 41 | .setLabel('Edit') 42 | .setStyle(ButtonStyle.Secondary) 43 | .setCustomId(`auto-response:edit:${autoResponse.id}`) 44 | ) 45 | ] 46 | }); 47 | } 48 | 49 | getDescription() { 50 | return 'Show a single auto-response'; 51 | } 52 | 53 | getName() { 54 | return 'show'; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/commands/settings/bad-word/CompletingBadWordCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import {AUTOCOMPLETE_NAME_LIMIT} from '../../../util/apiLimits.js'; 3 | import BadWord from '../../../database/BadWord.js'; 4 | 5 | /** 6 | * @abstract 7 | */ 8 | export default class CompletingBadWordCommand extends SubCommand { 9 | async complete(interaction) { 10 | const focussed = interaction.options.getFocused(true); 11 | switch (focussed.name) { 12 | case 'id': { 13 | const options = []; 14 | 15 | /** @type {import('discord.js').Collection} */ 16 | let badWords = await BadWord.getAll(interaction.guildId); 17 | 18 | const value = parseInt(focussed.value); 19 | if (value) { 20 | options.unshift({name: value, value: value}); 21 | badWords = badWords.filter(response => response.id.toString().includes(focussed.value)); 22 | } 23 | 24 | for (const word of badWords.values()) { 25 | let name = `[${word.id}] `; 26 | if (word.global) { 27 | name += 'global'; 28 | } 29 | else { 30 | name += word.channels.map(channel => { 31 | channel = (/** @type {import('discord.js').Guild} */ interaction.guild) 32 | .channels.cache.get(channel); 33 | return '#' + (channel?.name ?? 'unknown'); 34 | }).join(', '); 35 | } 36 | name += ` ${word.trigger.type}: ${word.trigger.asContentString()}`; 37 | 38 | options.push({ 39 | name: name.slice(0, AUTOCOMPLETE_NAME_LIMIT), 40 | value: word.id 41 | }); 42 | } 43 | 44 | return options; 45 | } 46 | } 47 | 48 | return super.complete(interaction); 49 | } 50 | } -------------------------------------------------------------------------------- /src/commands/settings/bad-word/DeleteBadWordCommand.js: -------------------------------------------------------------------------------- 1 | import CompletingBadWordCommand from './CompletingBadWordCommand.js'; 2 | import ErrorEmbed from '../../../formatting/embeds/ErrorEmbed.js'; 3 | import colors from '../../../util/colors.js'; 4 | import BadWord from '../../../database/BadWord.js'; 5 | 6 | export default class DeleteBadWordCommand extends CompletingBadWordCommand { 7 | 8 | buildOptions(builder) { 9 | builder.addIntegerOption(option => option 10 | .setName('id') 11 | .setDescription('The id of the bad-word you want to delete') 12 | .setMinValue(0) 13 | .setRequired(true) 14 | .setAutocomplete(true) 15 | ); 16 | return super.buildOptions(builder); 17 | } 18 | 19 | async execute(interaction) { 20 | const badWord = /** @type {?BadWord} */ 21 | await BadWord.getByID(interaction.options.getInteger('id', true), interaction.guildId); 22 | 23 | if (!badWord) { 24 | await interaction.reply(ErrorEmbed.message('There is no bad-word with this id.')); 25 | return; 26 | } 27 | 28 | await badWord.delete(); 29 | await interaction.reply(badWord 30 | .embed('Deleted bad-word', colors.RED) 31 | .toMessage() 32 | ); 33 | } 34 | 35 | async executeButton(interaction) { 36 | const parts = interaction.customId.split(':'); 37 | const id = parts[2]; 38 | const badWord = /** @type {?BadWord} */ 39 | await BadWord.getByID(id, interaction.guildId); 40 | 41 | if (!badWord) { 42 | await interaction.update({ 43 | embeds: [new ErrorEmbed('There is no bad-word with this id.')], 44 | components: [] 45 | }); 46 | return; 47 | } 48 | 49 | await badWord.delete(); 50 | await interaction.update({ 51 | embeds: [badWord.embed('Deleted bad-word', colors.RED)], 52 | components: [], 53 | }); 54 | } 55 | 56 | getDescription() { 57 | return 'Delete a bad-word'; 58 | } 59 | 60 | getName() { 61 | return 'delete'; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/settings/bad-word/ListBadWordCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import BadWord from '../../../database/BadWord.js'; 3 | import {MessageFlags} from 'discord.js'; 4 | 5 | export default class ListBadWordCommand extends SubCommand { 6 | 7 | async execute(interaction) { 8 | const embeds = await BadWord.getGuildOverview(interaction.guild, 'Bad-word'); 9 | 10 | for (const [index, embed] of embeds.entries()) { 11 | const options = { flags: MessageFlags.Ephemeral, embeds: [embed] }; 12 | if (index === 0) { 13 | await interaction.reply(options); 14 | } 15 | else { 16 | await interaction.followUp(options); 17 | } 18 | } 19 | } 20 | 21 | getDescription() { 22 | return 'List all bad-words in this guild'; 23 | } 24 | 25 | getName() { 26 | return 'list'; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/settings/bad-word/ShowBadWordCommand.js: -------------------------------------------------------------------------------- 1 | import ErrorEmbed from '../../../formatting/embeds/ErrorEmbed.js'; 2 | import {ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags} from 'discord.js'; 3 | import CompletingBadWordCommand from './CompletingBadWordCommand.js'; 4 | import BadWord from '../../../database/BadWord.js'; 5 | 6 | export default class ShowBadWordCommand extends CompletingBadWordCommand { 7 | 8 | buildOptions(builder) { 9 | builder.addIntegerOption(option => option 10 | .setName('id') 11 | .setDescription('The id of the bad-word you want to view') 12 | .setMinValue(0) 13 | .setRequired(true) 14 | .setAutocomplete(true) 15 | ); 16 | return super.buildOptions(builder); 17 | } 18 | 19 | async execute(interaction) { 20 | const badWord = /** @type {?BadWord} */ 21 | await BadWord.getByID(interaction.options.getInteger('id', true), interaction.guildId); 22 | 23 | if (!badWord) { 24 | await interaction.reply(ErrorEmbed.message('There is no bad-word with this id.')); 25 | return; 26 | } 27 | 28 | await interaction.reply({ 29 | flags: MessageFlags.Ephemeral, 30 | embeds: [badWord.embed()], 31 | components: [ 32 | new ActionRowBuilder() 33 | .addComponents( 34 | /** @type {*} */ 35 | new ButtonBuilder() 36 | .setLabel('Delete') 37 | .setStyle(ButtonStyle.Danger) 38 | .setCustomId(`bad-word:delete:${badWord.id}`), 39 | /** @type {*} */ 40 | new ButtonBuilder() 41 | .setLabel('Edit') 42 | .setStyle(ButtonStyle.Secondary) 43 | .setCustomId(`bad-word:edit:${badWord.id}`) 44 | ) 45 | ] 46 | }); 47 | } 48 | 49 | getDescription() { 50 | return 'Show a single bad-word'; 51 | } 52 | 53 | getName() { 54 | return 'show'; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/commands/settings/invites/SetInvitesCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import GuildSettings from '../../../settings/GuildSettings.js'; 3 | import ChannelSettings from '../../../settings/ChannelSettings.js'; 4 | import {getEmbed} from './ShowInvitesCommand.js'; 5 | import ErrorEmbed from '../../../formatting/embeds/ErrorEmbed.js'; 6 | 7 | export default class SetInvitesCommand extends SubCommand { 8 | 9 | buildOptions(builder) { 10 | builder.addStringOption(option => option 11 | .setName('invites') 12 | .setDescription('Are invites allowed?') 13 | .addChoices( 14 | { name: 'Allowed', value: 'allowed' }, 15 | { name: 'Forbidden', value: 'forbidden' }, 16 | { name: 'Default (only available for channels)', value: 'default' }, 17 | ) 18 | .setRequired(true) 19 | ); 20 | builder.addChannelOption(option => option 21 | .setName('channel') 22 | .setDescription('get the configuration for this channel') 23 | ); 24 | return super.buildOptions(builder); 25 | } 26 | 27 | async execute(interaction) { 28 | const channel = interaction.options.getChannel('channel'); 29 | let invites = null; 30 | 31 | switch (interaction.options.getString('invites')) { 32 | case 'default': 33 | invites = null; 34 | break; 35 | 36 | case 'allowed': 37 | invites = true; 38 | break; 39 | 40 | case 'forbidden': 41 | invites = false; 42 | break; 43 | } 44 | 45 | if (channel) { 46 | const channelSettings = await ChannelSettings.get(channel.id); 47 | channelSettings.invites = invites; 48 | await channelSettings.save(); 49 | await interaction.reply(await getEmbed(interaction.guildId, channel)); 50 | } 51 | else { 52 | if (invites === null) { 53 | return await interaction.reply(ErrorEmbed 54 | .message('The option \'default\' is only allowed for channel overrides.')); 55 | } 56 | 57 | const guildSettings = await GuildSettings.get(interaction.guildId); 58 | guildSettings.invites = invites; 59 | await guildSettings.save(); 60 | await interaction.reply(await getEmbed(interaction.guildId)); 61 | } 62 | } 63 | 64 | getDescription() { 65 | return 'Configure if users are allowed to post invites (in this channel)'; 66 | } 67 | 68 | getName() { 69 | return 'set'; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/settings/invites/ShowInvitesCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import GuildSettings from '../../../settings/GuildSettings.js'; 3 | import EmbedWrapper from '../../../formatting/embeds/EmbedWrapper.js'; 4 | import {channelMention} from 'discord.js'; 5 | import colors from '../../../util/colors.js'; 6 | import ChannelSettings from '../../../settings/ChannelSettings.js'; 7 | 8 | /** 9 | * Get the string representation of the allowed status 10 | * @param {boolean} boolean 11 | * @returns {string} 12 | */ 13 | function allowed(boolean) { 14 | return boolean ? 'allowed' : 'forbidden'; 15 | } 16 | 17 | /** 18 | * Get the color representation of the allowed status 19 | * @param {boolean} boolean 20 | * @returns {number} 21 | */ 22 | function color(boolean) { 23 | return boolean ? colors.GREEN : colors.RED; 24 | } 25 | 26 | /** 27 | * generate an invite embed 28 | * @param {import('discord.js').Snowflake} guildId 29 | * @param {?import('discord.js').Channel} channel 30 | * @returns {Promise<{flags: number, embeds: EmbedWrapper[]}>} 31 | */ 32 | export async function getEmbed(guildId, channel = null) { 33 | const embed = new EmbedWrapper(), 34 | guildSettings = await GuildSettings.get(guildId); 35 | 36 | if (channel) { 37 | const channelSettings = await ChannelSettings.get(channel.id); 38 | if (channelSettings.invites === null) { 39 | embed.setDescription(`There is no override for ${channelMention(channel.id)}. Default: ${allowed(guildSettings.invites)}`) 40 | .setColor(color(guildSettings.invites)); 41 | } 42 | else { 43 | embed.setDescription(`Invites are ${allowed(channelSettings.invites)} in ${channelMention(channel.id)}.`) 44 | .setColor(color(channelSettings.invites)); 45 | } 46 | } 47 | else { 48 | embed.setDescription(`Invites are ${allowed(guildSettings.invites)} per default.`) 49 | .setColor(color(guildSettings.invites)); 50 | } 51 | return embed.toMessage(); 52 | } 53 | 54 | export default class ShowInvitesCommand extends SubCommand { 55 | 56 | buildOptions(builder) { 57 | builder.addChannelOption(option => option 58 | .setName('channel') 59 | .setDescription('get the configuration for this channel') 60 | ); 61 | return super.buildOptions(builder); 62 | } 63 | 64 | async execute(interaction) { 65 | const channel = interaction.options.getChannel('channel'); 66 | await interaction.reply(await getEmbed(interaction.guildId, channel)); 67 | } 68 | 69 | getDescription() { 70 | return 'Check if users are allowed to post invites (in this channel)'; 71 | } 72 | 73 | getName() { 74 | return 'show'; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/commands/settings/muted-role/CreateMutedRoleCommand.js: -------------------------------------------------------------------------------- 1 | import AbstractMutedRoleCommand from './AbstractMutedRoleCommand.js'; 2 | 3 | export default class CreateMutedRoleCommand extends AbstractMutedRoleCommand { 4 | async execute(interaction) { 5 | const role = await interaction.guild.roles.create({name: 'Muted', hoist: false}); 6 | await this.setMutedRole(interaction, role); 7 | } 8 | 9 | getDescription() { 10 | return 'Create a muted role (required for long or permanent mutes)'; 11 | } 12 | 13 | getName() { 14 | return 'create'; 15 | } 16 | } -------------------------------------------------------------------------------- /src/commands/settings/muted-role/DisableMutedRoleCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import GuildSettings from '../../../settings/GuildSettings.js'; 3 | import EmbedWrapper from '../../../formatting/embeds/EmbedWrapper.js'; 4 | import colors from '../../../util/colors.js'; 5 | 6 | export default class DisableMutedRoleCommand extends SubCommand { 7 | async execute(interaction) { 8 | const guildSettings = await GuildSettings.get(interaction.guildId); 9 | guildSettings.mutedRole = null; 10 | await guildSettings.save(); 11 | await interaction.reply(new EmbedWrapper() 12 | .setDescription('The muted role has been disabled.') 13 | .setFooter({text: 'This command doesn\'t unmute currently muted members!'}) 14 | .setColor(colors.GREEN) 15 | .toMessage()); 16 | } 17 | 18 | getDescription() { 19 | return 'Disable the muted role'; 20 | } 21 | 22 | getName() { 23 | return 'disable'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/commands/settings/muted-role/SetMutedRoleCommand.js: -------------------------------------------------------------------------------- 1 | import AbstractMutedRoleCommand from './AbstractMutedRoleCommand.js'; 2 | 3 | export default class SetMutedRoleCommand extends AbstractMutedRoleCommand { 4 | 5 | buildOptions(builder) { 6 | builder.addRoleOption(option => option 7 | .setName('role') 8 | .setDescription('The role you want to use for muted members') 9 | .setRequired(true) 10 | ); 11 | return super.buildOptions(builder); 12 | } 13 | 14 | async execute(interaction) { 15 | const role = interaction.options.getRole('role', true); 16 | await this.setMutedRole(interaction, 17 | /** @type {import('discord.js').Role} */ role); 18 | } 19 | 20 | getDescription() { 21 | return 'Set a muted role (required for long or permanent mutes)'; 22 | } 23 | 24 | getName() { 25 | return 'set'; 26 | } 27 | } -------------------------------------------------------------------------------- /src/commands/settings/protected-roles/AddProtectedRoleCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import GuildSettings from '../../../settings/GuildSettings.js'; 3 | import EmbedWrapper from '../../../formatting/embeds/EmbedWrapper.js'; 4 | import colors from '../../../util/colors.js'; 5 | import {roleMention} from 'discord.js'; 6 | 7 | export default class AddProtectedRoleCommand extends SubCommand { 8 | 9 | buildOptions(builder) { 10 | builder.addRoleOption(option => option 11 | .setName('role') 12 | .setDescription('The role you want to protect from being moderated') 13 | .setRequired(true) 14 | ); 15 | return super.buildOptions(builder); 16 | } 17 | 18 | async execute(interaction) { 19 | const role = interaction.options.getRole('role', true); 20 | const guildSettings = await GuildSettings.get(interaction.guildId); 21 | const embed = new EmbedWrapper(); 22 | 23 | if (guildSettings.protectedRoles.has(role.id)) { 24 | embed.setColor(colors.RED) 25 | .setDescription(`${roleMention(role.id)} is already a protected role!`); 26 | } 27 | else { 28 | guildSettings.protectedRoles.add(role.id); 29 | await guildSettings.save(); 30 | embed.setColor(colors.GREEN) 31 | .setDescription(`Added ${roleMention(role.id)} to the protected roles!`); 32 | } 33 | 34 | await interaction.reply(embed.toMessage()); 35 | } 36 | 37 | getDescription() { 38 | return 'Add a protected role'; 39 | } 40 | 41 | getName() { 42 | return 'add'; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/settings/protected-roles/ListProtectedRolesCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import GuildSettings from '../../../settings/GuildSettings.js'; 3 | import LineEmbed from '../../../formatting/embeds/LineEmbed.js'; 4 | import {roleMention} from 'discord.js'; 5 | import GuildWrapper from '../../../discord/GuildWrapper.js'; 6 | 7 | export default class ListProtectedRolesCommand extends SubCommand { 8 | 9 | async execute(interaction) { 10 | const guildSettings = await GuildSettings.get(interaction.guildId); 11 | const embed = new LineEmbed() 12 | .setTitle('Protected roles') 13 | .setDescription('This server has no protected roles'); 14 | 15 | const guild = new GuildWrapper(interaction.guild); 16 | 17 | const validRoles = new Set(); 18 | for (const role of guildSettings.protectedRoles) { 19 | if (await guild.fetchRole(role)) { 20 | validRoles.add(role); 21 | } 22 | embed.addLine(`- ${roleMention(role)}`); 23 | } 24 | if (validRoles !== guildSettings.protectedRoles) { 25 | guildSettings.protectedRoles = validRoles; 26 | await guildSettings.save(); 27 | } 28 | 29 | await interaction.reply(embed.toMessage()); 30 | } 31 | 32 | getDescription() { 33 | return 'List the protected roles'; 34 | } 35 | 36 | getName() { 37 | return 'list'; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/settings/protected-roles/RemoveProtectedRoleCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import GuildSettings from '../../../settings/GuildSettings.js'; 3 | import EmbedWrapper from '../../../formatting/embeds/EmbedWrapper.js'; 4 | import colors from '../../../util/colors.js'; 5 | import {roleMention} from 'discord.js'; 6 | 7 | export default class RemoveProtectedRoleCommand extends SubCommand { 8 | 9 | buildOptions(builder) { 10 | builder.addRoleOption(option => option 11 | .setName('role') 12 | .setDescription('The role you want to remove') 13 | .setRequired(true) 14 | ); 15 | return super.buildOptions(builder); 16 | } 17 | 18 | async execute(interaction) { 19 | const role = interaction.options.getRole('role', true); 20 | const guildSettings = await GuildSettings.get(interaction.guildId); 21 | const embed = new EmbedWrapper(); 22 | 23 | if (guildSettings.protectedRoles.has(role.id)) { 24 | guildSettings.protectedRoles.delete(role.id); 25 | await guildSettings.save(); 26 | embed.setColor(colors.RED) 27 | .setDescription(`Removed ${roleMention(role.id)} from the protected roles!`); 28 | } else { 29 | embed.setColor(colors.RED) 30 | .setDescription(`${roleMention(role.id)} is not a protected role!`); 31 | } 32 | 33 | await interaction.reply(embed.toMessage()); 34 | } 35 | 36 | getDescription() { 37 | return 'Remove a protected role'; 38 | } 39 | 40 | getName() { 41 | return 'remove'; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/settings/punishments/SetPunishmentsCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import GuildSettings from '../../../settings/GuildSettings.js'; 3 | import {formatTime, parseTime} from '../../../util/timeutils.js'; 4 | import colors from '../../../util/colors.js'; 5 | import EmbedWrapper from '../../../formatting/embeds/EmbedWrapper.js'; 6 | import Punishment from '../../../database/Punishment.js'; 7 | 8 | export default class SetPunishmentsCommand extends SubCommand { 9 | 10 | buildOptions(builder) { 11 | builder.addIntegerOption(option => option 12 | .setName('strike-count') 13 | .setDescription('Strike count after which this punishment will be applied') 14 | .setMinValue(0) 15 | .setRequired(true) 16 | ); 17 | builder.addStringOption(option => option 18 | .setName('punishment') 19 | .setDescription('Punishment type for reaching this strike count') 20 | .setRequired(true) 21 | .setChoices( 22 | { name: 'Ban user', value: 'ban' }, 23 | { name: 'Kick user', value: 'kick' }, 24 | { name: 'Mute user', value: 'mute' }, 25 | { name: 'Softban user', value: 'softban' }, 26 | { name: 'Remove punishment', value: 'none' }, 27 | ) 28 | ); 29 | builder.addStringOption(option => option 30 | .setName('duration') 31 | .setDescription('Punishment duration (if applicable)') 32 | .setRequired(false) 33 | ); 34 | return super.buildOptions(builder); 35 | } 36 | 37 | async execute(interaction) { 38 | const count = interaction.options.getInteger('strike-count', true), 39 | action = interaction.options.getString('punishment', true), 40 | guildSettings = await GuildSettings.get(interaction.guildId); 41 | 42 | if (action === 'none') { 43 | await guildSettings.setPunishment(count, null); 44 | return interaction.reply(new EmbedWrapper() 45 | .setDescription(`Removed punishment for ${count} ${count === 1 ? 'strike': 'strikes'}`) 46 | .setColor(colors.GREEN) 47 | .toMessage()); 48 | } 49 | 50 | const duration = parseTime(interaction.options.getString('duration')); 51 | await guildSettings.setPunishment(count, new Punishment({ 52 | action, 53 | count, 54 | duration 55 | })); 56 | await interaction.reply(new EmbedWrapper() 57 | .setDescription(`Set punishment for ${count} ${count === 1 ? 'strike': 'strikes'} to ${action}${duration ? ` for ${formatTime(duration)}` : ''}.`) 58 | .setColor(colors.RED) 59 | .toMessage()); 60 | } 61 | 62 | getDescription() { 63 | return 'Set the punishment for reaching a specific strike count.'; 64 | } 65 | 66 | getName() { 67 | return 'set'; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/commands/settings/punishments/ShowPunishmentsCommand.js: -------------------------------------------------------------------------------- 1 | import SubCommand from '../../SubCommand.js'; 2 | import GuildSettings from '../../../settings/GuildSettings.js'; 3 | import KeyValueEmbed from '../../../formatting/embeds/KeyValueEmbed.js'; 4 | import {formatTime} from '../../../util/timeutils.js'; 5 | import colors from '../../../util/colors.js'; 6 | 7 | export default class ShowPunishmentsCommand extends SubCommand { 8 | 9 | async execute(interaction) { 10 | const guildSettings = await GuildSettings.get(interaction.guildId); 11 | const embed = new KeyValueEmbed() 12 | .setTitle('Punishments') 13 | .setDescription('No punishments set up yet. Use /settings punishments set to modify them.') 14 | .setFooter({text: 'Users will receive these punishments when they reach the matching strike counts.\n' + 15 | 'If no punishment is set for a specific strike count the previous punishment will be used'}) 16 | .setColor(colors.RED); 17 | 18 | for (const [count, punishment] of guildSettings.getPunishments().entries()) { 19 | const key = count.toString() + ' ' + (count === 1 ? 'strike': 'strikes'); 20 | let value = punishment.action; 21 | if (punishment.duration) { 22 | value += ` for ${formatTime(punishment.duration)}`; 23 | } 24 | 25 | embed.addPair(key, value); 26 | } 27 | 28 | await interaction.reply(embed.toMessage()); 29 | } 30 | 31 | getDescription() { 32 | return 'Show the punishments for reaching specific strike counts.'; 33 | } 34 | 35 | getName() { 36 | return 'show'; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/user/AvatarCommand.js: -------------------------------------------------------------------------------- 1 | import Command from '../Command.js'; 2 | import { 3 | ALLOWED_SIZES, 4 | escapeMarkdown, 5 | MediaGalleryItemBuilder, 6 | MessageFlags, 7 | } from 'discord.js'; 8 | import MemberWrapper from '../../discord/MemberWrapper.js'; 9 | import GuildWrapper from '../../discord/GuildWrapper.js'; 10 | import MessageBuilder from '../../formatting/MessageBuilder.js'; 11 | 12 | /** 13 | * @type {import('discord.js').ImageURLOptions} 14 | */ 15 | const IMAGE_OPTIONS = { 16 | size: ALLOWED_SIZES.at(-1), 17 | }; 18 | 19 | export default class AvatarCommand extends Command { 20 | isAvailableInDMs() { 21 | return true; 22 | } 23 | 24 | buildOptions(builder) { 25 | builder 26 | .addUserOption(option => 27 | option 28 | .setRequired(false) 29 | .setName('user') 30 | .setDescription('The user who\'s avatar you want to view') 31 | ) 32 | .addBooleanOption(option => 33 | option 34 | .setRequired(false) 35 | .setName('use-server-profile') 36 | .setDescription('Show avatar from server profile if it exists (default: true)') 37 | ); 38 | return super.buildOptions(builder); 39 | } 40 | 41 | async execute(interaction) { 42 | const user = interaction.options.getUser('user') ?? interaction.user; 43 | const useServerProfile = interaction.options.getBoolean('use-server-profile') ?? true; 44 | await interaction.reply(await this.buildMessage(user, interaction.guild, useServerProfile)); 45 | } 46 | 47 | async executeButton(interaction) { 48 | const member = await MemberWrapper.getMemberFromCustomId(interaction); 49 | if (!member) { 50 | return; 51 | } 52 | 53 | await interaction.reply(await this.buildMessage(member.user, interaction.guild, true)); 54 | } 55 | 56 | /** 57 | * build the message 58 | * @param {import('discord.js').User} user 59 | * @param {import('discord.js').Guild|null} guild 60 | * @param {boolean} useServerProfile 61 | * @returns {Promise<{flags: number, components: import('discord.js').ContainerBuilder[]}>} 62 | */ 63 | async buildMessage(user, guild, useServerProfile = true) { 64 | let url = user.displayAvatarURL(IMAGE_OPTIONS); 65 | 66 | const member = new MemberWrapper(user, new GuildWrapper(guild)); 67 | if (guild && useServerProfile && await member.fetchMember()) { 68 | url = member.member.displayAvatarURL(IMAGE_OPTIONS); 69 | } 70 | 71 | const message = new MessageBuilder() 72 | .heading(`Avatar of ${escapeMarkdown(user.displayName)}`) 73 | .image(new MediaGalleryItemBuilder().setURL(url)); 74 | 75 | return { 76 | components: [message.endComponent()], 77 | flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, 78 | }; 79 | } 80 | 81 | getDescription() { 82 | return 'Show the avatar of a user'; 83 | } 84 | 85 | getName() { 86 | return 'avatar'; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/database/Confirmation.js: -------------------------------------------------------------------------------- 1 | import database from './Database.js'; 2 | 3 | /** 4 | * @template T 5 | */ 6 | export default class Confirmation { 7 | 8 | /** 9 | * @param {T} data 10 | * @param {number} expires 11 | * @param {?number} id 12 | */ 13 | constructor(data, expires, id = null) { 14 | this.data = data; 15 | this.expires = expires; 16 | this.id = id; 17 | } 18 | 19 | 20 | /** 21 | * @template T 22 | * @param {number|string} id 23 | * @returns {Promise|null>} 24 | */ 25 | static async get(id) { 26 | if (typeof id === 'string') { 27 | id = parseInt(id); 28 | } 29 | 30 | const data = await database.query('SELECT id, data, expires FROM confirmations WHERE id = ?', id); 31 | if (!data) { 32 | return null; 33 | } 34 | 35 | return new this(JSON.parse(data.data), parseInt(data.expires), id); 36 | } 37 | 38 | /** 39 | * save this confirmation 40 | * @returns {Promise} 41 | */ 42 | async save() { 43 | if (this.id) { 44 | await database.query('UPDATE confirmations SET data = ?, expires = ? WHERE id = ?', 45 | JSON.stringify(this.data), this.expires, this.id); 46 | return this.id; 47 | } else { 48 | const insert = await database.queryAll('INSERT INTO confirmations (data, expires) VALUES (?,?)', 49 | JSON.stringify(this.data), this.expires); 50 | return this.id = insert.insertId; 51 | } 52 | } 53 | 54 | /** 55 | * delete this confirmation 56 | * @returns {Promise} 57 | */ 58 | async delete() { 59 | if (this.id) { 60 | await database.query('DELETE FROM confirmations WHERE id = ?', this.id); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/database/Punishment.js: -------------------------------------------------------------------------------- 1 | import {parseTime} from '../util/timeutils.js'; 2 | 3 | export default class Punishment { 4 | /** 5 | * @type {PunishmentAction} 6 | */ 7 | action; 8 | 9 | /** 10 | * @type {?number|string} 11 | */ 12 | duration = null; 13 | 14 | /** 15 | * @type {?string} 16 | * @deprecated 17 | */ 18 | message = null; 19 | 20 | /** 21 | * @param {object} raw 22 | * @param {PunishmentAction} raw.action 23 | * @param {?number|string} [raw.duration] 24 | * @param {?string} [raw.message] 25 | */ 26 | constructor(raw) { 27 | this.action = raw.action; 28 | this.duration = raw.duration ?? null; 29 | this.message = raw.message ?? null; 30 | } 31 | 32 | /** 33 | * create a new punishment 34 | * @param {PunishmentAction} action 35 | * @param {?string} duration 36 | * @param {?string} message 37 | * @returns {Punishment} 38 | */ 39 | static from(action, duration = null, message = null) { 40 | return new this({action, duration: parseTime(duration), message}); 41 | } 42 | } 43 | 44 | /** 45 | * Possible actions for punishments 46 | * @enum {string} 47 | */ 48 | export const PunishmentAction = { 49 | BAN: 'ban', 50 | KICK: 'kick', 51 | MUTE: 'mute', 52 | SOFTBAN: 'softban', 53 | STRIKE: 'strike', 54 | NONE: 'none', 55 | }; -------------------------------------------------------------------------------- /src/database/WhereParameter.js: -------------------------------------------------------------------------------- 1 | import database from './Database.js'; 2 | 3 | export default class WhereParameter { 4 | /** 5 | * comparison operator 6 | * @type {string} 7 | */ 8 | #comparator; 9 | 10 | constructor(field, value, comparator = '=') { 11 | this.field = field; 12 | this.value = value; 13 | this.#comparator = comparator; 14 | } 15 | 16 | get escapedField() { 17 | return database.escapeId(this.field); 18 | } 19 | 20 | get comparator() { 21 | return this.#comparator ?? '='; 22 | } 23 | 24 | get placeholder() { 25 | if (this.comparator.toUpperCase() === 'IN') { 26 | return '(?)'; 27 | } else { 28 | return '?'; 29 | } 30 | } 31 | 32 | toString() { 33 | return `${this.escapedField} ${this.comparator} ${this.placeholder}`; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/database/export/Exporter.js: -------------------------------------------------------------------------------- 1 | import GuildSettings from '../../settings/GuildSettings.js'; 2 | import ChannelSettings from '../../settings/ChannelSettings.js'; 3 | import Moderation from '../Moderation.js'; 4 | import AutoResponse from '../AutoResponse.js'; 5 | import BadWord from '../BadWord.js'; 6 | 7 | /** 8 | * @import {ChatTriggeredFeature} from '../ChatTriggeredFeature.js'; 9 | * @import {GuildSettingsJSON} from '../../settings/GuildSettings.js'; 10 | */ 11 | 12 | export default class Exporter { 13 | 14 | /** 15 | * @type {string} 16 | */ 17 | dataType = 'modbot-1.0.0'; 18 | 19 | /** 20 | * @type {GuildSettingsJSON} 21 | */ 22 | guildConfig; 23 | 24 | /** 25 | * @type {ChannelSettings[]} 26 | */ 27 | channels; 28 | 29 | /** 30 | * @type {import('discord.js').Snowflake} 31 | */ 32 | guildID; 33 | 34 | /** 35 | * @type {ChatTriggeredFeature[]} 36 | */ 37 | responses; 38 | 39 | /** 40 | * @type {ChatTriggeredFeature[]} 41 | */ 42 | badWords; 43 | 44 | /** 45 | * @type {Moderation[]} 46 | */ 47 | moderations; 48 | 49 | /** 50 | * @param {import('discord.js').Snowflake} guildID 51 | */ 52 | constructor(guildID) { 53 | this.guildID = guildID; 54 | } 55 | 56 | /** 57 | * get all data of this guild as a JSON string 58 | * @returns {Promise} 59 | */ 60 | async export() { 61 | await Promise.all([ 62 | this._getGuildConfig(), 63 | this._getChannelConfigs(), 64 | this._getModerations(), 65 | this._getResponses(), 66 | this._getBadWords() 67 | ]); 68 | 69 | return JSON.stringify(this, null, 2); 70 | } 71 | 72 | async _getGuildConfig() { 73 | this.guildConfig = (await GuildSettings.get(this.guildID)).getDataObject(); 74 | } 75 | 76 | async _getChannelConfigs() { 77 | this.channels = (await ChannelSettings.getForGuild(this.guildID)).map(c => c.getDataObject()); 78 | } 79 | 80 | async _getModerations() { 81 | this.moderations = await Moderation.getAll(this.guildID); 82 | } 83 | 84 | async _getResponses() { 85 | this.responses = Array.from((await AutoResponse.getAll(this.guildID)).values()); 86 | } 87 | 88 | async _getBadWords() { 89 | this.badWords = Array.from((await BadWord.getAll(this.guildID)).values()); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/database/export/Importer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class Importer 3 | * @classdesc Base class for importing data into the DB 4 | * @abstract 5 | */ 6 | export default class Importer { 7 | 8 | /** 9 | * import all data to the DB 10 | * @returns {Promise} 11 | * @abstract 12 | */ 13 | async import() { 14 | throw new Error('Method not implemented'); 15 | } 16 | 17 | /** 18 | * verify that all data is of correct types before importing 19 | * @throws {TypeError} 20 | * @abstract 21 | */ 22 | checkAllTypes() { 23 | throw new Error('Method not implemented'); 24 | } 25 | 26 | /** 27 | * generate an embed showing an overview of imported data 28 | * @returns {import('discord.js').EmbedBuilder} 29 | * @abstract 30 | */ 31 | generateEmbed() { 32 | throw new Error('Method not implemented'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/database/migrations/AutoResponseVisionMigration.js: -------------------------------------------------------------------------------- 1 | import VisionMigration from './VisionMigration.js'; 2 | 3 | export default class AutoResponseVisionMigration extends VisionMigration { 4 | get previousField() { 5 | return 'channels'; 6 | } 7 | 8 | get table() { 9 | return 'responses'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/database/migrations/BadWordVisionMigration.js: -------------------------------------------------------------------------------- 1 | import VisionMigration from './VisionMigration.js'; 2 | 3 | export default class BadWordVisionMigration extends VisionMigration { 4 | get previousField() { 5 | return 'priority'; 6 | } 7 | 8 | get table() { 9 | return 'badWords'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/database/migrations/CommentFieldMigration.js: -------------------------------------------------------------------------------- 1 | import Migration from './Migration.js'; 2 | 3 | export default class CommentFieldMigration extends Migration { 4 | async check() { 5 | /** 6 | * @type {{Field: string, Type: string, Key: string, Default, Extra: string}[]} 7 | */ 8 | const columns = await this.database.queryAll('DESCRIBE `moderations`'); 9 | return !columns.some(column => column.Field === 'comment'); 10 | } 11 | 12 | async run() { 13 | await this.database.query('ALTER TABLE `moderations` ADD COLUMN `comment` TEXT NULL DEFAULT NULL AFTER `reason`'); 14 | } 15 | } -------------------------------------------------------------------------------- /src/database/migrations/DMMigration.js: -------------------------------------------------------------------------------- 1 | import Migration from './Migration.js'; 2 | 3 | export default class DMMigration extends Migration { 4 | 5 | async check() { 6 | /** 7 | * @type {{Field: string, Type: string, Key: string, Default, Extra: string}[]} 8 | */ 9 | const columns = await this.database.queryAll('DESCRIBE `badWords`'); 10 | return !columns.some(column => column.Field === 'dm'); 11 | } 12 | 13 | async run() { 14 | await this.database.query('ALTER TABLE `badWords` ADD COLUMN `dm` TEXT NULL DEFAULT NULL AFTER `priority`'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/database/migrations/IndexMigration.js: -------------------------------------------------------------------------------- 1 | import Migration from './Migration.js'; 2 | 3 | /** 4 | * @import Database from '../bot/Database' 5 | */ 6 | 7 | /** 8 | * A migration that creates an index. 9 | */ 10 | export default class IndexMigration extends Migration { 11 | #table; 12 | #indexName; 13 | #columns; 14 | 15 | /** 16 | * @param {Database} database 17 | * @param {string} table name of the table to create the index on 18 | * @param {string} indexName name of the index to create 19 | * @param {string[]} columns list of columns to include in the index 20 | */ 21 | constructor(database, table, indexName, columns) { 22 | super(database); 23 | this.#table = table; 24 | this.#indexName = indexName; 25 | this.#columns = columns; 26 | } 27 | 28 | 29 | async check() { 30 | let res = await this.database.query(` 31 | SELECT 32 | COUNT(1) indexExists 33 | FROM 34 | INFORMATION_SCHEMA.STATISTICS 35 | WHERE 36 | table_schema = DATABASE() 37 | AND 38 | table_name = ? 39 | AND 40 | index_name = ?; 41 | `, this.#table, this.#indexName); 42 | 43 | return res.indexExists === "0"; 44 | } 45 | 46 | async run() { 47 | await this.database.query(`CREATE INDEX ${this.database.escapeId(this.#indexName)} 48 | ON ${this.database.escapeId(this.#table)} (${this.#columns.map(c => this.database.escapeId(c)).join(`, `)})`); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/database/migrations/Migration.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import Database from '../bot/Database' 3 | */ 4 | 5 | /** 6 | * @class Migration 7 | * @classdesc Base class for all migrations 8 | * @abstract 9 | */ 10 | export default class Migration { 11 | /** 12 | * @param {Database} database 13 | */ 14 | constructor(database) { 15 | this.database = database; 16 | } 17 | 18 | 19 | /** 20 | * Does the migration need to run? 21 | * @returns {Promise} 22 | * @abstract 23 | */ 24 | async check() { 25 | return false; 26 | } 27 | 28 | /** 29 | * Run the migration 30 | * @returns {Promise} 31 | * @abstract 32 | */ 33 | async run() { 34 | throw new Error('Migration not implemented'); 35 | } 36 | } -------------------------------------------------------------------------------- /src/database/migrations/VisionMigration.js: -------------------------------------------------------------------------------- 1 | import Migration from './Migration.js'; 2 | 3 | /** 4 | * @abstract 5 | */ 6 | export default class VisionMigration extends Migration { 7 | /** 8 | * @abstract 9 | */ 10 | get table() { 11 | throw new Error('Not implemented'); 12 | } 13 | 14 | /** 15 | * @abstract 16 | */ 17 | get previousField() { 18 | throw new Error('Not implemented'); 19 | } 20 | 21 | async check() { 22 | /** 23 | * @type {{Field: string, Type: string, Key: string, Default, Extra: string}[]} 24 | */ 25 | const columns = await this.database.queryAll('DESCRIBE ' + this.database.escapeId(this.table)); 26 | return !columns.some(column => column.Field === 'enableVision'); 27 | } 28 | 29 | async run() { 30 | await this.database.query(`ALTER TABLE ${this.database.escapeId(this.table)}` + 31 | `ADD COLUMN \`enableVision\` BOOLEAN DEFAULT FALSE AFTER ${this.database.escapeId(this.previousField)}`); 32 | } 33 | } -------------------------------------------------------------------------------- /src/database/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `channels` 2 | ( 3 | `id` VARCHAR(20) PRIMARY KEY, 4 | `config` TEXT NOT NULL, 5 | `guildid` VARCHAR(20), 6 | KEY `guildid` (`guildid`) 7 | ); 8 | 9 | CREATE TABLE IF NOT EXISTS `guilds` 10 | ( 11 | `id` VARCHAR(20) PRIMARY KEY, 12 | `config` TEXT NOT NULL 13 | ); 14 | 15 | CREATE TABLE IF NOT EXISTS `users` 16 | ( 17 | `id` VARCHAR(20) PRIMARY KEY, 18 | `config` TEXT NOT NULL 19 | ); 20 | 21 | CREATE TABLE IF NOT EXISTS `responses` 22 | ( 23 | `id` int PRIMARY KEY AUTO_INCREMENT, 24 | `guildid` VARCHAR(20) NOT NULL, 25 | `trigger` TEXT NOT NULL, 26 | `response` TEXT NOT NULL, 27 | `global` BOOLEAN NOT NULL, 28 | `channels` TEXT NULL DEFAULT NULL, 29 | `enableVision` BOOLEAN DEFAULT FALSE, 30 | KEY `guildid_global` (`guildid`, `global`) 31 | ); 32 | 33 | CREATE TABLE IF NOT EXISTS `badWords` 34 | ( 35 | `id` int PRIMARY KEY AUTO_INCREMENT, 36 | `guildid` VARCHAR(20) NOT NULL, 37 | `trigger` TEXT NOT NULL, 38 | `punishment` TEXT NOT NULL, 39 | `response` TEXT NOT NULL, 40 | `global` BOOLEAN NOT NULL, 41 | `channels` TEXT NULL DEFAULT NULL, 42 | `priority` int NULL, 43 | `dm` TEXT NULL DEFAULT NULL, 44 | `enableVision` BOOLEAN DEFAULT FALSE, 45 | KEY `guildid_global` (`guildid`, `global`) 46 | ); 47 | 48 | CREATE TABLE IF NOT EXISTS `moderations` 49 | ( 50 | `id` int PRIMARY KEY AUTO_INCREMENT, 51 | `guildid` VARCHAR(20) NOT NULL, 52 | `userid` VARCHAR(20) NOT NULL, 53 | `action` VARCHAR(10) NOT NULL, 54 | `created` bigint NOT NULL, 55 | `value` int DEFAULT 0, 56 | `expireTime` bigint NULL DEFAULT NULL, 57 | `reason` TEXT, 58 | `comment` TEXT NULL DEFAULT NULL, 59 | `moderator` VARCHAR(20) NULL DEFAULT NULL, 60 | `active` BOOLEAN DEFAULT TRUE, 61 | KEY `action_active_expireTime` (`action`, `active`, `expireTime`), 62 | KEY `guildid_userid_action_active` (`guildid`, `userid`, `action`, `active`), 63 | KEY `moderator_guildid_action` (`moderator`, `guildid`, `action`), 64 | KEY `guildid_created` (`guildid`, `created`) 65 | ); 66 | 67 | CREATE TABLE IF NOT EXISTS `confirmations` 68 | ( 69 | `id` int PRIMARY KEY AUTO_INCREMENT, 70 | `data` TEXT NOT NULL, 71 | `expires` bigint NOT NULL, 72 | KEY `expires` (`expires`) 73 | ); 74 | 75 | CREATE TABLE IF NOT EXISTS `safeSearch` 76 | ( 77 | `hash` CHAR(64) PRIMARY KEY, 78 | `data` TEXT NOT NULL 79 | ); 80 | -------------------------------------------------------------------------------- /src/database/triggers/IncludeTrigger.js: -------------------------------------------------------------------------------- 1 | import Trigger from './Trigger.js'; 2 | import {escapeRegExp} from '../../util/util.js'; 3 | import RegexTrigger from './RegexTrigger.js'; 4 | 5 | export default class IncludeTrigger extends Trigger { 6 | constructor(data) { 7 | super({ 8 | type: 'include', 9 | ...data 10 | }); 11 | } 12 | 13 | toRegex() { 14 | return new RegexTrigger({ 15 | content: escapeRegExp(this.content), 16 | flags: this.flags 17 | }); 18 | } 19 | 20 | test(content) { 21 | return content.toLowerCase().includes(this.content.toLowerCase()); 22 | } 23 | 24 | supportsImages() { 25 | return true; 26 | } 27 | } -------------------------------------------------------------------------------- /src/database/triggers/MatchTrigger.js: -------------------------------------------------------------------------------- 1 | import Trigger from './Trigger.js'; 2 | import {escapeRegExp} from '../../util/util.js'; 3 | import RegexTrigger from './RegexTrigger.js'; 4 | 5 | export default class MatchTrigger extends Trigger { 6 | constructor(data) { 7 | super({ 8 | type: 'match', 9 | ...data 10 | }); 11 | } 12 | 13 | toRegex() { 14 | return new RegexTrigger({ 15 | content: '^' + escapeRegExp(this.content) + '$', 16 | flags: this.flags 17 | }); 18 | } 19 | 20 | test(content) { 21 | return content.toLowerCase() === this.content.toLowerCase(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/database/triggers/PhishingTrigger.js: -------------------------------------------------------------------------------- 1 | import Trigger from './Trigger.js'; 2 | import stringSimilarity from 'string-similarity'; 3 | 4 | export default class PhishingTrigger extends Trigger { 5 | constructor(data) { 6 | super({ 7 | type: 'phishing', 8 | ...data 9 | }); 10 | } 11 | 12 | toRegex() { 13 | return this; 14 | } 15 | 16 | test(content) { 17 | // Split domain and min similarity (e.g. discord.com(gg):0.5) 18 | let [domain, similarity] = String(this.content).split(':'); 19 | similarity = parseFloat(similarity) || 0.5; 20 | domain = domain.toLowerCase(); 21 | // Split domain into "main part", extension and alternative extensions 22 | const parts = domain.match(/^([^/]+)\.([^./(]+)(?:\(([^)]+)\))?$/); 23 | if (!parts || !parts[1] || !parts[2]) { 24 | return false; 25 | } 26 | 27 | const expectedDomain = parts[1]; 28 | const expectedExtensions = parts[3] ? [parts[2], ...parts[3].toLowerCase().split(/,\s?/g)] : [parts[2]]; 29 | // Check all domains contained in the content (and split them into "main part" and extension) 30 | const regex = /https?:\/\/([^/]+)\.([^./]+)\b/ig; 31 | 32 | 33 | let matches; 34 | while ((matches = regex.exec(content)) !== null) { 35 | if (!matches[1] || !matches[2]) { 36 | continue; 37 | } 38 | const foundDomain = matches[1].toLowerCase(), 39 | foundExtension = matches[2].toLowerCase(); 40 | const mainPartMatches = foundDomain === expectedDomain || foundDomain.endsWith(`.${expectedDomain}`); 41 | 42 | // Domain is the actual domain or a subdomain of the actual domain -> no phishing 43 | if (mainPartMatches && expectedExtensions.includes(foundExtension)) { 44 | continue; 45 | } 46 | 47 | // "main part" matches, but extension doesn't -> probably phishing 48 | if (mainPartMatches && !expectedExtensions.includes(foundExtension)) { 49 | return true; 50 | } 51 | 52 | // "main part" is very similar to main part of the actual domain -> probably phishing 53 | if (stringSimilarity.compareTwoStrings(expectedDomain, foundDomain) >= similarity) { 54 | return true; 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /src/database/triggers/RegexTrigger.js: -------------------------------------------------------------------------------- 1 | import Trigger from './Trigger.js'; 2 | 3 | export default class RegexTrigger extends Trigger { 4 | constructor(data) { 5 | super({ 6 | type: 'regex', 7 | ...data 8 | }); 9 | } 10 | 11 | /** 12 | * @returns {string} 13 | */ 14 | asContentString() { 15 | return `/${this.content}/${this.flags ?? ''}`; 16 | } 17 | 18 | toRegex() { 19 | return this; 20 | } 21 | 22 | test(content) { 23 | let regex = new RegExp(this.content, this.flags); 24 | return regex.test(content); 25 | } 26 | 27 | supportsImages() { 28 | return true; 29 | } 30 | } -------------------------------------------------------------------------------- /src/database/triggers/Trigger.js: -------------------------------------------------------------------------------- 1 | import {inlineCode} from 'discord.js'; 2 | 3 | /** 4 | * @abstract 5 | */ 6 | export default class Trigger { 7 | /** 8 | * @type {string} 9 | */ 10 | type; 11 | 12 | /** 13 | * @type {string} 14 | */ 15 | content; 16 | 17 | /** 18 | * @type {string} 19 | */ 20 | flags; 21 | 22 | /** 23 | * @param {object} data 24 | * @property {string} type 25 | * @property {string} content 26 | * @property {string} [flags] 27 | */ 28 | constructor(data) { 29 | this.type = data.type; 30 | this.content = data.content; 31 | this.flags = data.flags; 32 | } 33 | 34 | /** 35 | * @returns {string} 36 | */ 37 | asString() { 38 | return `${this.type}: ${inlineCode(this.asContentString())}`; 39 | } 40 | 41 | /** 42 | * @returns {string} 43 | */ 44 | asContentString() { 45 | return this.content; 46 | } 47 | 48 | /** 49 | * Convert this trigger to a regex trigger. 50 | * This is only supported for include and match types. 51 | * @returns {Trigger} 52 | * @abstract 53 | */ 54 | toRegex() { 55 | throw new Error('Not implemented'); 56 | } 57 | 58 | /** 59 | * @param {string} content 60 | * @returns {boolean} 61 | * @abstract 62 | */ 63 | test(content) { 64 | throw new Error('Not implemented'); 65 | } 66 | 67 | /** 68 | * @returns {boolean} 69 | */ 70 | supportsImages() { 71 | return false; 72 | } 73 | } -------------------------------------------------------------------------------- /src/database/triggers/Triggers.js: -------------------------------------------------------------------------------- 1 | import IncludeTrigger from './IncludeTrigger.js'; 2 | import MatchTrigger from './MatchTrigger.js'; 3 | import RegexTrigger from './RegexTrigger.js'; 4 | import PhishingTrigger from './PhishingTrigger.js'; 5 | 6 | export default class Triggers { 7 | static of(data) { 8 | switch (data.type) { 9 | case 'include': 10 | return new IncludeTrigger(data); 11 | case 'match': 12 | return new MatchTrigger(data); 13 | case 'regex': 14 | return new RegexTrigger(data); 15 | case 'phishing': 16 | return new PhishingTrigger(data); 17 | default: 18 | throw new Error(`Invalid trigger type: ${data.type}`); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/discord/RateLimiter.js: -------------------------------------------------------------------------------- 1 | import {Collection} from 'discord.js'; 2 | import database from '../database/Database.js'; 3 | import logger from '../bot/Logger.js'; 4 | 5 | /** 6 | * @import {Guild, User, GuildMember} from 'discord.js'; 7 | */ 8 | 9 | export default class RateLimiter { 10 | static #modCountCache = new Collection(); 11 | static #modCountTimeouts = new Collection(); 12 | 13 | /** 14 | * send a user a direct message 15 | * @param {Guild} guild 16 | * @param {User|GuildMember} user 17 | * @param {string} message 18 | * @returns {Promise} 19 | */ 20 | static async sendDM(guild, user, message) { 21 | let count = this.#modCountCache.get(guild.id); 22 | if (!count) { 23 | count = await database.query( 24 | `SELECT COUNT(*) AS count FROM moderations WHERE guildid = ? AND created >= ?`, 25 | guild.id, 26 | Math.floor(Date.now()/1000) - 60*60*24 27 | ); 28 | count = parseInt(count.count); 29 | } 30 | 31 | this.#modCountCache.set(guild.id, count + 1); 32 | if (!this.#modCountTimeouts.has(guild.id)) { 33 | this.#modCountTimeouts.set(guild.id, setTimeout(() => { 34 | this.#modCountCache.delete(guild.id); 35 | this.#modCountTimeouts.delete(guild.id); 36 | }, 5 * 60 * 1000)); 37 | } 38 | if (count <= guild.memberCount * 0.05 || count <= 5) { 39 | await user.send(message); 40 | } 41 | else { 42 | await logger.warn({ 43 | message: `Guild ${guild.name}(${guild.id}) exceeded DM limit`, 44 | dms: count, 45 | memberCount: guild.memberCount, 46 | guildID: guild.id, 47 | guildName: guild.name 48 | }); 49 | } 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/discord/UserWrapper.js: -------------------------------------------------------------------------------- 1 | import {RESTJSONErrorCodes} from 'discord.js'; 2 | import bot from '../bot/Bot.js'; 3 | 4 | export default class UserWrapper { 5 | 6 | /** 7 | * @type {import('discord.js').Snowflake} 8 | */ 9 | id; 10 | 11 | /** 12 | * @type {import('discord.js').User} 13 | */ 14 | user; 15 | 16 | /** 17 | * 18 | * @param {import('discord.js').Snowflake} id 19 | */ 20 | constructor(id) { 21 | this.id = id; 22 | } 23 | 24 | /** 25 | * fetch this user 26 | * @returns {Promise} 27 | */ 28 | async fetchUser() { 29 | try { 30 | this.user = await bot.client.users.fetch(this.id); 31 | } 32 | catch (e) { 33 | if (e.code === RESTJSONErrorCodes.UnknownUser || e.httpStatus === 404) { 34 | this.user = null; 35 | } 36 | else { 37 | throw e; 38 | } 39 | } 40 | return this.user; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/discord/permissions/SlashCommandPermissionManager.js: -------------------------------------------------------------------------------- 1 | import {RESTJSONErrorCodes} from 'discord.js'; 2 | import SlashCommandPermissionOverrides from './SlashCommandPermissionOverrides.js'; 3 | import bot from '../../bot/Bot.js'; 4 | 5 | /** 6 | * @import {Command} from '../commands/Command.js'; 7 | */ 8 | 9 | /** 10 | * Emulate Discord Slash Command Permissions 11 | * @abstract 12 | */ 13 | export default class SlashCommandPermissionManager { 14 | 15 | /** 16 | * Calculates if a member has the permission to execute a command in a guild 17 | * Uses the older V2 Permission system: https://discord.com/developers/docs/change-log#updated-command-permissions 18 | * @param {import('discord.js').Interaction<"cached">} interaction 19 | * @param {Command} command 20 | * @returns {Promise} 21 | */ 22 | async hasPermission(interaction, command) { 23 | throw new Error('Not implemented'); 24 | } 25 | 26 | /** 27 | * fetch the overrides for a command or application 28 | * @param {import('discord.js').Interaction<"cached">} interaction 29 | * @param {import('discord.js').Snowflake} [commandId] leave empty to fetch global permissions 30 | * @returns {Promise} 31 | */ 32 | async fetchOverrides(interaction, commandId = bot.client.user.id) { 33 | let overrides = []; 34 | try { 35 | overrides = await interaction.guild.commands.permissions.fetch({command: commandId}); 36 | } 37 | catch (e) { 38 | if (e.code === RESTJSONErrorCodes.UnknownApplicationCommandPermissions) { 39 | overrides = []; 40 | } else { 41 | throw e; 42 | } 43 | } 44 | 45 | return new SlashCommandPermissionOverrides(overrides, interaction.guild, interaction.member, interaction.channel); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/discord/permissions/SlashCommandPermissionManagers.js: -------------------------------------------------------------------------------- 1 | import SlashCommandPermissionManagerV2 from './SlashCommandPermissionManagerV2.js'; 2 | import SlashCommandPermissionManagerV3 from './SlashCommandPermissionManagerV3.js'; 3 | import {GuildFeature} from 'discord.js'; 4 | 5 | export default class SlashCommandPermissionManagers { 6 | static V2 = new SlashCommandPermissionManagerV2(); 7 | static V3 = new SlashCommandPermissionManagerV3(); 8 | 9 | /** 10 | * @param {import('discord.js').Interaction<"cached">} interaction 11 | * @returns {SlashCommandPermissionManagerV2|SlashCommandPermissionManagerV3} 12 | */ 13 | static getManager(interaction) { 14 | if (interaction.guild.features.includes(GuildFeature.ApplicationCommandPermissionsV2)) { 15 | return this.V2; 16 | } else { 17 | return this.V3; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/events/EventListener.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class 3 | * @classdesc a task that's run repeatedly 4 | * @abstract 5 | */ 6 | export default class EventListener { 7 | /** 8 | * get the event name 9 | * @abstract 10 | * @returns {string} 11 | */ 12 | get name() { 13 | return 'message'; 14 | } 15 | 16 | /** 17 | * execute this event 18 | * @abstract 19 | */ 20 | async execute() { 21 | 22 | } 23 | } -------------------------------------------------------------------------------- /src/events/EventManager.js: -------------------------------------------------------------------------------- 1 | import logger from '../bot/Logger.js'; 2 | 3 | /** 4 | * @import {EventListener} from '../../events/EventListener.js'; 5 | */ 6 | 7 | export default class EventManager { 8 | 9 | /** 10 | * @abstract 11 | * @returns {EventListener[]} 12 | */ 13 | getEventListeners() { 14 | 15 | } 16 | 17 | /** 18 | * subscribe to event listeners 19 | * @abstract 20 | */ 21 | subscribe() { 22 | 23 | } 24 | 25 | /** 26 | * @param {EventListener} eventListener 27 | * @param {*} args 28 | * @returns {Promise} 29 | */ 30 | async notifyEventListener(eventListener, ...args) { 31 | try { 32 | await eventListener.execute(...args); 33 | } 34 | catch (e) { 35 | try { 36 | await logger.error(`Failed to execute event listener '${eventListener.constructor.name}': ${e.name}`, e); 37 | } 38 | catch (e) { 39 | console.error(e); 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/events/discord/BanRemoveEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../EventListener.js'; 2 | import {escapeMarkdown} from 'discord.js'; 3 | import database from '../../database/Database.js'; 4 | import {formatTime} from '../../util/timeutils.js'; 5 | import GuildWrapper from '../../discord/GuildWrapper.js'; 6 | import KeyValueEmbed from '../../formatting/embeds/KeyValueEmbed.js'; 7 | 8 | export default class BanRemoveEventListener extends EventListener { 9 | get name() { 10 | return 'guildBanRemove'; 11 | } 12 | 13 | /** 14 | * @param {import('discord.js').GuildBan} ban 15 | * @returns {Promise} 16 | */ 17 | async execute(ban) { 18 | const databaseBan = await database.query( 19 | 'SELECT * FROM moderations WHERE action = \'ban\' AND active = TRUE AND userid = ? AND guildid = ?', 20 | ban.user.id, ban.guild.id); 21 | if (databaseBan) { 22 | await database.query( 23 | 'UPDATE moderations SET active = FALSE WHERE action = \'ban\' AND active = TRUE AND userid = ? AND guildid = ?', 24 | ban.user.id, ban.guild.id); 25 | 26 | const embed = new KeyValueEmbed() 27 | .setAuthor({ 28 | name: `Ban ${databaseBan.id} was deleted from guild | ${escapeMarkdown(ban.user.displayName)}`, 29 | iconURL: ban.user.avatarURL() 30 | }) 31 | .setFooter({text: ban.user.id}) 32 | .addPair('User ID', ban.user.id); 33 | 34 | if (databaseBan.expireTime) { 35 | const remaining = databaseBan.expireTime - Math.floor(Date.now()/1000); 36 | embed.addPair('Remaining timer', formatTime(remaining)); 37 | } 38 | await (await GuildWrapper.fetch(ban.guild.id)).log({embeds: [embed]}); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/events/discord/DiscordEventManager.js: -------------------------------------------------------------------------------- 1 | import bot from '../../bot/Bot.js'; 2 | import ErrorEventListener from './ErrorEventListener.js'; 3 | import BanRemoveEventListener from './BanRemoveEventListener.js'; 4 | import GuildDeleteEventListener from './GuildDeleteEventListener.js'; 5 | import LogJoinEventListener from './guildMemberAdd/LogJoinEventListener.js'; 6 | import RestoreMutedRoleEventListener from './guildMemberAdd/RestoreMutedRoleEventListener.js'; 7 | import GuildMemberRemoveEventListener from './GuildMemberRemoveEventListener.js'; 8 | import AutoModEventListener from './messageCreate/AutoModEventListener.js'; 9 | import AutoResponseEventListener from './messageCreate/AutoResponseEventListener.js'; 10 | import EventManager from '../EventManager.js'; 11 | import MessageDeleteEventListener from './MessageDeleteEventListener.js'; 12 | import MessageDeleteBulkEventListener from './MessageDeleteBulkEventListener.js'; 13 | import LogMessageUpdateEventListener from './messageUpdate/LogMessageUpdateEventListener.js'; 14 | import WarnEventListener from './WarnEventListener.js'; 15 | import CommandEventListener from './interactionCreate/CommandEventListener.js'; 16 | import DeleteConfirmationEventListener from './interactionCreate/DeleteConfirmationEventListener.js'; 17 | import AutoModMessageEditEventListener from './messageUpdate/AutoModMessageEditEventListener.js'; 18 | import GuildAuditLogCreateEventListener from './GuildAuditLogCreateEventListener.js'; 19 | 20 | export default class DiscordEventManager extends EventManager { 21 | 22 | subscribe() { 23 | const client = bot.client; 24 | for (const eventListener of this.getEventListeners()) { 25 | client.on(eventListener.name, this.notifyEventListener.bind(this, eventListener)); 26 | } 27 | } 28 | 29 | getEventListeners() { 30 | return [ 31 | new ErrorEventListener(), 32 | new BanRemoveEventListener(), 33 | new GuildDeleteEventListener(), 34 | new WarnEventListener(), 35 | new GuildAuditLogCreateEventListener(), 36 | 37 | // members 38 | // join 39 | new LogJoinEventListener(), 40 | new RestoreMutedRoleEventListener(), 41 | // leave 42 | new GuildMemberRemoveEventListener(), 43 | 44 | // messages 45 | new AutoModEventListener(), 46 | new AutoResponseEventListener(), 47 | new MessageDeleteEventListener(), 48 | new MessageDeleteBulkEventListener(), 49 | new LogMessageUpdateEventListener(), 50 | new AutoModMessageEditEventListener(), 51 | 52 | // interactions 53 | new CommandEventListener(), 54 | new DeleteConfirmationEventListener(), 55 | ]; 56 | } 57 | } -------------------------------------------------------------------------------- /src/events/discord/ErrorEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../EventListener.js'; 2 | import logger from '../../bot/Logger.js'; 3 | 4 | export default class ErrorEventListener extends EventListener { 5 | get name() { 6 | return 'error'; 7 | } 8 | 9 | /** 10 | * @param {Error} error 11 | * @returns {Promise} 12 | */ 13 | async execute(error) { 14 | await logger.error('The discord client experienced an error', error); 15 | } 16 | } -------------------------------------------------------------------------------- /src/events/discord/GuildDeleteEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../EventListener.js'; 2 | import GuildWrapper from '../../discord/GuildWrapper.js'; 3 | 4 | export default class GuildDeleteEventListener extends EventListener { 5 | get name() { 6 | return 'guildDelete'; 7 | } 8 | 9 | /** 10 | * @param {import('discord.js').Guild} guild 11 | * @returns {Promise[]>} 12 | */ 13 | async execute(guild) { 14 | const wrapper = new GuildWrapper(guild); 15 | await wrapper.deleteData(); 16 | } 17 | } -------------------------------------------------------------------------------- /src/events/discord/GuildMemberRemoveEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../EventListener.js'; 2 | import {time, TimestampStyles} from 'discord.js'; 3 | import GuildWrapper from '../../discord/GuildWrapper.js'; 4 | import colors from '../../util/colors.js'; 5 | import KeyValueEmbed from '../../formatting/embeds/KeyValueEmbed.js'; 6 | 7 | export default class GuildMemberRemoveEventListener extends EventListener { 8 | get name() { 9 | return 'guildMemberRemove'; 10 | } 11 | 12 | /** 13 | * @param {import('discord.js').GuildMember} member 14 | * @returns {Promise} 15 | */ 16 | async execute(member) { 17 | const embed = new KeyValueEmbed() 18 | .setTitle(`${member.displayName} left this server`) 19 | .setColor(colors.RED) 20 | .setThumbnail(member.displayAvatarURL()) 21 | .addPair('User ID', member.user.id) 22 | .addPair('Created Account', time(member.user.createdAt, TimestampStyles.RelativeTime)) 23 | .setTimestamp() 24 | .setFooter({text: `Members: ${member.guild.memberCount}`}); 25 | 26 | if (member.joinedTimestamp) { 27 | embed.addPair('Joined', time(member.joinedAt, TimestampStyles.RelativeTime)); 28 | } 29 | 30 | const guild = await GuildWrapper.fetch(member.guild.id); 31 | await guild.logJoin({embeds: [embed]}); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/events/discord/MessageDeleteBulkEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../EventListener.js'; 2 | import {AttachmentBuilder, EmbedBuilder, userMention} from 'discord.js'; 3 | import colors from '../../util/colors.js'; 4 | import GuildWrapper from '../../discord/GuildWrapper.js'; 5 | import {EMBED_DESCRIPTION_LIMIT, MESSAGE_FILE_LIMIT} from '../../util/apiLimits.js'; 6 | 7 | export default class MessageDeleteBulkEventListener extends EventListener { 8 | get name() { 9 | return 'messageDeleteBulk'; 10 | } 11 | 12 | /** 13 | * @param {import('discord.js').Collection} messages 14 | * @param {import('discord.js').GuildTextBasedChannel} channel 15 | * @returns {Promise} 16 | */ 17 | async execute(messages, channel) { 18 | const embed = new EmbedBuilder() 19 | .setTitle(`${messages.size} messages were deleted in ${channel.name}`) 20 | .setColor(colors.RED); 21 | const attachments = []; 22 | for (const message of messages.sort((m1, m2) => m1.createdTimestamp - m2.createdTimestamp).values()) { 23 | for (const attachment of message.attachments.values()) { 24 | attachments.push(new AttachmentBuilder(attachment.attachment) 25 | .setDescription(attachment.description) 26 | .setName(attachment.name) 27 | .setSpoiler(true)); 28 | } 29 | 30 | let content = message.content.replaceAll('\n', ' ').trim(); 31 | let attachmentCount = message.attachments.size; 32 | 33 | if (!content && attachmentCount === 0) { 34 | continue; 35 | } 36 | 37 | let data = `${userMention(message.author.id)} (${message.id})`; 38 | if (attachmentCount) { 39 | data += ` ${attachmentCount} file${attachmentCount === 1 ? '' : 's'}`; 40 | } 41 | if (content) { 42 | data += `: ${content}\n`; 43 | } 44 | 45 | const description = embed.data.description ?? ''; 46 | embed.setDescription((description + data).substring(0, EMBED_DESCRIPTION_LIMIT)); 47 | if (description.length + data.length > EMBED_DESCRIPTION_LIMIT) { 48 | embed.setFooter({text: 'This message was shortened due to Discord API limitations.'}); 49 | break; 50 | } 51 | } 52 | 53 | if (!embed.data.description) { 54 | return; 55 | } 56 | 57 | const guild = new GuildWrapper(channel.guild); 58 | await guild.logMessage({ 59 | embeds: [embed], 60 | files: attachments.slice(0, MESSAGE_FILE_LIMIT) 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/events/discord/MessageDeleteEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../EventListener.js'; 2 | import bot from '../../bot/Bot.js'; 3 | import GuildWrapper from '../../discord/GuildWrapper.js'; 4 | import MessageDeleteEmbed from '../../formatting/embeds/MessageDeleteEmbed.js'; 5 | 6 | export default class MessageDeleteEventListener extends EventListener { 7 | get name() { 8 | return 'messageDelete'; 9 | } 10 | 11 | /** 12 | * @param {import('discord.js').Message} message 13 | * @returns {Promise} 14 | */ 15 | async execute(message) { 16 | if (!message.guild || message.author.bot) { 17 | return; 18 | } 19 | 20 | if (bot.deletedMessages.has(message.id)) { 21 | bot.deletedMessages.delete(message.id); 22 | return; 23 | } 24 | 25 | const embed = new MessageDeleteEmbed(message); 26 | 27 | const guild = new GuildWrapper(message.guild); 28 | await guild.logMessage(embed.toMessage()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/events/discord/WarnEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../EventListener.js'; 2 | import logger from '../../bot/Logger.js'; 3 | 4 | export default class WarnEventListener extends EventListener { 5 | get name() { 6 | return 'warn'; 7 | } 8 | 9 | /** 10 | * @param {string} warning 11 | * @returns {Promise} 12 | */ 13 | async execute(warning) { 14 | await logger.warn({ 15 | message: 'The discord client emitted a warning', 16 | warning 17 | }); 18 | } 19 | } -------------------------------------------------------------------------------- /src/events/discord/guildMemberAdd/GuildMemberAddEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../../EventListener.js'; 2 | 3 | /** 4 | * @abstract 5 | */ 6 | export default class GuildMemberAddEventListener extends EventListener { 7 | get name() { 8 | return 'guildMemberAdd'; 9 | } 10 | 11 | /** 12 | * @abstract 13 | * @param {import('discord.js').GuildMember} member 14 | * @returns {Promise} 15 | */ 16 | async execute(member) { 17 | member.id; 18 | } 19 | } -------------------------------------------------------------------------------- /src/events/discord/guildMemberAdd/LogJoinEventListener.js: -------------------------------------------------------------------------------- 1 | import GuildMemberAddEventListener from './GuildMemberAddEventListener.js'; 2 | import {time, TimestampStyles} from 'discord.js'; 3 | import GuildWrapper from '../../../discord/GuildWrapper.js'; 4 | import colors from '../../../util/colors.js'; 5 | import KeyValueEmbed from '../../../formatting/embeds/KeyValueEmbed.js'; 6 | 7 | export default class LogJoinEventListener extends GuildMemberAddEventListener { 8 | async execute(member) { 9 | const guild = await (GuildWrapper.fetch(member.guild.id)); 10 | const embed = new KeyValueEmbed() 11 | .setTitle(`${member.displayName} joined this server`) 12 | .setColor(colors.GREEN) 13 | .setThumbnail(member.displayAvatarURL()) 14 | .addPair('User ID', member.user.id) 15 | .addPair('Created Account', time(member.user.createdAt, TimestampStyles.RelativeTime)) 16 | .setTimestamp() 17 | .setFooter({text: `Members: ${member.guild.memberCount}`}); 18 | 19 | await guild.logJoin({ 20 | embeds: [embed] 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/events/discord/guildMemberAdd/RestoreMutedRoleEventListener.js: -------------------------------------------------------------------------------- 1 | import GuildMemberAddEventListener from './GuildMemberAddEventListener.js'; 2 | import GuildSettings from '../../../settings/GuildSettings.js'; 3 | import GuildWrapper from '../../../discord/GuildWrapper.js'; 4 | import {escapeMarkdown, RESTJSONErrorCodes} from 'discord.js'; 5 | import database from '../../../database/Database.js'; 6 | import KeyValueEmbed from '../../../formatting/embeds/KeyValueEmbed.js'; 7 | 8 | export default class RestoreMutedRoleEventListener extends GuildMemberAddEventListener { 9 | 10 | async execute(member) { 11 | if (member.communicationDisabledUntilTimestamp) { 12 | return; 13 | } 14 | 15 | const mute = await database.query( 16 | 'SELECT * FROM moderations WHERE action = \'mute\' AND active = TRUE AND userid = ? AND guildid = ?', 17 | member.id,member.guild.id); 18 | 19 | if (mute) { 20 | const guildSettings = await GuildSettings.get(member.guild.id); 21 | 22 | if (!guildSettings.mutedRole) { 23 | return; 24 | } 25 | 26 | try { 27 | await member.roles.add(guildSettings.mutedRole); 28 | } 29 | catch (e) { 30 | if ([RESTJSONErrorCodes.UnknownMember, RESTJSONErrorCodes.UnknownRole].includes(e.code)) { 31 | return; 32 | } 33 | throw e; 34 | } 35 | 36 | const guild = await GuildWrapper.fetch(member.guild.id); 37 | const embed = new KeyValueEmbed() 38 | .setTitle(`Restored mute | ${escapeMarkdown(member.displayName)}`) 39 | .addPair('User ID', member.id) 40 | .setDescription(`Mute ID: ${mute.id}`) 41 | .setFooter({text: member.id}); 42 | await guild.log({embeds: [embed]}); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/events/discord/interactionCreate/CommandEventListener.js: -------------------------------------------------------------------------------- 1 | import InteractionCreateEventListener from './InteractionCreateEventListener.js'; 2 | import { 3 | AutocompleteInteraction, 4 | ButtonInteraction, 5 | ChatInputCommandInteraction, 6 | MessageContextMenuCommandInteraction, 7 | ModalSubmitInteraction, 8 | UserContextMenuCommandInteraction 9 | } from 'discord.js'; 10 | import commandManager from '../../../commands/CommandManager.js'; 11 | 12 | export default class CommandEventListener extends InteractionCreateEventListener { 13 | async execute(interaction) { 14 | if (interaction instanceof ChatInputCommandInteraction) { 15 | await commandManager.execute(interaction); 16 | } 17 | else if (interaction instanceof AutocompleteInteraction) { 18 | await commandManager.autocomplete(interaction); 19 | } 20 | else if (interaction instanceof UserContextMenuCommandInteraction) { 21 | await commandManager.executeUserMenu(interaction); 22 | } 23 | else if (interaction instanceof MessageContextMenuCommandInteraction) { 24 | await commandManager.executeMessageMenu(interaction); 25 | } 26 | else if (interaction instanceof ButtonInteraction) { 27 | await commandManager.executeButton(interaction); 28 | } 29 | else if (interaction instanceof ModalSubmitInteraction) { 30 | await commandManager.executeModal(interaction); 31 | } 32 | else if (interaction.isAnySelectMenu()) { 33 | await commandManager.executeSelectMenu(/** @type {import('discord.js').AnySelectMenuInteraction} */interaction); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/events/discord/interactionCreate/DeleteConfirmationEventListener.js: -------------------------------------------------------------------------------- 1 | import InteractionCreateEventListener from './InteractionCreateEventListener.js'; 2 | import {ButtonInteraction} from 'discord.js'; 3 | import Confirmation from '../../../database/Confirmation.js'; 4 | 5 | export default class DeleteConfirmationEventListener extends InteractionCreateEventListener { 6 | 7 | async execute(interaction) { 8 | if (interaction instanceof ButtonInteraction) { 9 | if (!interaction.customId) { 10 | return; 11 | } 12 | 13 | const parts = interaction.customId.split(':'); 14 | if (parts[0] === 'confirmation') { 15 | const action = parts[1]; 16 | if (action === 'delete') { 17 | const id = parseInt(parts[2]); 18 | const confirmation = new Confirmation(null, 0, id); 19 | await confirmation.delete(); 20 | await interaction.update({ 21 | content: 'This confirmation has been canceled.', 22 | embeds: [], 23 | components: [] 24 | }); 25 | } 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/events/discord/interactionCreate/InteractionCreateEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../../EventListener.js'; 2 | 3 | export default class InteractionCreateEventListener extends EventListener { 4 | get name() { 5 | return 'interactionCreate'; 6 | } 7 | 8 | /** 9 | * @param {import('discord.js').Interaction} interaction 10 | * @returns {Promise} 11 | * @abstract 12 | */ 13 | async execute(interaction) { 14 | 15 | } 16 | } -------------------------------------------------------------------------------- /src/events/discord/messageCreate/AutoModEventListener.js: -------------------------------------------------------------------------------- 1 | import MessageCreateEventListener from './MessageCreateEventListener.js'; 2 | import autoModManager from '../../../automod/AutoModManager.js'; 3 | 4 | export default class AutoModEventListener extends MessageCreateEventListener { 5 | /** 6 | * @param {import('discord.js').Message} message 7 | * @returns {Promise} 8 | */ 9 | async execute(message) { 10 | await autoModManager.checkMessage(message); 11 | } 12 | } -------------------------------------------------------------------------------- /src/events/discord/messageCreate/AutoResponseEventListener.js: -------------------------------------------------------------------------------- 1 | import MessageCreateEventListener from './MessageCreateEventListener.js'; 2 | import AutoResponse from '../../../database/AutoResponse.js'; 3 | import {RESTJSONErrorCodes, ThreadChannel} from 'discord.js'; 4 | import {MESSAGE_LENGTH_LIMIT} from '../../../util/apiLimits.js'; 5 | import logger from '../../../bot/Logger.js'; 6 | import {asyncFilter} from '../../../util/util.js'; 7 | import cloudVision from '../../../apis/CloudVision.js'; 8 | import GuildSettings from '../../../settings/GuildSettings.js'; 9 | 10 | export default class AutoResponseEventListener extends MessageCreateEventListener { 11 | 12 | async execute(message) { 13 | if (!message.guild || message.author.bot) { 14 | return; 15 | } 16 | let channel = message.channel; 17 | 18 | if (channel instanceof ThreadChannel) { 19 | channel = (/** @type {import('discord.js').ThreadChannel} */ channel).parent; 20 | } 21 | 22 | let texts = null; 23 | const responses = /** @type {AutoResponse[]} */ Array.from(( 24 | await AutoResponse.get(channel.id, message.guild.id) 25 | ).values()); 26 | const triggered = /** @type {AutoResponse[]} */ await asyncFilter(responses, 27 | /** 28 | * @param {AutoResponse} response 29 | * @returns {Promise} 30 | */ 31 | async response => { 32 | if (response.matches(message.content)) { 33 | return true; 34 | } 35 | 36 | if (!cloudVision.isEnabled 37 | || !(await GuildSettings.get(message.guild.id)).isFeatureWhitelisted 38 | || !response.enableVision 39 | || !response.trigger.supportsImages() 40 | ) { 41 | return false; 42 | } 43 | 44 | texts ??= await cloudVision.getImageText(message); 45 | return texts.some(t => response.matches(t)); 46 | } 47 | ); 48 | 49 | if (triggered.length) { 50 | const response = triggered[Math.floor(Math.random() * triggered.length)]; 51 | try { 52 | await message.reply({content: response.response.substring(0, MESSAGE_LENGTH_LIMIT)}); 53 | } catch (e) { 54 | if (e.code === RESTJSONErrorCodes.MissingPermissions) { 55 | const channel = /** @type {import('discord.js').GuildTextBasedChannel} */ message.channel; 56 | await logger.warn(`Missing permissions to respond to message in channel ${channel?.name} (${message.channelId}) of guild ${message.guild?.name} (${message.guildId})`, e); 57 | return; 58 | } 59 | throw e; 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/events/discord/messageCreate/MessageCreateEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../../EventListener.js'; 2 | 3 | export default class MessageCreateEventListener extends EventListener { 4 | 5 | get name() { 6 | return 'messageCreate'; 7 | } 8 | 9 | /** 10 | * @abstract 11 | * @param {import('discord.js').Message} message 12 | * @returns {Promise} 13 | */ 14 | async execute(message) { 15 | message.createdAt; 16 | } 17 | } -------------------------------------------------------------------------------- /src/events/discord/messageUpdate/AutoModMessageEditEventListener.js: -------------------------------------------------------------------------------- 1 | import MessageUpdateEventListener from './MessageUpdateEventListener.js'; 2 | import autoModManager from '../../../automod/AutoModManager.js'; 3 | 4 | export default class AutoModMessageEditEventListener extends MessageUpdateEventListener { 5 | 6 | async execute(oldMessage, message) { 7 | await autoModManager.checkMessageEdit(message); 8 | } 9 | } -------------------------------------------------------------------------------- /src/events/discord/messageUpdate/LogMessageUpdateEventListener.js: -------------------------------------------------------------------------------- 1 | import {diffWords} from 'diff'; 2 | import { 3 | EmbedBuilder, 4 | escapeItalic, 5 | escapeMarkdown, 6 | escapeStrikethrough, 7 | strikethrough, 8 | underscore 9 | } from 'discord.js'; 10 | import colors from '../../../util/colors.js'; 11 | import GuildWrapper from '../../../discord/GuildWrapper.js'; 12 | import {EMBED_DESCRIPTION_LIMIT} from '../../../util/apiLimits.js'; 13 | import MessageUpdateEventListener from './MessageUpdateEventListener.js'; 14 | 15 | export default class LogMessageUpdateEventListener extends MessageUpdateEventListener { 16 | async execute(oldMessage, message) { 17 | if (!message.guild || message.author.bot || !oldMessage.content || oldMessage.content === message.content) { 18 | return; 19 | } 20 | 21 | const diff = diffWords(oldMessage.content, message.content); 22 | 23 | let formatted = ''; 24 | for (const part of diff) { 25 | part.value = escapeStrikethrough(escapeItalic(part.value)); 26 | 27 | const maxPartLength = EMBED_DESCRIPTION_LIMIT - formatted.length; 28 | if (part.added) { 29 | formatted += underscore(part.value.substring(0, maxPartLength - 5)) + ' '; 30 | } 31 | else if (part.removed) { 32 | formatted += strikethrough(part.value.substring(0, maxPartLength - 5)) + ' '; 33 | } 34 | else { 35 | formatted += part.value.substring(0, maxPartLength); 36 | } 37 | 38 | if (formatted.length === EMBED_DESCRIPTION_LIMIT){ 39 | break; 40 | } 41 | } 42 | 43 | const guild = new GuildWrapper(message.guild); 44 | await guild.logMessage({embeds: [ 45 | new EmbedBuilder() 46 | .setColor(colors.ORANGE) 47 | .setAuthor({ 48 | name: `Message by ${escapeMarkdown(message.member.displayName)} in #${message.channel.name} was edited`, 49 | iconURL: message.member.displayAvatarURL() 50 | }) 51 | .setDescription(formatted.trim()) 52 | .setFooter({text: message.author.id}) 53 | ]}); 54 | } 55 | } -------------------------------------------------------------------------------- /src/events/discord/messageUpdate/MessageUpdateEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../../EventListener.js'; 2 | 3 | export default class MessageUpdateEventListener extends EventListener { 4 | 5 | /** 6 | * @abstract 7 | * @param {import('discord.js').Message} oldMessage 8 | * @param {import('discord.js').Message} message 9 | * @returns {Promise} 10 | */ 11 | async execute(oldMessage, message) { 12 | return Promise.resolve(undefined); 13 | } 14 | 15 | get name() { 16 | return 'messageUpdate'; 17 | } 18 | } -------------------------------------------------------------------------------- /src/events/rest/RateLimitEventListener.js: -------------------------------------------------------------------------------- 1 | import EventListener from '../EventListener.js'; 2 | import logger from '../../bot/Logger.js'; 3 | 4 | export default class RateLimitEventListener extends EventListener { 5 | 6 | async execute(details) { 7 | await logger.warn({ 8 | message: 'The bot hit a ratelimit', 9 | details 10 | }); 11 | } 12 | 13 | get name() { 14 | return 'rateLimited'; 15 | } 16 | } -------------------------------------------------------------------------------- /src/events/rest/RestEventManager.js: -------------------------------------------------------------------------------- 1 | import bot from '../../bot/Bot.js'; 2 | import EventManager from '../EventManager.js'; 3 | import RateLimitEventListener from './RateLimitEventListener.js'; 4 | 5 | export default class RestEventManagerEventManager extends EventManager { 6 | subscribe() { 7 | const rest = bot.client.rest; 8 | for (const eventListener of this.getEventListeners()) { 9 | rest.on(eventListener.name, this.notifyEventListener.bind(this, eventListener)); 10 | } 11 | } 12 | 13 | getEventListeners() { 14 | return [ 15 | new RateLimitEventListener(), 16 | ]; 17 | } 18 | } -------------------------------------------------------------------------------- /src/formatting/embeds/BetterButtonBuilder.js: -------------------------------------------------------------------------------- 1 | import {ButtonBuilder} from 'discord.js'; 2 | 3 | export default class BetterButtonBuilder extends ButtonBuilder { 4 | /** 5 | * Set the emoji for this button. 6 | * If the emoji parameter is null, don't change the emoji. 7 | * @param {?import('discord.js').ComponentEmojiResolvable} emoji 8 | * @returns {ButtonBuilder|BetterButtonBuilder} 9 | */ 10 | setEmojiIfPresent(emoji) { 11 | if (!emoji) { 12 | return this; 13 | } 14 | 15 | return super.setEmoji(emoji); 16 | } 17 | } -------------------------------------------------------------------------------- /src/formatting/embeds/ChatFeatureEmbed.js: -------------------------------------------------------------------------------- 1 | import KeyValueEmbed from './KeyValueEmbed.js'; 2 | import {yesNo} from '../../util/format.js'; 3 | import {channelMention} from 'discord.js'; 4 | import cloudVision from '../../apis/CloudVision.js'; 5 | import {EMBED_FIELD_LIMIT} from '../../util/apiLimits.js'; 6 | 7 | export default class ChatFeatureEmbed extends KeyValueEmbed { 8 | /** 9 | * @param {import('../../database/ChatTriggeredFeature.js')} feature 10 | * @param {string} title 11 | * @param {import('discord.js').ColorResolvable} color 12 | */ 13 | constructor(feature, title, color) { 14 | super(); 15 | this.setTitle(title + ` [${feature.id}]`) 16 | .setColor(color) 17 | .addPair('Trigger', feature.trigger.asString()) 18 | .addPair('Global', yesNo(feature.global)) 19 | .addPairIf(!feature.global, 'Channels', feature.channels.map(channelMention).join(', ')) 20 | .addPairIf(cloudVision.isEnabled, 'Detect images', yesNo(feature.enableVision)) 21 | .addFields( 22 | /** @type {any} */ 23 | { 24 | name: 'Response', 25 | value: feature.response.substring(0, EMBED_FIELD_LIMIT) 26 | }, 27 | ); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/formatting/embeds/ConfirmationEmbed.js: -------------------------------------------------------------------------------- 1 | import {ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags} from 'discord.js'; 2 | import KeyValueEmbed from './KeyValueEmbed.js'; 3 | import colors from '../../util/colors.js'; 4 | 5 | export default class ConfirmationEmbed extends KeyValueEmbed { 6 | /** 7 | * @param {string} command command name 8 | * @param {number} confirmation confirmation id 9 | * @param {import('discord.js').ButtonStyle} [confirmButtonStyle] 10 | */ 11 | constructor(command, confirmation, confirmButtonStyle = ButtonStyle.Success) { 12 | super(); 13 | this.command = command; 14 | this.confirmation = confirmation; 15 | this.confirmButtonStyle = confirmButtonStyle; 16 | this.setColor(colors.RED); 17 | } 18 | 19 | toMessage(ephemeral = true) { 20 | return { 21 | flags: ephemeral ? MessageFlags.Ephemeral : 0, 22 | embeds: [this], 23 | components: [new ActionRowBuilder() 24 | .addComponents(/** @type {*} */ new ButtonBuilder() 25 | .setCustomId(`${this.command}:confirm:${this.confirmation}`) 26 | .setStyle(this.confirmButtonStyle) 27 | .setLabel('Confirm'), 28 | ) 29 | .addComponents(/** @type {*} */ new ButtonBuilder() 30 | .setCustomId(`confirmation:delete:${this.confirmation}`) 31 | .setStyle(ButtonStyle.Secondary) 32 | .setLabel('Cancel') 33 | ) 34 | ] 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/formatting/embeds/EmbedWrapper.js: -------------------------------------------------------------------------------- 1 | import {EmbedBuilder, MessageFlags} from 'discord.js'; 2 | 3 | export default class EmbedWrapper extends EmbedBuilder { 4 | /** 5 | * convert to discord message 6 | * @param {boolean} ephemeral should the message be ephemeral 7 | * @returns {{flags: number, embeds: this[]}} 8 | */ 9 | toMessage(ephemeral = true) { 10 | return {flags: ephemeral ? MessageFlags.Ephemeral : 0, embeds: [this]}; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/formatting/embeds/ErrorEmbed.js: -------------------------------------------------------------------------------- 1 | import EmbedWrapper from './EmbedWrapper.js'; 2 | import colors from '../../util/colors.js'; 3 | 4 | export default class ErrorEmbed extends EmbedWrapper { 5 | /** 6 | * @param {string} description 7 | */ 8 | constructor(description) { 9 | super(); 10 | this.setDescription(description); 11 | this.setColor(colors.RED); 12 | } 13 | 14 | /** 15 | * @param {string} description 16 | * @returns {{embeds: EmbedWrapper[]}} 17 | */ 18 | static message(description) { 19 | return new this(description).toMessage(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/formatting/embeds/KeyValueEmbed.js: -------------------------------------------------------------------------------- 1 | import {bold} from 'discord.js'; 2 | import LineEmbed from './LineEmbed.js'; 3 | 4 | /** 5 | * @import {Iterable} from '@types/node'; 6 | */ 7 | 8 | export default class KeyValueEmbed extends LineEmbed { 9 | /** 10 | * add a line 11 | * @param {string} name 12 | * @param {string|number} value 13 | * @returns {this} 14 | */ 15 | addPair(name, value) { 16 | return this.addLine(bold(name) + ': ' + value); 17 | } 18 | 19 | /** 20 | * add a list of values 21 | * @param {string} name 22 | * @param {Iterable} list 23 | * @returns {KeyValueEmbed} 24 | */ 25 | addList(name, list) { 26 | this.addLine(bold(name) + ':'); 27 | for (const item of list) { 28 | this.addLine('- ' + item); 29 | } 30 | return this; 31 | } 32 | 33 | /** 34 | * add a list of values or a short enumeration for lists with up to 3 items 35 | * @param {string} name 36 | * @param {Array} list 37 | * @returns {KeyValueEmbed} 38 | */ 39 | addListOrShortList(name, list) { 40 | if (list.length > 3) { 41 | return this.addList(name, list); 42 | } 43 | return this.addPair(name, list.join(', ')); 44 | } 45 | 46 | /** 47 | * @param {*} condition 48 | * @param {string} name 49 | * @param {string|number} value 50 | * @returns {this} 51 | */ 52 | addPairIf(condition, name, value) { 53 | if (condition) { 54 | this.addPair(name, value); 55 | } 56 | return this; 57 | } 58 | 59 | /** 60 | * Add a field - but don't be stupid about types 61 | * @param {string} name 62 | * @param {string} value 63 | * @param {boolean} inline 64 | * @returns {KeyValueEmbed} 65 | */ 66 | addField(name, value, inline = false) { 67 | return this.addFields(/** @type {*} */ {name, value, inline}); 68 | } 69 | 70 | /** 71 | * Add a field if a condition is met 72 | * @param {*} condition 73 | * @param {string} name 74 | * @param {string} value 75 | * @param {boolean} inline 76 | * @returns {KeyValueEmbed} 77 | */ 78 | addFieldIf(condition, name, value, inline = false) { 79 | if (condition) { 80 | this.addField(name, value, inline); 81 | } 82 | return this; 83 | } 84 | } -------------------------------------------------------------------------------- /src/formatting/embeds/LineEmbed.js: -------------------------------------------------------------------------------- 1 | import EmbedWrapper from './EmbedWrapper.js'; 2 | import {EMBED_DESCRIPTION_LIMIT} from '../../util/apiLimits.js'; 3 | 4 | export default class LineEmbed extends EmbedWrapper { 5 | #lines = []; 6 | 7 | 8 | #buildLines() { 9 | let content = ''; 10 | 11 | for (const line of this.#lines) { 12 | let newContent = content + line + '\n'; 13 | if (newContent.length <= EMBED_DESCRIPTION_LIMIT) { 14 | content = newContent; 15 | } 16 | else { 17 | break; 18 | } 19 | } 20 | 21 | this.setDescription(content); 22 | } 23 | 24 | /** 25 | * add a line 26 | * @param {string} content 27 | * @returns {this} 28 | */ 29 | addLine(content) { 30 | this.#lines.push(content); 31 | this.#buildLines(); 32 | return this; 33 | } 34 | 35 | /** 36 | * add an empty line 37 | * @returns {this} 38 | */ 39 | newLine() { 40 | this.#lines.push(''); 41 | return this; 42 | } 43 | 44 | getLineCount() { 45 | return this.#lines.length; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/formatting/embeds/MessageDeleteEmbed.js: -------------------------------------------------------------------------------- 1 | import EmbedWrapper from './EmbedWrapper.js'; 2 | import colors from '../../util/colors.js'; 3 | import {AttachmentBuilder, escapeMarkdown} from 'discord.js'; 4 | import {EMBED_DESCRIPTION_LIMIT} from '../../util/apiLimits.js'; 5 | import got from 'got'; 6 | 7 | export default class MessageDeleteEmbed extends EmbedWrapper { 8 | #files = []; 9 | 10 | constructor(message) { 11 | super(); 12 | this.setColor(colors.RED); 13 | if (message.system) { 14 | this.setAuthor({ 15 | name: `A system message was deleted in #${message.channel.name}` 16 | }); 17 | } 18 | else { 19 | /** @type {import('discord.js').GuildMember|import('discord.js').User} */ 20 | const author = message.member ?? message.author; 21 | this.setAuthor({ 22 | name: `Message by ${escapeMarkdown(author.displayName)} was deleted in #${message.channel.name}`, 23 | iconURL: author.displayAvatarURL() 24 | }).setFooter({text: 25 | `Message ID: ${message.id}\n` + 26 | `Channel ID: ${message.channel.id}\n` + 27 | `User ID: ${message.author.id}` 28 | }); 29 | 30 | if (message.content.length) { 31 | this.setDescription(message.content.substring(0, EMBED_DESCRIPTION_LIMIT)); 32 | } 33 | } 34 | 35 | for (/** @type {import('discord.js').Attachment} */ const attachment of message.attachments.values()) { 36 | this.#files.push(new AttachmentBuilder(got.stream(attachment.url)) 37 | .setDescription(attachment.description) 38 | .setName(attachment.name) 39 | .setSpoiler(true)); 40 | } 41 | } 42 | 43 | toMessage(ephemeral = true) { 44 | const message = super.toMessage(ephemeral); 45 | message.files = this.#files; 46 | return message; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/formatting/embeds/ModerationEmbed.js: -------------------------------------------------------------------------------- 1 | import {time, TimestampStyles, userMention} from 'discord.js'; 2 | import {resolveColor} from '../../util/colors.js'; 3 | import {formatTime} from '../../util/timeutils.js'; 4 | import KeyValueEmbed from './KeyValueEmbed.js'; 5 | 6 | /** 7 | * @import {Moderation} from '../models/Moderation.js'; 8 | */ 9 | 10 | export default class ModerationEmbed extends KeyValueEmbed { 11 | 12 | /** 13 | * @param {Moderation} moderation 14 | * @param {import('discord.js').GuildMember|import('discord.js').User} user 15 | */ 16 | constructor(moderation, user) { 17 | super(); 18 | this.setTitle(`Moderation #${moderation.id} | ${moderation.action.toUpperCase()} | ${user.displayName}`) 19 | .setColor(resolveColor(moderation.action)) 20 | .setFooter({text: `${user.displayName} - ${moderation.userid}`, iconURL: user.displayAvatarURL()}) 21 | .addPair('User ID', moderation.userid) 22 | .addPair('Created at', time(moderation.created, TimestampStyles.LongDate)); 23 | 24 | if (moderation.action === 'strike') { 25 | this.addPair('Strikes', moderation.value); 26 | } else if (moderation.action === 'pardon') { 27 | this.addPair('Pardoned Strikes', -moderation.value); 28 | } 29 | 30 | if (moderation.expireTime) { 31 | this.addPair('Duration', formatTime(moderation.expireTime - moderation.created)); 32 | this.addPair('Expires', time(moderation.expireTime, TimestampStyles.LongDate)); 33 | } 34 | 35 | if (moderation.moderator) { 36 | this.addPair('Moderator', userMention(moderation.moderator)); 37 | } 38 | 39 | this.addPair('Reason', moderation.reason); 40 | this.addPairIf(moderation.comment, 'Comment', moderation.comment); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/formatting/embeds/ModerationListEmbed.js: -------------------------------------------------------------------------------- 1 | import colors from '../../util/colors.js'; 2 | import KeyValueEmbed from './KeyValueEmbed.js'; 3 | 4 | export default class ModerationListEmbed extends KeyValueEmbed { 5 | /** 6 | * @param {import('discord.js').GuildMember|import('discord.js').User} user 7 | */ 8 | constructor(user) { 9 | super(); 10 | this.setColor(colors.ORANGE) 11 | .setAuthor({ 12 | name: `Moderations for ${user.displayName}`, 13 | iconURL: user.displayAvatarURL() 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/formatting/embeds/PurgeLogEmbed.js: -------------------------------------------------------------------------------- 1 | import KeyValueEmbed from './KeyValueEmbed.js'; 2 | import colors from '../../util/colors.js'; 3 | import {bold, channelMention, codeBlock, userMention} from 'discord.js'; 4 | 5 | export default class PurgeLogEmbed extends KeyValueEmbed { 6 | /** 7 | * @param {import('discord.js').Interaction} interaction 8 | * @param {number} count successfully deleted messages 9 | * @param {number} limit 10 | * @param {?import('discord.js').User} [user] targeted user 11 | * @param {?string} [regex] 12 | */ 13 | constructor(interaction, count, limit, user = null, regex = null) { 14 | super(); 15 | this.setColor(colors.RED) 16 | .setAuthor({name: `${interaction.member.displayName} purged ${count} messages`}) 17 | .addPair('Moderator ID', interaction.user.id) 18 | .addPair('Tested messages', limit) 19 | .newLine() 20 | .addLine(bold('Filters:')) 21 | .addPair('Channel', channelMention(interaction.channel.id)) 22 | .addPairIf(user, 'User', userMention(user?.id)) 23 | .addPairIf(regex, 'Regex', codeBlock(regex)) 24 | .setFooter({text: interaction.user.id.toString()}); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/formatting/embeds/UserActionEmbed.js: -------------------------------------------------------------------------------- 1 | import EmbedWrapper from './EmbedWrapper.js'; 2 | import {bold, escapeMarkdown, formatEmoji} from 'discord.js'; 3 | import {formatTime} from '../../util/timeutils.js'; 4 | 5 | export default class UserActionEmbed extends EmbedWrapper { 6 | /** 7 | * 8 | * @param {import('discord.js').User} user 9 | * @param {string} reason 10 | * @param {string} action 11 | * @param {number} color 12 | * @param {?string} emoji 13 | * @param {?number} duration 14 | */ 15 | constructor(user, reason, action, color, emoji, duration = null) { 16 | super(); 17 | let description = `${bold(escapeMarkdown(user.displayName))} has been ${action}`; 18 | if (duration) { 19 | description += ` for ${formatTime(duration)}`; 20 | } 21 | if (emoji) { 22 | description = formatEmoji(emoji) + ' ' + description; 23 | } 24 | description += `: ${reason}`; 25 | 26 | this.setDescription(description); 27 | this.setColor(color); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/formatting/embeds/UserEmbed.js: -------------------------------------------------------------------------------- 1 | import KeyValueEmbed from './KeyValueEmbed.js'; 2 | 3 | export default class UserEmbed extends KeyValueEmbed { 4 | /** 5 | * 6 | * @param {import('discord.js').User|import('discord.js').GuildMember} user 7 | */ 8 | constructor(user) { 9 | super(); 10 | this.setAuthor({ name: user.displayName, iconURL: user.displayAvatarURL() }); 11 | } 12 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import logger from './bot/Logger.js'; 2 | import {ShardingManager} from 'discord.js'; 3 | import config from './bot/Config.js'; 4 | import database from './database/Database.js'; 5 | import commandManager from './commands/CommandManager.js'; 6 | 7 | try { 8 | await logger.debug('Loading settings'); 9 | await config.load(); 10 | await logger.info('Connecting to database'); 11 | await database.connect(); 12 | await logger.info('Creating database tables'); 13 | await database.createTables(); 14 | await database.runMigrations(); 15 | await logger.notice('Registering slash commands'); 16 | await commandManager.registerGlobalCommands(); 17 | 18 | await logger.info('Spawning shards'); 19 | const manager = new ShardingManager( 20 | import.meta.dirname + '/shard.js', 21 | { 22 | token: config.data.authToken, 23 | } 24 | ); 25 | 26 | manager.on('shardCreate', async shard => { 27 | shard.args = [shard.id, manager.totalShards]; 28 | await logger.notice(`Launched shard ${shard.id}`); 29 | 30 | 31 | shard.on('ready', () => { 32 | logger.info(`Shard ${shard.id} connected to Discord's Gateway.`); 33 | }); 34 | }); 35 | 36 | const shards = await manager.spawn(); 37 | await logger.info(`Launched ${shards.size} shards`); 38 | } catch (error) { 39 | try { 40 | await logger.critical('Shard Manager crashed', error); 41 | } catch (e) { 42 | console.error('Failed to send fatal error to monitoring', e); 43 | } 44 | process.exit(1); 45 | } 46 | -------------------------------------------------------------------------------- /src/interval/CleanupConfirmationInterval.js: -------------------------------------------------------------------------------- 1 | import Interval from './Interval.js'; 2 | import database from '../database/Database.js'; 3 | 4 | export default class CleanupConfirmationInterval extends Interval { 5 | 6 | getInterval() { 7 | return 5 * 60 * 1000; 8 | } 9 | 10 | async run() { 11 | const now = Math.floor(Date.now() / 1000); 12 | await database.queryAll('DELETE FROM confirmations WHERE expires <= ?', now); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/interval/Interval.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class 3 | * @classdesc a task that's run repeatedly 4 | * @abstract 5 | */ 6 | export default class Interval { 7 | /** 8 | * @abstract 9 | * @returns {number} timeout in ms 10 | */ 11 | getInterval() { 12 | return 1000; 13 | } 14 | 15 | /** 16 | * run the task 17 | * @abstract 18 | * @returns {Promise} 19 | */ 20 | async run() { 21 | 22 | } 23 | } -------------------------------------------------------------------------------- /src/interval/IntervalManager.js: -------------------------------------------------------------------------------- 1 | import UnbanInterval from './UnbanInterval.js'; 2 | import logger from '../bot/Logger.js'; 3 | import UnmuteInterval from './UnmuteInterval.js'; 4 | import TransferMuteToTimeoutInterval from './TransferMuteToTimeoutInterval.js'; 5 | import CleanupConfirmationInterval from './CleanupConfirmationInterval.js'; 6 | 7 | export default class IntervalManager { 8 | /** 9 | * @type {import('Interval.js')[]} 10 | */ 11 | #intervals = [ 12 | new UnbanInterval(), 13 | new UnmuteInterval(), 14 | new TransferMuteToTimeoutInterval(), 15 | new CleanupConfirmationInterval(), 16 | ]; 17 | 18 | schedule() { 19 | for (const interval of this.#intervals) { 20 | setInterval(this.runInterval, interval.getInterval(), interval); 21 | } 22 | } 23 | 24 | /** 25 | * @param {import('Interval.js')} interval 26 | */ 27 | async runInterval(interval) { 28 | try { 29 | await interval.run(); 30 | } 31 | catch(error) { 32 | try { 33 | await logger.error(`Failed to run interval '${interval.constructor.name}'`, error); 34 | } 35 | catch (e) { 36 | console.error(e); 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/interval/TransferMuteToTimeoutInterval.js: -------------------------------------------------------------------------------- 1 | import Interval from './Interval.js'; 2 | import database from '../database/Database.js'; 3 | import GuildWrapper from '../discord/GuildWrapper.js'; 4 | import MemberWrapper from '../discord/MemberWrapper.js'; 5 | import {PermissionFlagsBits} from 'discord.js'; 6 | import {TIMEOUT_DURATION_LIMIT} from '../util/apiLimits.js'; 7 | import UserWrapper from '../discord/UserWrapper.js'; 8 | import GuildSettings from '../settings/GuildSettings.js'; 9 | import bot from '../bot/Bot.js'; 10 | import logger from '../bot/Logger.js'; 11 | 12 | export default class TransferMuteToTimeoutInterval extends Interval { 13 | 14 | getInterval() { 15 | return 60 * 60 * 1000; 16 | } 17 | 18 | async run() { 19 | for (const result of await database.queryAll('SELECT * FROM moderations WHERE action = \'mute\' AND active = TRUE AND expireTime IS NOT NULL AND expireTime <= ?', 20 | Math.floor(Date.now() / 1000) + TIMEOUT_DURATION_LIMIT)) { 21 | if (!bot.client.guilds.cache.has(result.guildid)) { 22 | continue; 23 | } 24 | 25 | try { 26 | const guild = await GuildWrapper.fetch(result.guildid), 27 | me = await guild.guild.members.fetchMe(); 28 | if (!me.permissions.has(PermissionFlagsBits.ModerateMembers)) { 29 | continue; 30 | } 31 | 32 | const user = await (new UserWrapper(result.userid)).fetchUser(); 33 | if (!user) { 34 | continue; 35 | } 36 | 37 | const member = await (new MemberWrapper(user, guild)).fetchMember(); 38 | const guildSettings = await GuildSettings.get(guild.guild.id); 39 | if (!member || !member.roles.cache.has(guildSettings.mutedRole)) { 40 | continue; 41 | } 42 | 43 | await member.disableCommunicationUntil(parseInt(result.expireTime) * 1000); 44 | await member.roles.remove(guildSettings.mutedRole); 45 | } catch (e) { 46 | await logger.error(`Failed to transfer mute to timeout for user ${result.userid} in guild ${result.guildid}`, e); 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/interval/UnbanInterval.js: -------------------------------------------------------------------------------- 1 | import Interval from './Interval.js'; 2 | import database from '../database/Database.js'; 3 | import bot from '../bot/Bot.js'; 4 | import GuildWrapper from '../discord/GuildWrapper.js'; 5 | import MemberWrapper from '../discord/MemberWrapper.js'; 6 | import {RESTJSONErrorCodes} from 'discord.js'; 7 | import ErrorEmbed from '../formatting/embeds/ErrorEmbed.js'; 8 | import logger from '../bot/Logger.js'; 9 | 10 | export default class UnbanInterval extends Interval { 11 | getInterval() { 12 | return 30 * 1000; 13 | } 14 | 15 | async run() { 16 | for (const result of await database.queryAll('SELECT * FROM moderations WHERE action = \'ban\' AND active = TRUE AND expireTime IS NOT NULL AND expireTime <= ?', 17 | Math.floor(Date.now() / 1000))) { 18 | if (!bot.client.guilds.cache.has(result.guildid)) { 19 | continue; 20 | } 21 | 22 | try { 23 | const guild = await GuildWrapper.fetch(result.guildid); 24 | 25 | if (!guild) { 26 | const wrapper = new GuildWrapper({id: result.guildid}); 27 | await wrapper.deleteData(); 28 | continue; 29 | } 30 | 31 | const user = await bot.client.users.fetch(result.userid); 32 | const member = new MemberWrapper(user, guild); 33 | try { 34 | await member.unban('Temporary ban completed!', null, bot.client.user); 35 | } catch (e) { 36 | if (e.code === RESTJSONErrorCodes.MissingPermissions) { 37 | await database.query('UPDATE moderations SET active = FALSE WHERE active = TRUE AND guildid = ? AND userid = ? AND action = \'ban\'', 38 | guild.guild.id, user.id); 39 | await guild.log(new ErrorEmbed('Missing permissions to unban user!') 40 | .setAuthor({name: user.displayName, iconURL: user.displayAvatarURL()}) 41 | .setFooter({text: user.id}) 42 | .toMessage(false)); 43 | await logger.warn(`Missing permissions to unban user ${result.userid} in guild ${result.guildid}`); 44 | } else { 45 | throw e; 46 | } 47 | } 48 | } catch (e) { 49 | await logger.error(`Failed to unban user ${result.userid} in guild ${result.guildid}`, e); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/interval/UnmuteInterval.js: -------------------------------------------------------------------------------- 1 | import Interval from './Interval.js'; 2 | import database from '../database/Database.js'; 3 | import bot from '../bot/Bot.js'; 4 | import GuildWrapper from '../discord/GuildWrapper.js'; 5 | import MemberWrapper from '../discord/MemberWrapper.js'; 6 | import {RESTJSONErrorCodes} from 'discord.js'; 7 | import ErrorEmbed from '../formatting/embeds/ErrorEmbed.js'; 8 | import logger from '../bot/Logger.js'; 9 | 10 | export default class UnmuteInterval extends Interval { 11 | getInterval() { 12 | return 30*1000; 13 | } 14 | 15 | async run() { 16 | for (const result of await database.queryAll('SELECT * FROM moderations WHERE action = \'mute\' AND active = TRUE AND expireTime IS NOT NULL AND expireTime <= ?', 17 | Math.floor(Date.now()/1000))) { 18 | if (!bot.client.guilds.cache.has(result.guildid)) { 19 | continue; 20 | } 21 | 22 | try { 23 | const guild = await GuildWrapper.fetch(result.guildid); 24 | 25 | if (!guild) { 26 | const wrapper = new GuildWrapper({id: result.guildid}); 27 | await wrapper.deleteData(); 28 | } 29 | 30 | const user = await bot.client.users.fetch(result.userid); 31 | const member = new MemberWrapper(user, guild); 32 | try { 33 | await member.unmute('Temporary mute completed!', null, bot.client.user); 34 | } 35 | catch (e) { 36 | if (e.code === RESTJSONErrorCodes.MissingPermissions) { 37 | await database.query('UPDATE moderations SET active = FALSE WHERE active = TRUE AND guildid = ? AND userid = ? AND action = \'mute\'', 38 | guild.guild.id, user.id); 39 | await guild.log(new ErrorEmbed('Missing permissions to unmute user!') 40 | .setAuthor({name: await member.displayAvatarURL(), iconURL: await member.displayAvatarURL()}) 41 | .setFooter({text: user.id}) 42 | .toMessage(false)); 43 | await logger.warn(`Missing permissions to unmute user ${result.userid} in guild ${result.guildid}`); 44 | } else { 45 | throw e; 46 | } 47 | } 48 | } catch (e) { 49 | await logger.error(`Failed to unmute user ${result.userid} in guild ${result.guildid}`, e); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/modals/inputs/CommentInput.js: -------------------------------------------------------------------------------- 1 | import {TextInputStyle} from 'discord.js'; 2 | import TextInput from './TextInput.js'; 3 | 4 | export default class CommentInput extends TextInput { 5 | constructor() { 6 | super(); 7 | this.setRequired(false) 8 | .setLabel('Comment') 9 | .setCustomId('comment') 10 | .setStyle(TextInputStyle.Paragraph) 11 | .setPlaceholder('No comment provided'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modals/inputs/CountInput.js: -------------------------------------------------------------------------------- 1 | import {TextInputStyle} from 'discord.js'; 2 | import TextInput from './TextInput.js'; 3 | 4 | export default class CountInput extends TextInput { 5 | constructor() { 6 | super(); 7 | this.setRequired(false) 8 | .setLabel('Count') 9 | .setCustomId('count') 10 | .setStyle(TextInputStyle.Short) 11 | .setPlaceholder('1'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modals/inputs/DeleteMessageHistoryInput.js: -------------------------------------------------------------------------------- 1 | import TextInput from './TextInput.js'; 2 | import {TextInputStyle} from 'discord.js'; 3 | 4 | export default class DeleteMessageHistoryInput extends TextInput { 5 | constructor() { 6 | super(); 7 | this.setRequired(false) 8 | .setLabel('Delete message history') 9 | .setCustomId('delete') 10 | .setStyle(TextInputStyle.Short) 11 | .setValue('1 hour'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modals/inputs/DurationInput.js: -------------------------------------------------------------------------------- 1 | import {TextInputStyle} from 'discord.js'; 2 | import TextInput from './TextInput.js'; 3 | 4 | export default class DurationInput extends TextInput { 5 | constructor() { 6 | super(); 7 | this.setRequired(false) 8 | .setLabel('Duration') 9 | .setCustomId('duration') 10 | .setStyle(TextInputStyle.Short); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modals/inputs/ReasonInput.js: -------------------------------------------------------------------------------- 1 | import {TextInputStyle} from 'discord.js'; 2 | import TextInput from './TextInput.js'; 3 | 4 | export default class ReasonInput extends TextInput { 5 | constructor() { 6 | super(); 7 | this.setRequired(false) 8 | .setLabel('Reason') 9 | .setCustomId('reason') 10 | .setStyle(TextInputStyle.Paragraph) 11 | .setPlaceholder('No reason provided'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modals/inputs/TextInput.js: -------------------------------------------------------------------------------- 1 | import {TextInputBuilder} from 'discord.js'; 2 | import SimpleActionRow from '../rows/SimpleActionRow.js'; 3 | 4 | export default class TextInput extends TextInputBuilder { 5 | /** 6 | * Create a new action row with this input 7 | * @returns {*} 8 | */ 9 | toActionRow() { 10 | return new SimpleActionRow(this); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modals/rows/SimpleActionRow.js: -------------------------------------------------------------------------------- 1 | import {ActionRowBuilder} from 'discord.js'; 2 | 3 | export default class SimpleActionRow extends ActionRowBuilder { 4 | /** 5 | * @param {*} input 6 | */ 7 | constructor(input) { 8 | super(); 9 | this.addComponents(input); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/purge/PurgeAgeFilter.js: -------------------------------------------------------------------------------- 1 | import PurgeFilter from './PurgeFilter.js'; 2 | import {BULK_DELETE_MAX_AGE} from '../util/apiLimits.js'; 3 | 4 | /** 5 | * @class PurgeAgeFilter 6 | * @classdesc A filter that matches messages younger than the max age 7 | */ 8 | export default class PurgeAgeFilter extends PurgeFilter { 9 | matches(message) { 10 | return (Date.now() - message.createdTimestamp) < BULK_DELETE_MAX_AGE; 11 | } 12 | } -------------------------------------------------------------------------------- /src/purge/PurgeContentFilter.js: -------------------------------------------------------------------------------- 1 | import PurgeFilter from './PurgeFilter.js'; 2 | 3 | /** 4 | * @class PurgeContentFilter 5 | * @classdesc A filter that matches messages containing a string 6 | */ 7 | export default class PurgeContentFilter extends PurgeFilter { 8 | /** 9 | * @param {string} content the content to filter for 10 | */ 11 | constructor(content) { 12 | super(); 13 | this.content = content.toLowerCase(); 14 | } 15 | 16 | matches(message) { 17 | return message.content.includes(this.content); 18 | } 19 | } -------------------------------------------------------------------------------- /src/purge/PurgeFilter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class PurgeFilter 3 | * @classdesc Represents a filter for the purge command 4 | * @abstract 5 | */ 6 | export default class PurgeFilter { 7 | 8 | /** 9 | * Does this message match this filter? 10 | * @abstract 11 | * @param {import('discord.js').Message} message 12 | * @returns {boolean} 13 | */ 14 | matches(message) { 15 | throw new Error('Not implemented'); 16 | } 17 | } -------------------------------------------------------------------------------- /src/purge/PurgeRegexFilter.js: -------------------------------------------------------------------------------- 1 | import PurgeFilter from './PurgeFilter.js'; 2 | 3 | /** 4 | * @class PurgeRegexFilter 5 | * @classdesc A filter that matches messages matching a regex 6 | */ 7 | export default class PurgeRegexFilter extends PurgeFilter { 8 | /** 9 | * @param {RegExp} regex 10 | */ 11 | constructor(regex) { 12 | super(); 13 | this.regex = regex; 14 | } 15 | 16 | matches(message) { 17 | return this.regex.test(message.content); 18 | } 19 | } -------------------------------------------------------------------------------- /src/purge/PurgeUserFilter.js: -------------------------------------------------------------------------------- 1 | import PurgeFilter from './PurgeFilter.js'; 2 | 3 | /** 4 | * @class PurgeUserFilter 5 | * @classdesc A filter that matches messages sent by a user 6 | */ 7 | export class PurgeUserFilter extends PurgeFilter { 8 | /** 9 | * @param {import('discord.js').User} user 10 | */ 11 | constructor(user) { 12 | super(); 13 | this.user = user; 14 | } 15 | 16 | matches(message) { 17 | return message.author.id === this.user.id; 18 | } 19 | } -------------------------------------------------------------------------------- /src/settings/ChannelSettings.js: -------------------------------------------------------------------------------- 1 | import TypeChecker from './TypeChecker.js'; 2 | import {RESTJSONErrorCodes} from 'discord.js'; 3 | import Settings from './Settings.js'; 4 | import database from '../database/Database.js'; 5 | import bot from '../bot/Bot.js'; 6 | 7 | /** 8 | * Class representing the settings of a channel 9 | */ 10 | export default class ChannelSettings extends Settings { 11 | 12 | static tableName = 'channels'; 13 | 14 | invites; 15 | 16 | lock; 17 | 18 | /** 19 | * @param {import('discord.js').Snowflake} id channel id 20 | * @param {object} [json] options 21 | * @param {boolean} [json.invites] allow invites 22 | * @param {object} [json.lock] permissions before locking (only affected perms) 23 | * @returns {ChannelSettings} the settings of the channel 24 | */ 25 | constructor(id, json = {}) { 26 | super(id); 27 | 28 | this.invites = json.invites ?? null; 29 | this.lock = json.lock ?? {}; 30 | } 31 | 32 | /** 33 | * check if the types of this object are a valid guild settings 34 | * @param {object} json 35 | * @throws {TypeError} incorrect types 36 | */ 37 | static checkTypes(json) { 38 | TypeChecker.assertOfTypes(json, ['object'], 'Data object'); 39 | 40 | TypeChecker.assertOfTypes(json.invites, ['undefined','boolean'], 'Invites', true); 41 | TypeChecker.assertOfTypes(json.lock, ['object'], 'Lock', true); 42 | } 43 | 44 | /** 45 | * get all channel configs from this guild 46 | * @param {import('discord.js').Snowflake} guildID 47 | * @returns {Promise} 48 | */ 49 | static async getForGuild(guildID) { 50 | const result = []; 51 | for (const {id, config} of await database.queryAll('SELECT id, config FROM channels WHERE guildid = ?', [guildID])) { 52 | result.push(new ChannelSettings(id, JSON.parse(config))); 53 | } 54 | return result; 55 | } 56 | 57 | /** 58 | * get the guildID of this channel 59 | * @returns {Promise} 60 | */ 61 | async getGuildID() { 62 | try { 63 | const channel = await bot.client.channels.fetch(this.id); 64 | return channel.guild.id; 65 | } 66 | catch (e) { 67 | if ([RESTJSONErrorCodes.UnknownChannel, RESTJSONErrorCodes.MissingAccess].includes(e.code)) { 68 | return null; 69 | } 70 | throw e; 71 | } 72 | } 73 | 74 | async insert() { 75 | return database.query('INSERT INTO channels (config,id,guildid) VALUES (?,?,?)', 76 | this.toJSONString(), this.id, await this.getGuildID()); 77 | } 78 | 79 | /** 80 | * @param {import('discord.js').Snowflake} guildID 81 | * @param {Settings} data 82 | * @returns {Promise} 83 | */ 84 | static async import(guildID, data) { 85 | let channel; 86 | try { 87 | channel = await bot.client.channels.fetch(data.id); 88 | } 89 | catch { 90 | return null; 91 | } 92 | 93 | if (channel.guild.id !== guildID) return null; 94 | 95 | const config = new this(data.id, data); 96 | await config.save(); 97 | return config; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/settings/TypeChecker.js: -------------------------------------------------------------------------------- 1 | export default class TypeChecker { 2 | /** 3 | * check if value has correct type, otherwise throw a type error 4 | * @param {*} value 5 | * @param {("undefined"|"object"|"boolean"|"number"|"string"|"function"|"symbol"|"bigint")[]} types 6 | * @param {string} name 7 | * @param {boolean} [allowNull] 8 | * @throws {TypeError} 9 | */ 10 | static assertOfTypes(value, types, name, allowNull = false) { 11 | if (allowNull && value === null) { 12 | return; 13 | } 14 | if (!types.includes(typeof value)) { 15 | throw new TypeError(`${name} has the wrong type. Expected: ${types.join(', ')} Found: ${typeof value}`); 16 | } 17 | } 18 | 19 | /** 20 | * check if value is a number or undefined 21 | * @param {*} value 22 | * @param {string} name 23 | */ 24 | static assertNumberUndefinedOrNull(value, name) { 25 | this.assertOfTypes(value, ['number', 'undefined'], name, true); 26 | } 27 | 28 | /** 29 | * check if value is a number 30 | * @param {*} value 31 | * @param {string} name 32 | */ 33 | static assertNumber(value, name) { 34 | this.assertOfTypes(value, ['number'], name); 35 | } 36 | 37 | /** 38 | * check if value is a string or undefined 39 | * @param {*} value 40 | * @param {string} name 41 | */ 42 | static assertStringUndefinedOrNull(value, name) { 43 | this.assertOfTypes(value, ['string', 'undefined'], name, true); 44 | } 45 | 46 | 47 | /** 48 | * check if value is a string 49 | * @param {*} value 50 | * @param {string} name 51 | */ 52 | static assertString(value, name) { 53 | this.assertOfTypes(value, ['string'], name); 54 | } 55 | 56 | static assertBooleanOrNull(value, name) { 57 | this.assertOfTypes(value, ['boolean'], name, true); 58 | } 59 | } -------------------------------------------------------------------------------- /src/settings/UserSettings.js: -------------------------------------------------------------------------------- 1 | import Settings from './Settings.js'; 2 | 3 | export default class UserSettings extends Settings { 4 | 5 | static tableName = 'users'; 6 | 7 | /** 8 | * @param {import('discord.js').Snowflake} id user id 9 | * @param {object} [json] options 10 | * @param {boolean} [json.deleteCommands] should commands be deleted automatically 11 | * @returns {UserSettings} the settings of the channel 12 | */ 13 | constructor(id, json = {}) { 14 | super(id); 15 | } 16 | 17 | /** 18 | * @param {import('discord.js').Snowflake} userid 19 | * @returns {Promise} 20 | */ 21 | static async get(userid) { 22 | return super.get(userid); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/shard.js: -------------------------------------------------------------------------------- 1 | import logger from './bot/Logger.js'; 2 | import config from './bot/Config.js'; 3 | import database from './database/Database.js'; 4 | import bot from './bot/Bot.js'; 5 | import DiscordEventManager from './events/discord/DiscordEventManager.js'; 6 | import RestEventManagerEventManager from './events/rest/RestEventManager.js'; 7 | import commandManager from './commands/CommandManager.js'; 8 | import IntervalManager from './interval/IntervalManager.js'; 9 | 10 | try { 11 | await logger.debug('Loading settings'); 12 | await config.load(); 13 | await logger.info('Connecting to database'); 14 | await database.connect(); 15 | await logger.notice('Logging into discord'); 16 | await bot.start(); 17 | await logger.info('Online'); 18 | 19 | await logger.debug('Loading event listeners'); 20 | new DiscordEventManager().subscribe(); 21 | new RestEventManagerEventManager().subscribe(); 22 | await logger.debug('Loading intervals'); 23 | new IntervalManager().schedule(); 24 | await logger.notice('Updating guild commands'); 25 | await commandManager.updateCommandIds(); 26 | await commandManager.updateGuildCommands(); 27 | await logger.info('Started'); 28 | } catch (error) { 29 | try { 30 | await logger.critical('Shard crashed', error); 31 | } catch (e) { 32 | console.error('Failed to send fatal error to monitoring', e); 33 | } 34 | process.exit(1); 35 | } 36 | -------------------------------------------------------------------------------- /src/util/apiLimits.js: -------------------------------------------------------------------------------- 1 | import {GuildPremiumTier} from 'discord.js'; 2 | 3 | /** 4 | * maximum timeout duration in seconds 5 | * @type {number} 6 | */ 7 | export const TIMEOUT_LIMIT = 28 * 24 * 60 * 60; 8 | 9 | /** 10 | * maximum number of embeds per message 11 | * @type {number} 12 | */ 13 | export const MESSAGE_EMBED_LIMIT = 10; 14 | 15 | /** 16 | * maximum length of an embed description 17 | * @type {number} 18 | */ 19 | export const EMBED_DESCRIPTION_LIMIT = 4096; 20 | 21 | /** 22 | * total length limit for the entire embed 23 | * @type {number} 24 | */ 25 | export const EMBED_TOTAL_LIMIT = 6000; 26 | 27 | /** 28 | * limit for the length of an embed field 29 | * @type {number} 30 | */ 31 | export const EMBED_FIELD_LIMIT = 1024; 32 | 33 | /** 34 | * maximum length of the title for a select menu 35 | * @type {number} 36 | */ 37 | export const SELECT_MENU_TITLE_LIMIT = 100; 38 | 39 | /** 40 | * maximum length of the value for a select menu 41 | * @type {number} 42 | */ 43 | export const SELECT_MENU_VALUE_LIMIT = 100; 44 | 45 | /** 46 | * upload limits for guilds in byte 47 | * @type {Map} 48 | */ 49 | export const FILE_UPLOAD_LIMITS = new Map() 50 | .set(GuildPremiumTier.None, 25 * 1024 * 1024) 51 | .set(GuildPremiumTier.Tier1, 25 * 1024 * 1024) 52 | .set(GuildPremiumTier.Tier2, 50 * 1024 * 1024) 53 | .set(GuildPremiumTier.Tier3, 100 * 1024 * 1024); 54 | 55 | /** 56 | * maximum number of autocomplete options 57 | * @type {number} 58 | */ 59 | export const AUTOCOMPLETE_OPTIONS_LIMIT = 25; 60 | 61 | export const AUTOCOMPLETE_NAME_LIMIT = 100; 62 | 63 | export const CHOICE_NAME_LIMIT = 100; 64 | 65 | /** 66 | * maximum select menu options 67 | * @type {number} 68 | */ 69 | export const SELECT_MENU_OPTIONS_LIMIT = 25; 70 | 71 | /** 72 | * maximum seconds of messages you can delete with a ban 73 | * @type {number} 74 | */ 75 | export const BAN_MESSAGE_DELETE_LIMIT = 7 * 24 * 60 * 60; 76 | 77 | /** 78 | * maximum duration of a timeout 79 | * @type {number} 80 | */ 81 | export const TIMEOUT_DURATION_LIMIT = 28 * 24 * 60 * 60; 82 | 83 | export const MODAL_TITLE_LIMIT = 45; 84 | 85 | export const TEXT_INPUT_LABEL_LIMIT = 45; 86 | 87 | export const FETCH_MESSAGES_LIMIT = 100; 88 | 89 | export const BULK_DELETE_LIMIT = 100; 90 | 91 | export const BULK_DELETE_MAX_AGE = 14 * 24 * 60 * 60 * 1000; 92 | 93 | /** 94 | * maximum number of bans that can be fetched per page 95 | * @type {number} 96 | */ 97 | export const FETCH_BAN_PAGE_SIZE = 1000; 98 | 99 | export const MESSAGE_FILE_LIMIT = 10; 100 | export const MESSAGE_LENGTH_LIMIT = 2000; -------------------------------------------------------------------------------- /src/util/channels.js: -------------------------------------------------------------------------------- 1 | import {StringSelectMenuBuilder} from 'discord.js'; 2 | import {SELECT_MENU_OPTIONS_LIMIT} from './apiLimits.js'; 3 | 4 | /** 5 | * @param {import('../discord/ChannelWrapper.js')[]} channels 6 | * @param {import('discord.js').Snowflake[]} defaultChannels 7 | * @returns {import('discord.js').StringSelectMenuBuilder} 8 | */ 9 | export function channelSelectMenu(channels, defaultChannels = []) { 10 | return new StringSelectMenuBuilder() 11 | .setOptions( 12 | /** @type {*} */ 13 | channels 14 | .sort((a,b) => { 15 | const aParent = a.channel.parentId ?? ''; 16 | const bParent = b.channel.parentId ?? ''; 17 | return aParent.localeCompare(bParent) || a.channel.position - b.channel.position; 18 | }) 19 | .map(channel => ({ 20 | default: defaultChannels.includes(channel.channel.id), 21 | label: channel.channel.name, 22 | value: channel.channel.id, 23 | emoji: channel.getChannelEmoji(), 24 | })) 25 | .slice(0, SELECT_MENU_OPTIONS_LIMIT) 26 | ) 27 | .setMinValues(1) 28 | .setMaxValues(Math.min(SELECT_MENU_OPTIONS_LIMIT, channels.length)); 29 | } -------------------------------------------------------------------------------- /src/util/colors.js: -------------------------------------------------------------------------------- 1 | const RED = 0xf04747; 2 | const ORANGE = 0xfaa61a; 3 | const GREEN = 0x43b581; 4 | export default {RED, ORANGE, GREEN}; 5 | 6 | /** 7 | * Resolves an action to a color 8 | * @param {string} action name of the action to resolve 9 | * @returns {number|null} hex color code or null 10 | */ 11 | export function resolveColor(action) { 12 | switch (action.toLowerCase()) { 13 | case 'banned': 14 | case 'ban': 15 | return RED; 16 | case 'striked': 17 | case 'muted': 18 | case 'softbanned': 19 | case 'kicked': 20 | case 'strike': 21 | case 'mute': 22 | case 'softban': 23 | case 'kick': 24 | return ORANGE; 25 | case 'pardon': 26 | case 'pardoned': 27 | case 'unbanned': 28 | case 'unmuted': 29 | case 'unban': 30 | case 'unmute': 31 | return GREEN; 32 | } 33 | return null; 34 | } 35 | -------------------------------------------------------------------------------- /src/util/format.js: -------------------------------------------------------------------------------- 1 | import config from '../bot/Config.js'; 2 | import {formatEmoji} from 'discord.js'; 3 | 4 | /** 5 | * convert a string to title case 6 | * @param {string} s 7 | * @returns {string} 8 | */ 9 | export function toTitleCase(s) { 10 | return s.toLowerCase().replace(/^(\w)|\s(\w)/g, c => c.toUpperCase()); 11 | } 12 | 13 | /** 14 | * @param {*} bool 15 | * @returns {string} 16 | */ 17 | export function yesNo(bool) { 18 | return bool ? 'Yes' : 'No'; 19 | } 20 | 21 | /** 22 | * @param {string} configKey 23 | * @returns {string} 24 | */ 25 | export function inlineEmojiIfExists(configKey) { 26 | const emoji = config.data.emoji[configKey]; 27 | if (!emoji) { 28 | return ''; 29 | } 30 | else { 31 | return formatEmoji(emoji) + ' '; 32 | } 33 | } 34 | 35 | /** 36 | * Format a number followed by a name/unit. 37 | * Add s if number is not 1 38 | * @param {number} number 39 | * @param {string} name 40 | * @returns {*} 41 | */ 42 | export function formatNumber(number, name) { 43 | if (number === 1) { 44 | return `${number} ${name}`; 45 | } 46 | return `${number} ${name}s`; 47 | } 48 | 49 | /** 50 | * @param {string} configKey name of the emoji in the config 51 | * @param {?string} fallback emoji character to use if the config key is not set 52 | * @returns {?import('discord.js').APIMessageComponentEmoji} 53 | */ 54 | export function componentEmojiIfExists(configKey, fallback = null) { 55 | const emoji = config.data.emoji[configKey]; 56 | if (emoji) { 57 | return {id: emoji}; 58 | } 59 | 60 | if (fallback) { 61 | return {name: fallback}; 62 | } 63 | 64 | return null; 65 | } -------------------------------------------------------------------------------- /src/util/fsutils.js: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import {readFile} from 'fs/promises'; 3 | 4 | /** 5 | * @param {string} path 6 | * @returns {Promise} 7 | */ 8 | export async function exists(path) { 9 | try { 10 | await fs.stat(path); 11 | } 12 | catch (e) { 13 | if (e.code === 'ENOENT') { 14 | return false; 15 | } 16 | throw e; 17 | } 18 | return true; 19 | } 20 | 21 | /** 22 | * read a json file 23 | * @param {string} path 24 | * @returns {Promise<*>} 25 | */ 26 | export async function readJSON(path) { 27 | return JSON.parse((await readFile(path)).toString()); 28 | } -------------------------------------------------------------------------------- /src/util/icons.js: -------------------------------------------------------------------------------- 1 | export default { 2 | error: String.fromCodePoint(128721), 3 | forbidden: String.fromCodePoint(9940), 4 | no: String.fromCodePoint(10060), 5 | yes: String.fromCodePoint(9989), 6 | first: String.fromCodePoint(9198), 7 | left: String.fromCodePoint(11013), 8 | refresh: String.fromCodePoint(128260), 9 | right: String.fromCodePoint(10145), 10 | last: String.fromCodePoint(9197), 11 | eyes: String.fromCodePoint(128064), 12 | article: String.fromCodePoint(128214), 13 | video: String.fromCodePoint(127909) 14 | }; 15 | -------------------------------------------------------------------------------- /src/util/interaction.js: -------------------------------------------------------------------------------- 1 | import {MessageFlags} from 'discord.js'; 2 | 3 | /** 4 | * Reply to an interaction if it wasn't already deferred or replied. Follow up otherwise. 5 | * @param {import('discord.js').Interaction} interaction 6 | * @param {string | import('discord.js').MessagePayload | import('discord.js').InteractionReplyOptions} options 7 | * @returns {Promise} 8 | */ 9 | export async function replyOrFollowUp(interaction, options) { 10 | if (interaction.deferred || interaction.replied) { 11 | await interaction.followUp(options); 12 | } 13 | else { 14 | await interaction.reply(options); 15 | } 16 | } 17 | 18 | /** 19 | * Reply to an interaction if it wasn't already deferred or replied. Edit the response otherwise. 20 | * @param {import('discord.js').Interaction} interaction 21 | * @param {string | import('discord.js').MessagePayload | import('discord.js').InteractionReplyOptions} options 22 | * @returns {Promise} 23 | */ 24 | export async function replyOrEdit(interaction, options) { 25 | if (interaction.deferred || interaction.replied) { 26 | await interaction.editReply(options); 27 | } 28 | else { 29 | await interaction.reply(options); 30 | } 31 | } 32 | 33 | /** 34 | * Defer a reply to an interaction if it wasn't already deferred or replied. 35 | * @param {import('discord.js').Interaction} interaction interaction to defer 36 | * @param {boolean} ephemeral should the reply be ephemeral 37 | * @returns {Promise} 38 | */ 39 | export async function deferReplyOnce(interaction, ephemeral = true) { 40 | if (!interaction.deferred && !interaction.replied) { 41 | await interaction.deferReply( {flags: ephemeral ? MessageFlags.Ephemeral : 0}); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/util/timeutils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * parse this time string to a duration in seconds 3 | * @param {?string} string 4 | * @returns {?number} null if no time was included in the string 5 | */ 6 | export function parseTime(string) { 7 | if (!string) { 8 | return null; 9 | } 10 | 11 | let total = null; 12 | 13 | const regex = /(?\d*\.?\d+)\s*(?years?|yrs?|y|months?|M|weeks?|w|days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)/i; 14 | let match = regex.exec(string); 15 | 16 | while (match) { 17 | let factor = 1; 18 | const unit = match.groups.unit.toLowerCase(); 19 | 20 | if (['years', 'year', 'yrs', 'yr', 'y'].includes(unit)) { 21 | factor = 365 * 24 * 60 * 60; 22 | } 23 | else if (['months', 'month'].includes(unit) || match.groups.unit === 'M') { 24 | factor = 30 * 24 * 60 * 60; 25 | } 26 | else if (['weeks', 'week', 'w'].includes(unit)) { 27 | factor = 7 * 24 * 60 * 60; 28 | } 29 | else if (['days', 'day', 'd'].includes(unit)) { 30 | factor = 24 * 60 * 60; 31 | } 32 | else if (['hours', 'hour', 'hrs', 'hr', 'h'].includes(unit)) { 33 | factor = 60 * 60; 34 | } 35 | else if (['minutes', 'minute', 'mins', 'min', 'm'].includes(unit)) { 36 | factor = 60; 37 | } 38 | else if (['seconds', 'second', 'secs', 'sec', 's'].includes(unit)) { 39 | factor = 1; 40 | } 41 | 42 | total ??= 0; 43 | total += parseInt(match.groups.value) * factor; 44 | 45 | string = string.slice(match[0].length); 46 | match = regex.exec(string); 47 | } 48 | 49 | return total; 50 | } 51 | 52 | /** 53 | * format this duration as a string 54 | * @param {number} duration in seconds 55 | * @returns {string} 56 | */ 57 | export function formatTime(duration) { 58 | let output = ''; 59 | for (let [name, factor] of [ 60 | ['year', 365 * 24 * 60 * 60], 61 | ['month', 30 * 24 * 60 * 60], 62 | ['day', 24 * 60 * 60], 63 | ['hour', 60 * 60], 64 | ['minute', 60], 65 | ['second', 1]]) { 66 | 67 | const value = Math.floor(duration / factor); 68 | if (value) { 69 | if (value !== 1) { 70 | name = name + 's'; 71 | } 72 | output += `${value} ${name} `; 73 | } 74 | duration = duration % factor; 75 | } 76 | 77 | return output.trimEnd(); 78 | } 79 | 80 | /** 81 | * current time as a unix timestamp (seconds) 82 | * @returns {number} 83 | */ 84 | export function timeNow() { 85 | return Math.floor(Date.now() / 1000); 86 | } 87 | 88 | /** 89 | * return time after duration has expired as unix timestamp (seconds) 90 | * @param {string} duration 91 | * @returns {number} 92 | */ 93 | export function timeAfter(duration) { 94 | return timeNow() + (parseTime(duration) ?? 0); 95 | } -------------------------------------------------------------------------------- /src/util/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Retries a function if it fails 3 | * @async 4 | * @param {Function} fn function to retry 5 | * @param {object} thisArg object that should execute the function 6 | * @param {Array} [args] arguments to pass to the function 7 | * @param {number} [maxRetries] amount of retries before throwing an error 8 | * @param {Function} [returnValMatch] function to test the result on 9 | * @returns {*} result of fn 10 | */ 11 | export async function retry(fn, thisArg, args = [], maxRetries = 5, returnValMatch = null) { 12 | let err; 13 | for (let i = 0; i < maxRetries; i++) { 14 | let res; 15 | try { 16 | res = await fn.apply(thisArg, args); 17 | } catch (e) { 18 | err = e; 19 | continue; 20 | } 21 | if (typeof returnValMatch === 'function' && !returnValMatch(res)) { 22 | err = new Error('Returned value did not match requirements'); 23 | continue; 24 | } 25 | return res; 26 | } 27 | throw err; 28 | } 29 | 30 | /** 31 | * @template T 32 | * @callback AsyncFilterCallback 33 | * @param {T} element 34 | * @param {...*} args 35 | * @returns {Promise} 36 | */ 37 | 38 | /** 39 | * 40 | * @template T 41 | * @param {T[]} array 42 | * @param {AsyncFilterCallback} filter 43 | * @param {*} args 44 | * @returns {Promise} 45 | */ 46 | export async function asyncFilter(array, filter, ...args) { 47 | const results = []; 48 | for (const element of array) { 49 | if (await filter(element, ...args)) { 50 | results.push(element); 51 | } 52 | } 53 | return results; 54 | } 55 | 56 | /** 57 | * Ensure that a number is within bounds 58 | * Returns min if value is below limit or not a number. 59 | * Returns max if value is above limit. 60 | * If value is within limits returns value 61 | * @param {number} value 62 | * @param {number} min 63 | * @param {number} max 64 | * @returns {number} 65 | */ 66 | export function inLimits(value, min, max) { 67 | if (isNaN(value)) { 68 | return min; 69 | } 70 | 71 | return Math.min(Math.max(value, min), max); 72 | } 73 | 74 | /** 75 | * deep merge two objects 76 | * @param {object} target 77 | * @param {object} source 78 | * @returns {object} 79 | */ 80 | export function deepMerge(target, source) { 81 | for (const key in source) { 82 | switch (typeof target[key]) { 83 | case 'undefined': 84 | target[key] = source[key]; 85 | break; 86 | case 'object': 87 | target[key] = deepMerge(target[key], source[key]); 88 | break; 89 | } 90 | } 91 | return target; 92 | } 93 | 94 | /** 95 | * escape a regular expression 96 | * @param {string} string 97 | * @returns {string} 98 | */ 99 | export function escapeRegExp(string) { 100 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 101 | } --------------------------------------------------------------------------------