diff --git a/.env.example b/.env.example index 7baa8750..b4ece774 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,6 @@ HEARTBEAT_INTERVAL=300 # Set the interval in seconds for the birthday handler to be run, 0 to disable BIRTHDAY_INTERVAL=1800 + +# Set the interval in seconds for the in memory cache to be refreshed, 1800 is the default +CACHE_REFRESH_INTERVAL=1800 diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index c6c3769a..a6a6aae3 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -2,6 +2,7 @@ Update _ October 2024 +- feat: Prefix Command Management (23/10/2024) - fix: role assignment typo for server announcements (22/10/2024) Update _ August 2024 diff --git a/config/production.json b/config/production.json index 97e71b7c..d7288b0a 100644 --- a/config/production.json +++ b/config/production.json @@ -30,6 +30,8 @@ "1163130801131634868", "1021464464928809022" ], + "prefixCommandPrefix": ".", + "prefixCommandPermissionDelay": 10000, "roleAssignmentIds": [ { "group": "interestedIn", diff --git a/config/staging.json b/config/staging.json index 76389adb..69be73e6 100644 --- a/config/staging.json +++ b/config/staging.json @@ -31,6 +31,8 @@ "1163130801131634868", "1021464464928809022" ], + "prefixCommandPrefix": ".", + "prefixCommandPermissionDelay": 10000, "roleAssignmentIds": [ { "group": "interestedIn", diff --git a/docs/prefix-commands.md b/docs/prefix-commands.md new file mode 100644 index 00000000..c44ca048 --- /dev/null +++ b/docs/prefix-commands.md @@ -0,0 +1,521 @@ +# Prefix Commands + +This documentation is meant for Discord server admins/moderators and for Developers. It'll first cover the General Concepts, then the Use for admins/moderators and finally the design so Developers understand how things have been set up. + +## Table of Content + +- [Prefix Commands](#prefix-commands) + - [Table of Content](#table-of-content) + - [Overview](#overview) + - [Detailed Concepts](#detailed-concepts) + - [Command](#command) + - [Command Content](#command-content) + - [Category](#category) + - [Version](#version) + - [Version Behavior](#version-behavior) + - [Channel Default Version](#channel-default-version) + - [Permission](#permission) + - [Permission Behavior](#permission-behavior) + - [Errors](#errors) + - [Managing Prefix Commands](#managing-prefix-commands) + - [Requirements and Bot Setup](#requirements-and-bot-setup) + - [Management Capabilities](#management-capabilities) + - [Managing Categories](#managing-categories) + - [Listing Categories](#listing-categories) + - [Adding a Category](#adding-a-category) + - [Modifying a Category](#modifying-a-category) + - [Deleting a Category](#deleting-a-category) + - [Managing Versions](#managing-versions) + - [Listing Versions](#listing-versions) + - [Adding a Version](#adding-a-version) + - [Modifying a Version](#modifying-a-version) + - [Deleting a Version](#deleting-a-version) + - [Managing Commands](#managing-commands) + - [Listing commands](#listing-commands) + - [Adding a command](#adding-a-command) + - [Modifying a command](#modifying-a-command) + - [Deleting a command](#deleting-a-command) + - [Managing Content](#managing-content) + - [Showing Content](#showing-content) + - [Setting Content](#setting-content) + - [Deleting Content](#deleting-content) + - [Managing Command Permissions](#managing-command-permissions) + - [Showing Permissions](#showing-permissions) + - [Setting Permissions](#setting-permissions) + - [Managing Channels](#managing-channels) + - [Managing Roles](#managing-roles) + - [Managing Channel Default Versions](#managing-channel-default-versions) + - [Showing the Channel Default Version](#showing-the-channel-default-version) + - [Setting the Channel Default Version](#setting-the-channel-default-version) + - [Deleting the Channel Default Version](#deleting-the-channel-default-version) + - [Showing Commands for a Category](#showing-commands-for-a-category) + - [Design and Development Overview](#design-and-development-overview) + - [In-Memory Cache](#in-memory-cache) + - [Cache Design](#cache-design) + - [Command Cache](#command-cache) + - [Versions Cache](#versions-cache) + - [Categories Cache](#categories-cache) + - [Channel Default Version Cache](#channel-default-version-cache) + - [Cache Refresh](#cache-refresh) + - [Message Handler](#message-handler) + +## Overview + +Prefix Commands are dynamically controlled commands that are accessed by using a prefix. For example, if the prefix is configured to be `.`, and the command is `hello`, the user in Discord would execute `.hello`. + +These commands are not hard coded in the bot, and instead they are configured through typical `/`-commands and then stored in the MongoDB. + +Additionally, there's a set of features that make these commands very flexible in use: + +- **Categories** + Commands are categorized for identifying the purpose or use of the command. Categories are manage dynamically through `/`-commands and stored in MongoDB. + +- **Versions** + The content of commands are defined per Version. A version can be used to provide different content based on the context in which the command is executed. By default, there is a hard-coded `GENERIC` version available, more can be added and managed dynamically through `/`-commands and stored in MongoDB. If multiple versions exist, and no version is specified during the execution, the `GENERIC` version is shown, with buttons to select the version to be shown. + +- **Content** + For each version, it is possible (but not a must) to set the content of a command. The content is static information that is managed with `/`-commands and stored in MongoDB. Depending on the version requested, the content is loaded and shown. Content contains a Title, Body and Image. + +- **Permissions** + Two types of permissions exist: Channel permissions and Role permissions. Using permissions it is possible to block or allow the use of a command in certain channels or by certain roles. + +- **Channel Default Versions** + For every channel, a specific version can be set as the default. In this case, even if there are multiple versions, if the command is executed without a version specified, the version set as the default for that channel is shown. + +### Detailed Concepts + +#### Command + +Commands only contain the basic information that is needed to use them: + +- `name` + A command has a name, this is the main way to execute the command, in combination with the configured prefix. This is a required attribute. +- `category` + The category a command belongs to, a command can only belong to a single category. This is a required attribute. +- `description` + The description of commands gives a short and brief overview of what the command is used for. This is a required attribute. +- `aliases` + A comma separated list for aliases for this command, each alias can be used to call the command instead of the name of the command. This is an optional attribute. Default value is empty. +- `is_embed` + A boolean attribute that identifies if the output should be posted as an Embed. If set to `False`, it will be shown as a regular text output, which is useful for simple image commands, or for simple links. This is an optional attribute. Default value is `False`. +- `embed_color` + Embeds in Discord have a color to the left, which can be used to give it a special look. By setting this value, you can change the default `FBW_CYAN` to other special colors. Colors are defined in the Config JSON file. This is an optional attribute. Default value is `FBW_CYAN` (only value currently in the `production.json` config file). + +Note that a Command does not contain any information about the content itself. This is because the content is specific per version and is described below. + +#### Command Content + +Content contains the actual information that is shown to the user when executing the command. Depending on the configuration of the command itself, this content will be displayed as an Embed or as standard text. The following attributes are available: + +- `version` + The version this content applies to, this is a reference to one of the existing versions in the bot. This is a required attribute. +- `title` + The title of the content, this will be shown as the title of the Embed, or as the first bold line of the text output in case the command is not an embed. This is a required attribute. +- `content` + A markdown capable string that identifies the actual content of the specified version. It can be up to 2048 Unicode characters, including emojis. This is an optional attribute. The default value is an empty (`null`), in which case it will not be shown as part of the output. +- `image` + A URL that refers to an image that should be shown as part of the content if the command is an Embed. For text-style commands, the URL should be part of the content itself so Discord automatically loads it as a preview. This is an optional attribute. The default value is empty (`null`). + +The `image` behavior can be surprising, but is chosen because it is not possible to just 'add' a message to a text-style response, unless if it is uploaded, and the decision was made to not upload an image very time the command is executed. What can be done is use markdown to load the image using the link syntax. Discord will then automatically load it as a preview. + +#### Category + +Categories are used to group commands together. This is mostly useful for when the help command is called to get the list of available commands for a specific category. It groups them together and makes it easy to identify. Categories have the following attributes: + +- `name` + The name as how it should be shown to the users and in any output. This is a required attribute. +- `emoji` + An emoji to identify the category, this will be shown next to the category whenever shown. This is an optional attribute. The default value is empty (`null`), in which case it isn't shown. + +#### Version + +Versions are useful if you want the same command to have different contents based on the context in which it is executed. An example use for FlyByWire Simulations is to use it to have a single command that gives different output based on the product for which it is requested. Later the impact of Versions will be described more. Versions have the following attributes: + +- `name` + A name for the version, this is how the version is identified in the different commands on how to manage the content of commands. This is a required attribute. +- `emoji` + The emoji associated with the version. When a command is executed without any version context, a GENERIC content will be displayed that offers the user the choice to get the details for a specific version. It does so by showing the emojis as buttons for the user to select. This is a required attribute. +- `alias` + This is a command alias for the version. By executing ` `, the user can get the content for a specific version directly, instead of going through the GENERIC content. This is a required attribute. +- `is_enabled` + A boolean attribute that can enable or disable a version. When this is set to `False`, the version will not be exposed to users. It will not show up in the selection of versions for the GENERIC content, and the alias will not work. This allows for versions and the content for those versions to be created ahead of enabling them. This is an optional attribute. The default value is `False`. + +#### Version Behavior + +Users can execute commands in two different ways, and they each result in different behavior. Any time a non-GENERIC version is mentioned, it must be enabled. Disable versions will never show up: + +- `` + This is the direct way of executing a command, depending on the available content, several things might happen: + - If no Channel Default Version is configured for the channel in which the command is executed: + - GENERIC version and one or more other versions have content: + The GENERIC content is shown and the user is given a choice underneath it with buttons containing the emjois of the other versions with content. When the user clicks on one of the buttons, the GENERIC content is removed and a new message is send with the content of the selected version. + - Only GENERIC version has content: + The GENERIC content is shown, and the user is not given a choice of other versions, as there is no choice available. + - No GENERIC content is set: + No response is given to the user: + - No content is set at all: + No response is given to the user. + - If a Channel Default Version is configured for the channel in which the command is executed: + - Content is set for the Channel Default Version: + The content for that version is shown to the user directly. + - No content is set for the Channel Default Version, but it does exist for the GENERIC version: + The content for the GENERIC version is shown, but no selection buttons are shown. + - No content is set for the Channel Default version, and no content exists for the GENERIC version: + No response is given to the user. + - No content is set at all: + No response is given to the user. +- ` ` + This directly requests the content for the specified version: + - Content is set for the specified version: + The content for the specified version is shown to the user directly. + - No content is set for the specified version, GENERIC version and one or more other versions have content: + The GENERIC content is shown and the user is given a choice underneath it with buttons containing the emjois of the other versions with content. When the user clicks on one of the buttons, the GENERIC content is removed and a new message is send with the content of the selected version. + - Content is not set for the specified version and only GENERIC version has content: + The GENERIC content is shown, and the user is not given a choice of other versions, as there is no choice available. + - No content is set for the specified version and no GENERIC content is set: + No response is given to the user. + - No content is set at all: + No response is given to the user. + +#### Channel Default Version + +It is possible to set a version as the default for a specific channel. By doing so, whenever someone executes the command directly (`` in that channel, it will automatically default to that version and not first post the GENERIC version with choices. This bypasses the choice menu and allows for an optimized experience for users. Two attributes need to be provided during configuration: + +- `channel` + The Discord channel to which the version should be defaulted to. This is a required attribute. +- `version` + The version that should be the default for this channel. It is possible to select a disabled version for this, but if you do so and the command is executed in the channel, no output will be shown. This is a required attribute. + +#### Permission + +Permissions can be set on commands so there are limitations to who can use the command and/or in which channels they can be used. The permission behavior is described below. Permissions have the following attributes: + +- `roles` + A list of Discord roles that either have access or do not have access to the command. This is an optional attribute. The default is an empty list, which results in the roles of the user not being checked. +- `role-blocklist` + A boolean attribute that identifies if the list of roles is blocked from using the command (`True`) or allowed to use the command (`False`). This is an optional attribute. The default value is `False`, meaning that if a `roles` list is set, only users with at least one of those roles can execute the command. +- `channels` + A list of Disord channels in which the command can either be executed or not executed. This is an optional attirubute. The default is an empty list, which results in the channel not being checked. +- `channel-blocklist` + A boolean attribute that identifies if the list of channels is blocked from command execution (`True`) or allowed to execute the command in (`False`). This is an optional attribute. The default value is `False`, meaning that if a `channels` list is set, the command is only allowed to be executed in one of those channels. +- `quiet-errors` + A boolean attribute, which if set to `True` will not display any warning to the user and will quietly fail the command execution if the permissions do not allow the execution of the command. This is an optional attribute. The default value is `False`. +- `verbose-errors` + A boolean attribute, which if set to `True` will show detailed output about the permission violated and who (role violation) or where (channel violation) the command can be executed. This is an optional attribute. The default value is `False`. + +##### Permission Behavior + +Permissions are checked in the following flow: + +- If there is a list of `roles` defined, the user is checked if they have any of the roles. + - If `role-blocklist` is `False` and the user *does not* have any of the roles, the execution of the command is blocked. + - If `role-blocklist` is `True` and the user *does* have any of the roles, the execution of the command is blocked. +- If there is a list of `channels` defined, the list is checked to see if the channel in which the command is executed, is part of the list. + - If `channel-blocklist` is `False` and the command *is not* executed in any of the channels, the execution of the command is blocked. + - If `channel-blocklist` is `True` and the command *is* executed in any of the channels, the execution of the command is blocked. + +##### Errors + +By default, when a command is blocked from execution, a message is shown that the execution is blocked either because of a role permission, or a channel permission. These messages are generic and do not reveal any detail about which role or channel needs to be present (blocklist is `False`), or needs to be absent (blocklist is `True`). + +When `verbose-errors` is `True`, the message is enriched with information about which roles or channels are allowed (blocklist is `False`), or blocked (blocklist is `True`). + +When `quiet-errors` is `True`, no message is send at all. + +If both are set to `True`, `quiet-errors` takes precedence. + +## Managing Prefix Commands + +### Requirements and Bot Setup + +The bot will require a MongoDB environment set up. There are a few settings to configure: + +- **Config JSON**: + - `prefixCommandPrefix` + A String that will be the prefix for command that will be used by the user to execute commands. For example `.` to execute commands like `.help` + - `prefixCommandPermissionDelay` + A number in milliseconds that identifies the delay before the message about invalid permissions is deleted. + +- **Environment Variable**: + - `CACHE_REFRESH_INTERVAL` + A number in milliseconds that is used to automatically refresh the cache from the database, to make sure the cache remains up to date. + +### Management Capabilities + +High level, the following capabilities exist from a management perspective, each with their own `/`-command: + +- Manage Categories + - `/prefix-commands categories list [search_text]` + - `/prefix-commands categories add [emoji]` + - `/prefix-commands categories modify [name] [emoji]` + - `/prefix-commands categories delete ` +- Manage Versions + - `/prefix-commands versions list [search_text]` + - `/prefix-commands versions add [is_enabled]` + - `/prefix-commands versions modify [name] [emoji] [alias] [is_enabled]` + - `/prefix-commands versions delete [force]` +- Manage Commands + - `/prefix-commands commands list [search_text]` + - `/prefix-commands commands add [aliases] [is_embed] [embed_color]` + - `/prefix-commands commands modify [name] [category] [description] [aliases] [is_embed] [embed_color]` + - `/prefix-commands commands ` +- Manage Content for Commands + - `/prefix-commands content show ` + - `/prefix-commands content set ` + - `/prefix-commands content delete ` +- Manage Permissions for Commands + - `/prefix-command-permissions show ` + - `/prefix-command-permissions settings [roles-blocklist] [channels-blocklist] [quiet-errors] [verbose-errors]` + - `/prefix-command-permissions channels add ` + - `/prefix-command-permissions channels remove ` + - `/prefix-command-permissions roles add ` + - `/prefix-command-permissions roles remove ` +- Manage Channel Default Versions for Channels + - `/prefix-commands channel-default-version show ` + - `/prefix-commands channel-default-version set ` + - `/prefix-commands channel-default-version delete ` +- Showing available Commands per Category + - `/prefix-help [search]` + +Below is a deeper dive in each of those. The deep-dives will not contain any details on the attributes themselves, those have been described above. + +### Managing Categories + +#### Listing Categories + +It is possible to get a list of all categories, or a list of filtered categories, using the following command: + +`/prefix-commands categories list [search_text]` + +In this command, if the `search_text` is empty, all categories are shown. If it is set, any category containing the provided string will be listed. + +#### Adding a Category + +Adding a category is done using the command below. A name must be provided, and must be unique. An emoji can optionally be provided to have a nice representation of the category. + +`/prefix-commands categories add [emoji]` + +#### Modifying a Category + +To modify a category, use the command below. When executing the command, you have to select the category you want to modify. This is an automatically generated list from the existing categories. Then you provide an optional new name and new emoji. If you do not provide a new value, the original setting is kept. + +`/prefix-commands categories modify [name] [emoji]` + +#### Deleting a Category + +You can delete a category by using the below command. This will fail if there's still commands part of the category. + +`/prefix-commands categories delete ` + +### Managing Versions + +#### Listing Versions + +It is possible to get a list of all versions, or a list of filtered versions, using the following command: + +`/prefix-commands versions list [search_text]` + +In this command, if the `search_text` is empty, all versions are shown. If it is set, any version containing the provided string will be listed. + +#### Adding a Version + +Adding a version is done using the command below. A name, emoji and alias must be provided, and they must be unique. A version can optionally be enabled. + +`/prefix-commands versions add [is_enabled]` + +#### Modifying a Version + +To modify a version, use the command below. When executing the command, you have to select the version you want to modify. This is an automatically generated list from the existing versions. Then you provide an optional new name, new emoji, new alias and if you want to enable or disable it. If you do not provide a new value, the original setting is kept. + +`/prefix-commands versions modify [name] [emoji] [alias] [is_enabled]` + +#### Deleting a Version + +You can delete a version by using the below command. This will fail if there's still content or channel default versions configured of the version unless force is enabled. Deleting a version with force will automatically delete any content and channel default versions referencing it. + +`/prefix-commands versions delete [force]` + +### Managing Commands + +#### Listing commands + +It is possible to get a list of all commands, or a list of filtered commands, using the following command: + +`/prefix-commands commands list [search_text]` + +In this command, if the `search_text` is empty, all commands are shown. If it is set, any command containing the provided string will be listed. + +#### Adding a command + +Adding a command is done using the command below. A name, category and description must be provided. The name must be unique. A command can optionally have aliases. By default, a command is not an Embed, but this can be enabled and a color for the embed can be selected. + +`/prefix-commands commands add [aliases] [is_embed] [embed_color]` + +#### Modifying a command + +To modify a command, use the command below. When executing the command, you have to select the command you want to modify. This is an automatically generated list from the existing commands. Then you provide an optional new name, new category, new description, new aliases, if you want to make it an embed or not, and what color the embed should have. If you do not provide a new value, the original setting is kept. + +`/prefix-commands commands modify [name] [category] [description] [aliases] [is_embed] [embed_color]` + +#### Deleting a command + +You can delete a command by using the below command. Deleting a command will automatically delete all content for it, as well as its permissions. + +`/prefix-commands commands ` + +### Managing Content + +### Showing Content + +It is possible to show to show the content of a command and version by using the below command. You must provide a command and version. + +`/prefix-commands content show ` + +### Setting Content + +To set the content of a command, execute the below command. You must provide a command and version. + +`/prefix-commands content set ` + +When executing this command, a window will pop open that will have a form asking for the title (required), content (optional) and image URL (optional). If the content was already set, it will be prefilled so it is easy to modify. You will have 2 minutes to fill in the fields. + +It is strongly advised that you prepare the content and just copy/paste it into the form, instead of typing it out in the form itself, as it might time out. + +**It is best to always create content for the GENERIC version for every command, as it is a fallback and default in many cases.** + +#### Deleting Content + +If you want to remove the content for a command and version entirely, you can do so using the below command. You must provide a command and version. + +`/prefix-commands content delete ` + +### Managing Command Permissions + +#### Showing Permissions + +To show the permission settings of a command, execute the following command. You must select a command. It will show an embed with all the settings, roles and channels that are configured for the command. + +`/prefix-command-permissions show ` + +#### Setting Permissions + +You can set the different settings for a command using the following command. You must select a command, but the rest of the attributes are optional. If you do not provide a new value, the original setting is kept. + +`/prefix-command-permissions settings [roles-blocklist] [channels-blocklist] [quiet-errors] [verbose-errors]` + +#### Managing Channels + +The list of channels to which the permissions apply can be set using the following commands. For simplicity, managing the list is done by adding or removing channels. You must provide the command and channel. If you try to remove a channel that isn't in the list, or try to add a channel that is already in the list, nothing will happen. + +`/prefix-command-permissions channels add ` +`/prefix-command-permissions channels remove ` + +#### Managing Roles + +The list of roles to which the permissions apply can be set using the following commands. For simplicity, managing the list is done by adding or removing roles. You must provide the command and channel. If you try to remove a channel that isn't in the list, or try to add a channel that is already in the list, nothing will happen. + +`/prefix-command-permissions roles add ` +`/prefix-command-permissions roles remove ` + +### Managing Channel Default Versions + +#### Showing the Channel Default Version + +To show the channel default version for a specific channel, execute the following command. A channel must be provided. If no channel default version is set, it will let you know. + +`/prefix-commands channel-default-version show ` + +#### Setting the Channel Default Version + +Setting the default version for a channel can be done with the below command. You must provide the channel and the version. + +`/prefix-commands channel-default-version set ` + +#### Deleting the Channel Default Version + +To remove a channel default version, and return a channel to use the generic version again by default, you can execute the below command. A channel must be provided. + +`/prefix-commands channel-default-version delete ` + +### Showing Commands for a Category + +It is possible for any user, regardless of role and permissions, to list all the existing prefix commands for a specific category, by using the below command. This command has an optional search, any command within the category that matches the search, will be shown with it's name, versions, aliases and description. + +`/prefix-help [search]` + +## Design and Development Overview + +A few items should be highlighted in how the design of this solution works, most importantly around the caching system and the message handling. + +### In-Memory Cache + +As all prefix commands are stored in MongoDB, if there was no local in-memory cache, for every time someone started a message with the prefix, the bot would need to go to MongoDB to check if it existed, retrieve the necessary details, including version details, content information and permissions. This would cause a lot of activity towards the database, and given the database is not hosted together with the bot, this could cause significant delays in handling the commands. + +To avoid these problems, an in-memory cache is set up when the bot starts up. During startup, it will fill the cache with all the data from the database. The following objects are currently cached: + +- Commands, including + - Content + - Permissions +- Versions +- Categories +- Channel Default Versions + +#### Cache Design + +The in-memory cache is a simple key/value pair cache that is fully stored in memory. To distinguish between the different types of objects, the key is prefixed with a specific string that identifies the type of object. + +##### Command Cache + +The prefix for commands is set as `PF_COMMAND`. For each command, the command is stored potentially multiple times: + +- Once for the name of the command, in lower case (`PF_COMMAND:`). +- Once for every alias of the command, in lower case (`PF_COMMAND:`). + +In each case, the entire command object is stored as the value. This is done for the efficient retrieval of the command when executed by a user, either by its name, or by its alias. + +Every time the command, content or permissions for a command is added, modified or deleted, the cache is refreshed for that command. + +##### Versions Cache + +The prefix for versions is set as `PF_VERSION`. For each version the version is stored twice: + +- Once for the name of the version, in lower case (`PF_VERSION:`). +- Once for the MongoDB ID of the version (`PF_VERSION:<_id>`). + +In each case, the entire version object is stored as the value. The latter case is done as in several cases, a version will be referenced by ID and this optimizes the access to versions. + +Every time a version is added, modified or deleted, the cache is refreshed for that version. + +#### Categories Cache + +The prefix for categories is set as `PF_CATEGORY`. For each category the category is stored once for the name of the category, in lower case (`PF_CATEGORY:`). + +In each case, the entire category object is stored as the value. + +Every time a category is added, modified or deleted, the cache is refreshed for that category. + +#### Channel Default Version Cache + +The prefix for channel default versions is set as `PF_CHANNEL_VERSION`. For each channel default version the channel default version is stored once for the channel ID of the channel (`PF_CHANNEL_VERSION:`). + +In each case, the entire channel default version object is stored as the value. + +Every time a channel default version is added, modified or deleted, the cache is refreshed for that channel default version. + +#### Cache Refresh + +Periodically, the cache will be updated automatically using the scheduler. The interval is configurable using an environment variable `CACHE_REFRESH_INTERVAL`. + +When the cache is refreshed, it will do the following steps: + +1. Fetch all existing items from the DB. +2. Fetch all keys from the cache. +3. Loop over all keys found in cache. + 1. Verify the item still exists in the items from the DB. If not, delete the cache key. +4. Update or Add all objects from the DB. + +### Message Handler + +A new Message Handler has been created that listens for all message creations. It takes the following steps: + +TODO: Add diagram - to be drawn diff --git a/package-lock.json b/package-lock.json index 1da2a388..28533493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@hokify/agenda": "^6.0.0", "@octokit/request": "^8.1.1", "bad-words": "^3.0.4", + "cache-manager": "^5.7.6", "config": "^3.3.9", "discord.js": "^14.11.0", "jsdom": "^23.2.0", @@ -3426,6 +3427,25 @@ "ieee754": "^1.1.13" } }, + "node_modules/cache-manager": { + "version": "5.7.6", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz", + "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.2.2", + "promise-coalesce": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, "node_modules/call-bind": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", @@ -4572,6 +4592,11 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5734,6 +5759,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6403,6 +6433,14 @@ "node": ">=0.4.0" } }, + "node_modules/promise-coalesce": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", + "integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==", + "engines": { + "node": ">=16" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index 20845ba2..8c34b285 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@hokify/agenda": "^6.0.0", "@octokit/request": "^8.1.1", "bad-words": "^3.0.4", + "cache-manager": "^5.7.6", "config": "^3.3.9", "discord.js": "^14.11.0", "jsdom": "^23.2.0", diff --git a/src/commands/index.ts b/src/commands/index.ts index e16ab038..244171fe 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -31,6 +31,10 @@ import commandTable from './moderation/commandTable'; import listRoleUsers from './moderation/listRoleUsers'; import clearMessages from './moderation/clearMessages'; import locate from './utils/locate/locate'; +import prefixCommands from './moderation/prefixCommands/prefixCommands'; +import prefixCommandPermissions from './moderation/prefixCommands/prefixCommandPermissions'; +import prefixCommandCacheUpdate from './moderation/prefixCommands/prefixCommandCacheUpdate'; +import prefixHelp from './utils/prefixHelp'; const commandArray: SlashCommand[] = [ ping, @@ -65,6 +69,10 @@ const commandArray: SlashCommand[] = [ listRoleUsers, clearMessages, locate, + prefixCommands, + prefixCommandPermissions, + prefixCommandCacheUpdate, + prefixHelp, ]; export default commandArray; diff --git a/src/commands/moderation/prefixCommands/functions/addCategory.ts b/src/commands/moderation/prefixCommands/functions/addCategory.ts new file mode 100644 index 00000000..73645c6f --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/addCategory.ts @@ -0,0 +1,97 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommandCategory, Logger, makeEmbed, loadSinglePrefixCommandCategoryToCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Add Category - No Connection', + description: 'Could not connect to the database. Unable to add the prefix command category.', + color: Colors.Red, +}); + +const failedEmbed = (category: string) => makeEmbed({ + title: 'Prefix Commands - Add Category - Failed', + description: `Failed to add the prefix command category ${category}.`, + color: Colors.Red, +}); + +const alreadyExistsEmbed = (category: string) => makeEmbed({ + title: 'Prefix Commands - Add Category - Already exists', + description: `The prefix command category ${category} already exists. Not adding again.`, + color: Colors.Red, +}); + +const successEmbed = (category: string) => makeEmbed({ + title: `Prefix command category ${category} was added successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, category: string, emoji: string, categoryId: string) => makeEmbed({ + title: 'Prefix command category added', + fields: [ + { + name: 'Category', + value: category, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + { + name: 'Emoji', + value: emoji, + }, + ], + footer: { text: `Category ID: ${categoryId}` }, + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Add Category - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleAddPrefixCommandCategory(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const name = interaction.options.getString('name')!; + const emoji = interaction.options.getString('emoji') || ''; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const existingCategory = await PrefixCommandCategory.findOne({ name }); + + if (!existingCategory) { + const prefixCommandCategory = new PrefixCommandCategory({ + name, + emoji, + }); + try { + await prefixCommandCategory.save(); + await loadSinglePrefixCommandCategoryToCache(prefixCommandCategory); + await interaction.followUp({ embeds: [successEmbed(name)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, name, emoji, prefixCommandCategory.id)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to add a prefix command category ${name}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(name)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [alreadyExistsEmbed(name)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/addChannelPermission.ts b/src/commands/moderation/prefixCommands/functions/addChannelPermission.ts new file mode 100644 index 00000000..823f277c --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/addChannelPermission.ts @@ -0,0 +1,112 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommand, Logger, makeEmbed, refreshSinglePrefixCommandCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Add Channel - No Connection', + description: 'Could not connect to the database. Unable to add the prefix command channel.', + color: Colors.Red, +}); + +const noCommandEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Add Channel - No Command', + description: `Failed to add the prefix command channel for command ${command} as the command does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const failedEmbed = (command: string, channel: string) => makeEmbed({ + title: 'Prefix Commands - Add Channel - Failed', + description: `Failed to add the prefix command channel <#${channel}> for command ${command}.`, + color: Colors.Red, +}); + +const alreadyExistsEmbed = (command: string, channel: string) => makeEmbed({ + title: 'Prefix Commands - Add Channel - Already exists', + description: `A prefix command channel <#${channel}> for command ${command} already exists. Not adding again.`, + color: Colors.Red, +}); + +const successEmbed = (command: string, channel: string) => makeEmbed({ + title: `Prefix command channel <#${channel}> added for command ${command}.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, command: string, channel: string) => makeEmbed({ + title: 'Add prefix command channel permission', + fields: [ + { + name: 'Command', + value: command, + }, + { + name: 'Channel', + value: `<#${channel}>`, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + ], + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Add Channel - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleAddPrefixCommandChannelPermission(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command')!; + const channel = interaction.options.getChannel('channel')!; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + let foundCommands = await PrefixCommand.find({ name: command }); + if (!foundCommands || foundCommands.length > 1) { + foundCommands = await PrefixCommand.find({ aliases: { $in: [command] } }); + } + if (!foundCommands || foundCommands.length > 1) { + await interaction.followUp({ embeds: [noCommandEmbed(command)], ephemeral: true }); + return; + } + const [foundCommand] = foundCommands; + const { id: channelId } = channel; + + const existingChannelPermission = foundCommand.permissions.channels?.includes(channelId); + if (!existingChannelPermission) { + if (!foundCommand.permissions.channels) { + foundCommand.permissions.channels = []; + } + foundCommand.permissions.channels.push(channelId); + try { + await foundCommand.save(); + await refreshSinglePrefixCommandCache(foundCommand, foundCommand); + await interaction.followUp({ embeds: [successEmbed(command, channelId)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, command, channelId)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to add prefix command channel <#${channel}> for command ${command}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(command, channelId)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [alreadyExistsEmbed(command, channelId)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/addCommand.ts b/src/commands/moderation/prefixCommands/functions/addCommand.ts new file mode 100644 index 00000000..ebcbe8a6 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/addCommand.ts @@ -0,0 +1,178 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommand, Logger, makeEmbed, PrefixCommandCategory, loadSinglePrefixCommandToCache, PrefixCommandVersion } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Add Command - No Connection', + description: 'Could not connect to the database. Unable to add the prefix command.', + color: Colors.Red, +}); + +const failedEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Add Command - Failed', + description: `Failed to add the prefix command ${command}.`, + color: Colors.Red, +}); + +const wrongFormatEmbed = (invalidString: string) => makeEmbed({ + title: 'Prefix Commands - Add Command - Wrong format', + description: `The name and aliases of a command can only contain alphanumerical characters, underscores and dashes. ${invalidString} is invalid.`, + color: Colors.Red, +}); + +const categoryNotFoundEmbed = (category: string) => makeEmbed({ + title: 'Prefix Commands - Add Command - Category not found', + description: `The prefix command category ${category} does not exist. Please create it first.`, + color: Colors.Red, +}); + +const alreadyExistsEmbed = (command: string, reason: string) => makeEmbed({ + title: 'Prefix Commands - Add Command - Already exists', + description: `The prefix command ${command} can not be added: ${reason}`, + color: Colors.Red, +}); + +const successEmbed = (command: string) => makeEmbed({ + title: `Prefix command ${command} was added successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, command: string, aliases: string[], description: string, isEmbed: boolean, embedColor: string, commandId: string) => makeEmbed({ + title: 'Prefix command added', + fields: [ + { + name: 'Command', + value: command, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + { + name: 'Aliases', + value: aliases.join(','), + }, + { + name: 'Description', + value: description, + }, + { + name: 'Is Embed', + value: isEmbed ? 'Yes' : 'No', + }, + { + name: 'Embed Color', + value: embedColor || '', + }, + ], + footer: { text: `Command ID: ${commandId}` }, + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Add Command - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleAddPrefixCommand(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const name = interaction.options.getString('name')?.toLowerCase().trim()!; + const category = interaction.options.getString('category')!; + const description = interaction.options.getString('description')!; + const aliasesString = interaction.options.getString('aliases')?.toLowerCase().trim() || ''; + const aliases = aliasesString !== '' ? aliasesString.split(',') : []; + const isEmbed = interaction.options.getBoolean('is_embed') || false; + const embedColor = interaction.options.getString('embed_color') || ''; + const moderator = interaction.user; + + const nameRegex = /^[\w-]+$/; + if (!nameRegex.test(name)) { + await interaction.followUp({ embeds: [wrongFormatEmbed(name)], ephemeral: true }); + return; + } + for (const alias of aliases) { + if (!nameRegex.test(alias)) { + // eslint-disable-next-line no-await-in-loop + await interaction.followUp({ embeds: [wrongFormatEmbed(alias)], ephemeral: true }); + return; + } + } + + // Check if command name and alias are unique, additionally check if they do not exist as a version alias. + const foundCommandName = await PrefixCommand.findOne({ + $or: [ + { name }, + { name: { $in: aliases } }, + { aliases: name }, + { aliases: { $in: aliases } }, + ], + }); + if (foundCommandName) { + await interaction.followUp({ embeds: [alreadyExistsEmbed(name, `${name} already exists as a command or alias, or one of the aliases already exists as a command or alias.`)], ephemeral: true }); + return; + } + const foundVersion = await PrefixCommandVersion.findOne({ + $or: [ + { alias: name }, + { alias: { $in: aliases } }, + ], + }); + if (foundVersion || name.toLowerCase() === 'generic' || aliases.includes('generic')) { + await interaction.followUp({ embeds: [alreadyExistsEmbed(name, `${name} already exists as a version alias, or one of the aliases already exists as a version alias.`)], ephemeral: true }); + return; + } + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const foundCategory = await PrefixCommandCategory.findOne({ name: category }); + if (!foundCategory) { + await interaction.followUp({ embeds: [categoryNotFoundEmbed(category)], ephemeral: true }); + return; + } + const { id: categoryId } = foundCategory; + Logger.info(`categoryId: ${categoryId}`); + + const prefixCommand = new PrefixCommand({ + name, + categoryId, + aliases, + description, + isEmbed, + embedColor, + contents: [], + permissions: { + roles: [], + rolesBlocklist: false, + channels: [], + channelsBlocklist: false, + quietErrors: false, + verboseErrors: false, + }, + }); + try { + await prefixCommand.save(); + await loadSinglePrefixCommandToCache(prefixCommand); + await interaction.followUp({ embeds: [successEmbed(name)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, name, aliases, description, isEmbed, embedColor, prefixCommand.id)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to add a prefix command ${name}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(name)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/addRolePermission.ts b/src/commands/moderation/prefixCommands/functions/addRolePermission.ts new file mode 100644 index 00000000..946b24ce --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/addRolePermission.ts @@ -0,0 +1,112 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommand, Logger, makeEmbed, refreshSinglePrefixCommandCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Add Role - No Connection', + description: 'Could not connect to the database. Unable to add the prefix command role.', + color: Colors.Red, +}); + +const noCommandEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Add Role - No Command', + description: `Failed to add the prefix command role for command ${command} as the command does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const failedEmbed = (command: string, roleName: string) => makeEmbed({ + title: 'Prefix Commands - Add Role - Failed', + description: `Failed to add the prefix command role ${roleName} for command ${command}.`, + color: Colors.Red, +}); + +const alreadyExistsEmbed = (command: string, roleName: string) => makeEmbed({ + title: 'Prefix Commands - Add Role - Already exists', + description: `A prefix command role ${roleName} for command ${command} already exists. Not adding again.`, + color: Colors.Red, +}); + +const successEmbed = (command: string, roleName: string) => makeEmbed({ + title: `Prefix command role ${roleName} added for command ${command}.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, command: string, roleName: string) => makeEmbed({ + title: 'Add prefix command role permission', + fields: [ + { + name: 'Command', + value: command, + }, + { + name: 'Role', + value: roleName, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + ], + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Add Role - No Mod Log', + description: 'I can\'t find the mod logs role. Please check the role still exists.', + color: Colors.Red, +}); + +export async function handleAddPrefixCommandRolePermission(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command')!; + const role = interaction.options.getRole('role')!; + const moderator = interaction.user; + + //Check if the mod logs role exists + const modLogsRole = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsRole) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + let foundCommands = await PrefixCommand.find({ name: command }); + if (!foundCommands || foundCommands.length > 1) { + foundCommands = await PrefixCommand.find({ aliases: { $in: [command] } }); + } + if (!foundCommands || foundCommands.length > 1) { + await interaction.followUp({ embeds: [noCommandEmbed(command)], ephemeral: true }); + return; + } + const [foundCommand] = foundCommands; + const { id: roleId, name: roleName } = role; + + const existingRolePermission = foundCommand.permissions.roles?.includes(roleId); + if (!existingRolePermission) { + if (!foundCommand.permissions.roles) { + foundCommand.permissions.roles = []; + } + foundCommand.permissions.roles.push(roleId); + try { + await foundCommand.save(); + await refreshSinglePrefixCommandCache(foundCommand, foundCommand); + await interaction.followUp({ embeds: [successEmbed(command, roleName)], ephemeral: true }); + if (modLogsRole) { + try { + await modLogsRole.send({ embeds: [modLogEmbed(moderator, command, roleName)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs role: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to add prefix command role ${roleName} for command ${command}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(command, roleName)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [alreadyExistsEmbed(command, roleName)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/addVersion.ts b/src/commands/moderation/prefixCommands/functions/addVersion.ts new file mode 100644 index 00000000..8126ccdc --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/addVersion.ts @@ -0,0 +1,142 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommandVersion, Logger, makeEmbed, loadSinglePrefixCommandVersionToCache, PrefixCommand } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Add Version - No Connection', + description: 'Could not connect to the database. Unable to add the prefix command version.', + color: Colors.Red, +}); + +const failedEmbed = (version: string) => makeEmbed({ + title: 'Prefix Commands - Add Version - Failed', + description: `Failed to add the prefix command version ${version}.`, + color: Colors.Red, +}); + +const wrongFormatEmbed = (invalidString: string) => makeEmbed({ + title: 'Prefix Commands - Add Version - Wrong format', + description: `The name and alias of a version can only contain alphanumerical characters, underscores and dashes. "${invalidString}" is invalid.`, + color: Colors.Red, +}); + +const alreadyExistsEmbed = (version: string, reason: string) => makeEmbed({ + title: 'Prefix Commands - Add Version - Already exists', + description: `The prefix command version ${version} already exists: ${reason}`, + color: Colors.Red, +}); + +const successEmbed = (version: string) => makeEmbed({ + title: `Prefix command version ${version} was added successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, version: string, emoji: string, alias: string, enabled: boolean, versionId: string) => makeEmbed({ + title: 'Prefix command version added', + fields: [ + { + name: 'Version', + value: version, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + { + name: 'Emoji', + value: emoji, + }, + { + name: 'Alias', + value: alias, + }, + { + name: 'Enabled', + value: enabled ? 'Yes' : 'No', + }, + ], + footer: { text: `Version ID: ${versionId}` }, + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Add Version - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleAddPrefixCommandVersion(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const name = interaction.options.getString('name')!; + const emoji = interaction.options.getString('emoji')!; + const alias = interaction.options.getString('alias')!.toLowerCase(); + const enabled = interaction.options.getBoolean('is_enabled') || false; + const moderator = interaction.user; + + const nameRegex = /^[\w-]+$/; + if (!nameRegex.test(name)) { + await interaction.followUp({ embeds: [wrongFormatEmbed(name)], ephemeral: true }); + return; + } + if (!nameRegex.test(alias)) { + // eslint-disable-next-line no-await-in-loop + await interaction.followUp({ embeds: [wrongFormatEmbed(alias)], ephemeral: true }); + return; + } + + // Check if a command or version exists with the same name or alias + const foundCommandName = await PrefixCommand.findOne({ + $or: [ + { name: alias }, + { aliases: alias }, + ], + }); + if (foundCommandName) { + await interaction.followUp({ embeds: [alreadyExistsEmbed(name, `${alias} already exists as a command or alias.`)], ephemeral: true }); + return; + } + const foundVersion = await PrefixCommandVersion.findOne({ + $or: [ + { name }, + { alias }, + ], + }); + if (foundVersion || name.toLowerCase() === 'generic' || alias === 'generic') { + await interaction.followUp({ embeds: [alreadyExistsEmbed(name, `${alias} already exists as a version alias.`)], ephemeral: true }); + return; + } + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const prefixCommandVersion = new PrefixCommandVersion({ + name, + emoji, + enabled, + alias, + }); + try { + await prefixCommandVersion.save(); + await loadSinglePrefixCommandVersionToCache(prefixCommandVersion); + await interaction.followUp({ embeds: [successEmbed(name)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, name, emoji, alias, enabled, prefixCommandVersion.id)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to add a prefix command category ${name}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(name)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/deleteCategory.ts b/src/commands/moderation/prefixCommands/functions/deleteCategory.ts new file mode 100644 index 00000000..25756683 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/deleteCategory.ts @@ -0,0 +1,93 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommandCategory, Logger, makeEmbed, clearSinglePrefixCommandCategoryCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Delete Category - No Connection', + description: 'Could not connect to the database. Unable to delete the prefix command category.', + color: Colors.Red, +}); + +const failedEmbed = (categoryId: string) => makeEmbed({ + title: 'Prefix Commands - Delete Category - Failed', + description: `Failed to delete the prefix command category with id ${categoryId}.`, + color: Colors.Red, +}); + +const doesNotExistsEmbed = (category: string) => makeEmbed({ + title: 'Prefix Commands - Delete Category - Does not exist', + description: `The prefix command category ${category} does not exists. Can not delete it.`, + color: Colors.Red, +}); + +const successEmbed = (category: string, categoryId: string) => makeEmbed({ + title: `Prefix command category ${category} (${categoryId}) was deleted successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, category: string, emoji: string, categoryId: string) => makeEmbed({ + title: 'Prefix command category deleted', + fields: [ + { + name: 'Category', + value: category, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + { + name: 'Emoji', + value: emoji, + }, + ], + footer: { text: `Category ID: ${categoryId}` }, + color: Colors.Red, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Delete Category - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleDeletePrefixCommandCategory(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const category = interaction.options.getString('category')!; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const existingCategory = await PrefixCommandCategory.findOne({ name: category }); + + if (existingCategory) { + const { id: categoryId, name, emoji } = existingCategory; + try { + await clearSinglePrefixCommandCategoryCache(existingCategory); + await existingCategory.deleteOne(); + await interaction.followUp({ embeds: [successEmbed(name || '', categoryId)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, name || '', emoji || '', categoryId)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to delete a prefix command category with id ${categoryId}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(categoryId)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [doesNotExistsEmbed(category)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/deleteChannelDefaultVersion.ts b/src/commands/moderation/prefixCommands/functions/deleteChannelDefaultVersion.ts new file mode 100644 index 00000000..bd0962d4 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/deleteChannelDefaultVersion.ts @@ -0,0 +1,88 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommandChannelDefaultVersion, Logger, makeEmbed, clearSinglePrefixCommandChannelDefaultVersionCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Unset Default Channel Version - No Connection', + description: 'Could not connect to the database. Unable to unset the default channel version.', + color: Colors.Red, +}); + +const failedEmbed = (channel: string) => makeEmbed({ + title: 'Prefix Commands - Unset Default Channel Version - Failed', + description: `Failed to unset the default channel version with for ${channel}.`, + color: Colors.Red, +}); + +const doesNotExistsEmbed = (channel: string) => makeEmbed({ + title: 'Prefix Commands - Unset Default Channel Version - Does not exist', + description: `The default channel version with for ${channel} does not exists. Can not unset it.`, + color: Colors.Red, +}); + +const successEmbed = (channel: string) => makeEmbed({ + title: `Default channel version for channel ${channel} was unset successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, channel: string) => makeEmbed({ + title: 'Prefix Commands - Default Channel Version unset', + fields: [ + { + name: 'Channel', + value: channel, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + ], + color: Colors.Red, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Unset Default Channel Version - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleDeletePrefixCommandChannelDefaultVersion(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const channel = interaction.options.getChannel('channel')!; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const { id: channelId, name: channelName } = channel; + const existingChannelDefaultVersion = await PrefixCommandChannelDefaultVersion.findOne({ channelId }); + + if (existingChannelDefaultVersion) { + try { + await clearSinglePrefixCommandChannelDefaultVersionCache(existingChannelDefaultVersion); + await existingChannelDefaultVersion.deleteOne(); + await interaction.followUp({ embeds: [successEmbed(channelName)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, channelName)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to unset a default channel version for channel ${channelName}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(channelName)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [doesNotExistsEmbed(channelName)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/deleteCommand.ts b/src/commands/moderation/prefixCommands/functions/deleteCommand.ts new file mode 100644 index 00000000..b412a9eb --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/deleteCommand.ts @@ -0,0 +1,105 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommand, Logger, makeEmbed, clearSinglePrefixCommandCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Delete Command - No Connection', + description: 'Could not connect to the database. Unable to delete the prefix command.', + color: Colors.Red, +}); + +const failedEmbed = (commandId: string) => makeEmbed({ + title: 'Prefix Commands - Delete Command - Failed', + description: `Failed to delete the prefix command with id ${commandId}.`, + color: Colors.Red, +}); + +const doesNotExistsEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Delete Command - Does not exist', + description: `The prefix command ${command} does not exists. Can not delete it.`, + color: Colors.Red, +}); + +const successEmbed = (command: string, commandId: string) => makeEmbed({ + title: `Prefix command ${command} (${commandId}) was deleted successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, command: string, aliases: string[], description: string, isEmbed: boolean, embedColor: string, commandId: string) => makeEmbed({ + title: 'Prefix command deleted', + fields: [ + { + name: 'Command', + value: command, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + { + name: 'Aliases', + value: aliases.join(','), + }, + { + name: 'Description', + value: description, + }, + { + name: 'Is Embed', + value: isEmbed ? 'Yes' : 'No', + }, + { + name: 'Embed Color', + value: embedColor || '', + }, + ], + footer: { text: `Command ID: ${commandId}` }, + color: Colors.Red, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Delete Command - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleDeletePrefixCommand(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command')!; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const existingCommand = await PrefixCommand.findOne({ name: command }); + + if (existingCommand) { + const { id: commandId, name, description, aliases, isEmbed, embedColor } = existingCommand; + try { + await clearSinglePrefixCommandCache(existingCommand); + await existingCommand.deleteOne(); + await interaction.followUp({ embeds: [successEmbed(name || '', commandId)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, name || '', aliases, description, isEmbed || false, embedColor || '', commandId)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to delete a prefix command command with id ${commandId}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(commandId)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [doesNotExistsEmbed(command)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/deleteContent.ts b/src/commands/moderation/prefixCommands/functions/deleteContent.ts new file mode 100644 index 00000000..72a07de1 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/deleteContent.ts @@ -0,0 +1,145 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, Logger, makeEmbed, PrefixCommand, PrefixCommandVersion, refreshSinglePrefixCommandCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Delete Content - No Connection', + description: 'Could not connect to the database. Unable to delete the prefix command content.', + color: Colors.Red, +}); + +const noContentEmbed = (command: string, version: string) => makeEmbed({ + title: 'Prefix Commands - Delete Content - No Content', + description: `Failed to delete command content for command ${command} and version ${version} as the content does not exist.`, + color: Colors.Red, +}); + +const noCommandEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Delete Content - No Command', + description: `Failed to delete command content for command ${command} as the command does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const noVersionEmbed = (version: string) => makeEmbed({ + title: 'Prefix Commands - Delete Content - No Version', + description: `Failed to delete command content for version ${version} as the version does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const failedEmbed = (version: string) => makeEmbed({ + title: 'Prefix Commands - Delete Content - Failed', + description: `Failed to delete the prefix command content with version ${version}.`, + color: Colors.Red, +}); + +const successEmbed = (command: string, version: string) => makeEmbed({ + title: `Prefix command content for command ${command} and version ${version} was deleted successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, commandName: string, versionName: string, title: string, content: string, image: string) => makeEmbed({ + title: 'Prefix command content delete', + fields: [ + { + name: 'Command', + value: commandName, + }, + { + name: 'Version', + value: versionName, + }, + { + name: 'Title', + value: title, + }, + { + name: 'Content', + value: content, + }, + { + name: 'Image', + value: image, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + ], + color: Colors.Red, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Delete Content - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleDeletePrefixCommandContent(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command')!; + const version = interaction.options.getString('version')!; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const foundCommand = await PrefixCommand.findOne({ name: command }); + if (!foundCommand) { + await interaction.followUp({ embeds: [noCommandEmbed(command)], ephemeral: true }); + return; + } + let versionId = ''; + let foundVersions = null; + if (version === 'GENERIC' || version === 'generic') { + versionId = 'GENERIC'; + } else { + foundVersions = await PrefixCommandVersion.find({ name: version }); + if (foundVersions && foundVersions.length === 1) { + [{ _id: versionId }] = foundVersions; + } else { + await interaction.followUp({ embeds: [noVersionEmbed(version)], ephemeral: true }); + return; + } + } + const existingContent = foundCommand.contents.find((content) => content.versionId.toString() === versionId.toString()); + + if (foundCommand && existingContent) { + const { title, content, image } = existingContent; + const { name: commandName } = foundCommand; + let versionName = ''; + if (versionId !== 'GENERIC') { + const foundVersion = await PrefixCommandVersion.findById(versionId); + if (!foundVersion) { + return; + } + versionName = foundVersion.name || ''; + } + try { + foundCommand.contents.find((con) => con.versionId.toString() === versionId.toString())?.deleteOne(); + await foundCommand.save(); + await refreshSinglePrefixCommandCache(foundCommand, foundCommand); + await interaction.followUp({ embeds: [successEmbed(`${commandName}`, `${versionName}`)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, `${commandName}`, `${versionName}`, `${title}`, `${content}`, `${image}`)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to delete a prefix command content with version ${version}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(version)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [noContentEmbed(command, version)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/deleteVersion.ts b/src/commands/moderation/prefixCommands/functions/deleteVersion.ts new file mode 100644 index 00000000..1ff68030 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/deleteVersion.ts @@ -0,0 +1,149 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommandVersion, Logger, makeEmbed, PrefixCommandChannelDefaultVersion, clearSinglePrefixCommandVersionCache, PrefixCommand, refreshSinglePrefixCommandCache, clearSinglePrefixCommandChannelDefaultVersionCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Delete Version - No Connection', + description: 'Could not connect to the database. Unable to delete the prefix command version.', + color: Colors.Red, +}); + +const contentPresentEmbed = makeEmbed({ + title: 'Prefix Commands - Delete Version - Content Present', + description: 'There is content present for this command version. Please delete the content first, or use the `force` option to delete the command version and all the command contents for the version.', + color: Colors.Red, +}); + +const channelDefaultVersionPresentEmbed = makeEmbed({ + title: 'Prefix Commands - Delete Version - Default Channel Versions Present', + description: 'There is one or more channel with this version selected as its default version. Please change or unset the default version for those channels first, or use the `force` option to delete the command version and all the default channel versions referencing it (making them default back to the GENERIC version).', + color: Colors.Red, +}); + +const failedEmbed = (versionId: string) => makeEmbed({ + title: 'Prefix Commands - Delete Version - Failed', + description: `Failed to delete the prefix command version with id ${versionId}.`, + color: Colors.Red, +}); + +const doesNotExistsEmbed = (version: string) => makeEmbed({ + title: 'Prefix Commands - Delete Version - Does not exist', + description: `The prefix command version ${version} does not exists. Can not delete it.`, + color: Colors.Red, +}); + +const successEmbed = (version: string, versionId: string) => makeEmbed({ + title: `Prefix command version ${version} (${versionId}) was deleted successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, version: string, emoji: string, alias: string, enabled: boolean, versionId: string) => makeEmbed({ + title: 'Prefix command version deleted', + fields: [ + { + name: 'Version', + value: version, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + { + name: 'Emoji', + value: emoji, + }, + { + name: 'Alias', + value: alias, + }, + { + name: 'Enabled', + value: enabled ? 'Yes' : 'No', + }, + ], + footer: { text: `Version ID: ${versionId}` }, + color: Colors.Red, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Delete Version - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleDeletePrefixCommandVersion(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const version = interaction.options.getString('version')!; + const force = interaction.options.getBoolean('force') || false; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const existingVersion = await PrefixCommandVersion.findOne({ name: version }); + if (!existingVersion) { + await interaction.followUp({ embeds: [doesNotExistsEmbed(version)], ephemeral: true }); + return; + } + const { id: versionId } = existingVersion; + // Find all PrefixCommands with content where version ID == versionId + const foundCommandsWithContent = await PrefixCommand.find({ 'contents.versionId': versionId }); + if (foundCommandsWithContent && foundCommandsWithContent.length > 0 && !force) { + await interaction.followUp({ embeds: [contentPresentEmbed], ephemeral: true }); + return; + } + const foundChannelDefaultVersions = await PrefixCommandChannelDefaultVersion.find({ versionId }); + if (foundChannelDefaultVersions && foundChannelDefaultVersions.length > 0 && !force) { + await interaction.followUp({ embeds: [channelDefaultVersionPresentEmbed], ephemeral: true }); + return; + } + + if (existingVersion) { + const { name, emoji, enabled, alias } = existingVersion; + try { + if (foundCommandsWithContent && force) { + for (const command of foundCommandsWithContent) { + const { _id: commandId } = command; + // eslint-disable-next-line no-await-in-loop + const updatedCommand = await PrefixCommand.findOneAndUpdate({ _id: commandId }, { $pull: { contents: { versionId } } }, { new: true }); + if (updatedCommand) { + // eslint-disable-next-line no-await-in-loop + await refreshSinglePrefixCommandCache(command, updatedCommand); + } + } + } + if (foundChannelDefaultVersions && force) { + for (const channelDefaultVersion of foundChannelDefaultVersions) { + // eslint-disable-next-line no-await-in-loop + await clearSinglePrefixCommandChannelDefaultVersionCache(channelDefaultVersion); + // eslint-disable-next-line no-await-in-loop + await channelDefaultVersion.deleteOne(); + } + } + await clearSinglePrefixCommandVersionCache(existingVersion); + await existingVersion.deleteOne(); + await interaction.followUp({ embeds: [successEmbed(name || '', versionId)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, name || '', emoji || '', alias || '', enabled || false, versionId)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to delete a prefix command version with id ${versionId}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(versionId)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [doesNotExistsEmbed(versionId)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/listCategories.ts b/src/commands/moderation/prefixCommands/functions/listCategories.ts new file mode 100644 index 00000000..071817ce --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/listCategories.ts @@ -0,0 +1,59 @@ +import { APIEmbedField, ChatInputCommandInteraction, Colors } from 'discord.js'; +import { getConn, PrefixCommandCategory, Logger, makeEmbed } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - List Categories - No Connection', + description: 'Could not connect to the database. Unable to list the prefix command categories.', + color: Colors.Red, +}); + +const failedEmbed = (searchText: string) => makeEmbed({ + title: 'Prefix Commands - List Categories - Failed', + description: `Failed to list the prefix command categories with search text: ${searchText}.`, + color: Colors.Red, +}); + +const noResultsEmbed = (searchText: string) => makeEmbed({ + title: 'Prefix Commands - List Categories - Does not exist', + description: `No prefix command categories found matching the search text: ${searchText}.`, +}); + +const successEmbed = (searchText: string, fields: APIEmbedField[]) => makeEmbed({ + title: 'Prefix Commands - Categories', + description: searchText ? `Matching search: ${searchText}` : undefined, + fields, + color: Colors.Green, +}); + +export async function handleListPrefixCommandCategories(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const searchText = interaction.options.getString('search_text') || ''; + const foundCategories = await PrefixCommandCategory.find({ name: { $regex: searchText, $options: 'i' } }); + + if (foundCategories) { + const embedFields: APIEmbedField[] = []; + for (let i = 0; i < foundCategories.length; i++) { + const category = foundCategories[i]; + const { id, name, emoji } = category; + embedFields.push({ + name: `${name} - ${emoji}`, + value: `${id}`, + }); + } + try { + await interaction.followUp({ embeds: [successEmbed(searchText, embedFields)], ephemeral: false }); + } catch (error) { + Logger.error(`Failed to list prefix command categories with search ${searchText}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(searchText)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [noResultsEmbed(searchText)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/listCommands.ts b/src/commands/moderation/prefixCommands/functions/listCommands.ts new file mode 100644 index 00000000..53d8727c --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/listCommands.ts @@ -0,0 +1,59 @@ +import { APIEmbedField, ChatInputCommandInteraction, Colors } from 'discord.js'; +import { getConn, PrefixCommand, Logger, makeEmbed } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - List Commands - No Connection', + description: 'Could not connect to the database. Unable to list the prefix commands.', + color: Colors.Red, +}); + +const failedEmbed = (searchText: string) => makeEmbed({ + title: 'Prefix Commands - List Commands - Failed', + description: `Failed to list the prefix commands with search text: ${searchText}.`, + color: Colors.Red, +}); + +const noResultsEmbed = (searchText: string) => makeEmbed({ + title: 'Prefix Commands - List Commands - Does not exist', + description: `No prefix commands found matching the search text: ${searchText}.`, +}); + +const successEmbed = (searchText: string, fields: APIEmbedField[]) => makeEmbed({ + title: 'Prefix Commands', + description: searchText ? `Matching search: ${searchText}` : undefined, + fields, + color: Colors.Green, +}); + +export async function handleListPrefixCommands(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const searchText = interaction.options.getString('search_text') || ''; + const foundCommands = await PrefixCommand.find({ name: { $regex: searchText, $options: 'i' } }); + + if (foundCommands) { + const embedFields: APIEmbedField[] = []; + for (let i = 0; i < foundCommands.length; i++) { + const command = foundCommands[i]; + const { name, description, aliases, isEmbed, embedColor } = command; + embedFields.push({ + name: `${name} - ${aliases.join(',')} - ${isEmbed ? 'Embed' : 'No Embed'} - ${embedColor || 'No Color'}`, + value: `${description}`, + }); + } + try { + await interaction.followUp({ embeds: [successEmbed(searchText, embedFields)], ephemeral: false }); + } catch (error) { + Logger.error(`Failed to list prefix command commands with search ${searchText}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(searchText)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [noResultsEmbed(searchText)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/listVersions.ts b/src/commands/moderation/prefixCommands/functions/listVersions.ts new file mode 100644 index 00000000..50cdd0e1 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/listVersions.ts @@ -0,0 +1,59 @@ +import { APIEmbedField, ChatInputCommandInteraction, Colors } from 'discord.js'; +import { getConn, PrefixCommandVersion, Logger, makeEmbed } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - List Versions - No Connection', + description: 'Could not connect to the database. Unable to list the prefix command versions.', + color: Colors.Red, +}); + +const failedEmbed = (searchText: string) => makeEmbed({ + title: 'Prefix Commands - List Versions - Failed', + description: `Failed to list the prefix command versions with search text: ${searchText}.`, + color: Colors.Red, +}); + +const noResultsEmbed = (searchText: string) => makeEmbed({ + title: 'Prefix Commands - List Versions - Does not exist', + description: `No prefix command versions found matching the search text: ${searchText}.`, +}); + +const successEmbed = (searchText: string, fields: APIEmbedField[]) => makeEmbed({ + title: 'Prefix Commands - Versions', + description: searchText ? `Matching search: ${searchText}` : undefined, + fields, + color: Colors.Green, +}); + +export async function handleListPrefixCommandVersions(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const searchText = interaction.options.getString('search_text') || ''; + const foundVersions = await PrefixCommandVersion.find({ name: { $regex: searchText, $options: 'i' } }); + + if (foundVersions) { + const embedFields: APIEmbedField[] = []; + for (let i = 0; i < foundVersions.length; i++) { + const version = foundVersions[i]; + const { id, name, emoji, enabled, alias } = version; + embedFields.push({ + name: `${name} - ${emoji} - ${enabled ? 'Enabled' : 'Disabled'} - ${alias}`, + value: `${id}`, + }); + } + try { + await interaction.followUp({ embeds: [successEmbed(searchText, embedFields)], ephemeral: false }); + } catch (error) { + Logger.error(`Failed to list prefix command versions with search ${searchText}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(searchText)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [noResultsEmbed(searchText)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/modifyCategory.ts b/src/commands/moderation/prefixCommands/functions/modifyCategory.ts new file mode 100644 index 00000000..53755406 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/modifyCategory.ts @@ -0,0 +1,99 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommandCategory, Logger, makeEmbed, refreshSinglePrefixCommandCategoryCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Modify Category - No Connection', + description: 'Could not connect to the database. Unable to modify the prefix command category.', + color: Colors.Red, +}); + +const failedEmbed = (categoryId: string) => makeEmbed({ + title: 'Prefix Commands - Modify Category - Failed', + description: `Failed to modify the prefix command category with id ${categoryId}.`, + color: Colors.Red, +}); + +const doesNotExistsEmbed = (category: string) => makeEmbed({ + title: 'Prefix Commands - Modify Category - Does not exist', + description: `The prefix command category ${category} does not exists. Can not modify it.`, + color: Colors.Red, +}); + +const successEmbed = (category: string, categoryId: string) => makeEmbed({ + title: `Prefix command category ${category} (${categoryId}) was modified successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, category: string, emoji: string, categoryId: string) => makeEmbed({ + title: 'Prefix command category modified', + fields: [ + { + name: 'Category', + value: category, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + { + name: 'Emoji', + value: emoji, + }, + ], + footer: { text: `Category ID: ${categoryId}` }, + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Modified Category - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleModifyPrefixCommandCategory(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const category = interaction.options.getString('category')!; + const name = interaction.options.getString('name') || ''; + const emoji = interaction.options.getString('emoji') || ''; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const existingCategory = await PrefixCommandCategory.findOne({ name: category }); + + if (existingCategory) { + const { id: categoryId } = existingCategory; + const oldCategory = existingCategory.$clone(); + existingCategory.name = name || existingCategory.name; + existingCategory.emoji = emoji || existingCategory.emoji; + try { + await existingCategory.save(); + const { name, emoji } = existingCategory; + await refreshSinglePrefixCommandCategoryCache(oldCategory, existingCategory); + await interaction.followUp({ embeds: [successEmbed(name, categoryId)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, name, emoji || '', categoryId)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to modify a prefix command category with id ${categoryId}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(categoryId)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [doesNotExistsEmbed(category)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/modifyCommand.ts b/src/commands/moderation/prefixCommands/functions/modifyCommand.ts new file mode 100644 index 00000000..e05fb258 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/modifyCommand.ts @@ -0,0 +1,204 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommand, Logger, makeEmbed, PrefixCommandCategory, refreshSinglePrefixCommandCache, PrefixCommandVersion } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Modify Command - No Connection', + description: 'Could not connect to the database. Unable to modify the prefix command.', + color: Colors.Red, +}); + +const failedEmbed = (commandId: string) => makeEmbed({ + title: 'Prefix Commands - Modify Command - Failed', + description: `Failed to modify the prefix command with id ${commandId}.`, + color: Colors.Red, +}); + +const wrongFormatEmbed = (invalidString: string) => makeEmbed({ + title: 'Prefix Commands - Modify Command - Wrong format', + description: `The name and aliases of a command can only contain alphanumerical characters, underscores and dashes. "${invalidString}" is invalid.`, + color: Colors.Red, +}); + +const categoryNotFoundEmbed = (category: string) => makeEmbed({ + title: 'Prefix Commands - Modify Command - Category not found', + description: `The prefix command category ${category} does not exist. Please create it first.`, + color: Colors.Red, +}); + +const doesNotExistsEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Modify Command - Does not exist', + description: `The prefix command ${command} does not exists. Can not modify it.`, + color: Colors.Red, +}); + +const alreadyExistsEmbed = (command: string, reason: string) => makeEmbed({ + title: 'Prefix Commands - Modify Command - Already exists', + description: `The prefix command ${command} can not be modified: ${reason}`, + color: Colors.Red, +}); + +const successEmbed = (command: string, commandId: string) => makeEmbed({ + title: `Prefix command ${command} (${commandId}) was modified successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, command: string, aliases: string[], description: string, isEmbed: boolean, embedColor: string, commandId: string) => makeEmbed({ + title: 'Prefix command modified', + fields: [ + { + name: 'Command', + value: command, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + { + name: 'Aliases', + value: aliases.join(','), + }, + { + name: 'Description', + value: description, + }, + { + name: 'Is Embed', + value: isEmbed ? 'Yes' : 'No', + }, + { + name: 'Embed Color', + value: embedColor || '', + }, + ], + footer: { text: `Command ID: ${commandId}` }, + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Modified Command - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleModifyPrefixCommand(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command')!; + const name = interaction.options.getString('name')?.toLowerCase().trim() || ''; + const category = interaction.options.getString('category') || ''; + const description = interaction.options.getString('description') || ''; + const aliasesString = interaction.options.getString('aliases')?.toLowerCase().trim() || ''; + const aliases = aliasesString !== '' ? aliasesString.split(',') : []; + const isEmbed = interaction.options.getBoolean('is_embed'); + const embedColor = interaction.options.getString('embed_color') || ''; + const moderator = interaction.user; + + const nameRegex = /^[\w-]+$/; + if (name && !nameRegex.test(name)) { + await interaction.followUp({ embeds: [wrongFormatEmbed(name)], ephemeral: true }); + return; + } + for (const alias of aliases) { + if (!nameRegex.test(alias)) { + // eslint-disable-next-line no-await-in-loop + await interaction.followUp({ embeds: [wrongFormatEmbed(alias)], ephemeral: true }); + return; + } + } + // Check if command name and alias are unique, additionally check if they do not exist as a version alias. + if (name) { + const foundCommandName = await PrefixCommand.findOne({ + name: { $ne: command }, + $or: [ + { name }, + { aliases: name }, + ], + }); + if (foundCommandName) { + await interaction.followUp({ embeds: [alreadyExistsEmbed(command, `${name} already exists as a different command or alias.`)], ephemeral: true }); + return; + } + const foundVersion = await PrefixCommandVersion.findOne({ + $or: [ + { alias: name }, + ], + }); + if (foundVersion || name === 'generic') { + await interaction.followUp({ embeds: [alreadyExistsEmbed(command, `${name} already exists as a version alias.`)], ephemeral: true }); + return; + } + } + if (aliases.length > 0) { + const foundCommandName = await PrefixCommand.findOne({ + name: { $ne: command }, + $or: [ + { name: { $in: aliases } }, + { aliases: { $in: aliases } }, + ], + }); + if (foundCommandName) { + await interaction.followUp({ embeds: [alreadyExistsEmbed(command, 'The new aliases contain an alias that already exists as a different command or alias.')], ephemeral: true }); + return; + } + const foundVersion = await PrefixCommandVersion.findOne({ + $or: [ + { alias: { $in: aliases } }, + ], + }); + if (foundVersion || aliases.includes('generic')) { + await interaction.followUp({ embeds: [alreadyExistsEmbed(command, 'The new aliases contain an alias that already exists as a version alias.')], ephemeral: true }); + return; + } + } + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + let foundCategory; + if (category !== '') { + [foundCategory] = await PrefixCommandCategory.find({ name: category }); + if (!foundCategory) { + await interaction.followUp({ embeds: [categoryNotFoundEmbed(category)], ephemeral: true }); + return; + } + } + const existingCommand = await PrefixCommand.findOne({ name: command }); + + if (existingCommand) { + const { id: commandId } = existingCommand; + const oldCommand = existingCommand.$clone(); + existingCommand.name = name || existingCommand.name; + existingCommand.categoryId = foundCategory?.id || existingCommand.categoryId; + existingCommand.description = description || existingCommand.description; + existingCommand.aliases = aliases.length > 0 ? aliases : existingCommand.aliases; + existingCommand.isEmbed = isEmbed !== null ? isEmbed : existingCommand.isEmbed; + existingCommand.embedColor = embedColor || existingCommand.embedColor; + try { + await existingCommand.save(); + const { name, description, aliases, isEmbed, embedColor } = existingCommand; + await refreshSinglePrefixCommandCache(oldCommand, existingCommand); + await interaction.followUp({ embeds: [successEmbed(name, commandId)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, name, aliases, description, isEmbed || false, embedColor || '', existingCommand.id)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to modify a prefix command command with id ${commandId}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(commandId)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [doesNotExistsEmbed(command)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/modifyVersion.ts b/src/commands/moderation/prefixCommands/functions/modifyVersion.ts new file mode 100644 index 00000000..93b6992f --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/modifyVersion.ts @@ -0,0 +1,166 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommandVersion, Logger, makeEmbed, refreshSinglePrefixCommandVersionCache, PrefixCommand } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Modify Version - No Connection', + description: 'Could not connect to the database. Unable to modify the prefix command version.', + color: Colors.Red, +}); + +const failedEmbed = (versionId: string) => makeEmbed({ + title: 'Prefix Commands - Modify Version - Failed', + description: `Failed to modify the prefix command version with id ${versionId}.`, + color: Colors.Red, +}); + +const wrongFormatEmbed = (invalidString: string) => makeEmbed({ + title: 'Prefix Commands - Modify Version - Wrong format', + description: `The name and alias of a version can only contain alphanumerical characters, underscores and dashes. "${invalidString}" is invalid.`, + color: Colors.Red, +}); + +const doesNotExistsEmbed = (version: string) => makeEmbed({ + title: 'Prefix Commands - Modify Version - Does not exist', + description: `The prefix command version ${version} does not exists. Can not modify it.`, + color: Colors.Red, +}); + +const alreadyExistsEmbed = (version: string, reason: string) => makeEmbed({ + title: 'Prefix Commands - Add Version - Already exists', + description: `The prefix command version ${version} already exists: ${reason}`, + color: Colors.Red, +}); + +const successEmbed = (version: string, versionId: string) => makeEmbed({ + title: `Prefix command version ${version} (${versionId}) was modified successfully.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, version: string, emoji: string, alias: string, enabled: boolean, versionId: string) => makeEmbed({ + title: 'Prefix command version modified', + fields: [ + { + name: 'Version', + value: version, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + { + name: 'Emoji', + value: emoji, + }, + { + name: 'Alias', + value: alias, + }, + { + name: 'Enabled', + value: enabled ? 'Yes' : 'No', + }, + ], + footer: { text: `Version ID: ${versionId}` }, + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Modified Version - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleModifyPrefixCommandVersion(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const version = interaction.options.getString('version')!; + const name = interaction.options.getString('name') || ''; + const emoji = interaction.options.getString('emoji') || ''; + const alias = interaction.options.getString('alias')?.toLowerCase() || ''; + const enabled = interaction.options.getBoolean('is_enabled'); + const moderator = interaction.user; + + const nameRegex = /^[\w-]+$/; + if (name && !nameRegex.test(name)) { + await interaction.followUp({ embeds: [wrongFormatEmbed(name)], ephemeral: true }); + return; + } + if (alias && !nameRegex.test(alias)) { + // eslint-disable-next-line no-await-in-loop + await interaction.followUp({ embeds: [wrongFormatEmbed(alias)], ephemeral: true }); + return; + } + if (name) { + const foundVersion = await PrefixCommandVersion.findOne({ + name: { + $ne: version, + $eq: name, + }, + }); + if (foundVersion || name.toLowerCase() === 'generic') { + await interaction.followUp({ embeds: [alreadyExistsEmbed(version, `${name} already exists as a version.`)], ephemeral: true }); + return; + } + } + if (alias) { + const foundVersion = await PrefixCommandVersion.findOne({ + name: { $ne: version }, + alias, + }); + if (foundVersion || alias === 'generic') { + await interaction.followUp({ embeds: [alreadyExistsEmbed(version, `${alias} already exists as a version alias.`)], ephemeral: true }); + return; + } + const foundCommandName = await PrefixCommand.findOne({ + $or: [ + { name: alias }, + { aliases: alias }, + ], + }); + if (foundCommandName) { + await interaction.followUp({ embeds: [alreadyExistsEmbed(version, `${alias} already exists as a command or command alias.`)], ephemeral: true }); + return; + } + } + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + const existingVersion = await PrefixCommandVersion.findOne({ name: version }); + + if (existingVersion) { + const { id: versionId } = existingVersion; + const oldVersion = existingVersion.$clone(); + existingVersion.name = name || existingVersion.name; + existingVersion.emoji = emoji || existingVersion.emoji; + existingVersion.alias = alias || existingVersion.alias; + existingVersion.enabled = enabled !== null ? enabled : existingVersion.enabled; + try { + await existingVersion.save(); + const { name, emoji, alias, enabled } = existingVersion; + await refreshSinglePrefixCommandVersionCache(oldVersion, existingVersion); + await interaction.followUp({ embeds: [successEmbed(name, versionId)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, name, emoji, alias, enabled || false, versionId)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to modify a prefix command version with id ${versionId}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(versionId)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [doesNotExistsEmbed(version)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/removeChannelPermission.ts b/src/commands/moderation/prefixCommands/functions/removeChannelPermission.ts new file mode 100644 index 00000000..0d2f5571 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/removeChannelPermission.ts @@ -0,0 +1,109 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommand, Logger, makeEmbed, refreshSinglePrefixCommandCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Remove Channel - No Connection', + description: 'Could not connect to the database. Unable to remove the prefix command channel.', + color: Colors.Red, +}); + +const noCommandEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Remove Channel - No Command', + description: `Failed to remove the prefix command channel for command ${command} as the command does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const failedEmbed = (command: string, channel: string) => makeEmbed({ + title: 'Prefix Commands - Remove Channel- Failed', + description: `Failed to remove the prefix command channel <#${channel}> for command ${command}.`, + color: Colors.Red, +}); + +const doesNotExistEmbed = (command: string, channel: string) => makeEmbed({ + title: 'Prefix Commands - Remove Channel - Does not exist', + description: `A prefix command channel <#${channel}> for command ${command} does not exist.`, + color: Colors.Red, +}); + +const successEmbed = (command: string, channel: string) => makeEmbed({ + title: `Prefix command channel <#${channel}> removed for command ${command}.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, command: string, channel: string) => makeEmbed({ + title: 'Remove prefix command channel permission', + fields: [ + { + name: 'Command', + value: command, + }, + { + name: 'Channel', + value: `<#${channel}>`, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + ], + color: Colors.Red, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Remove Channel - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleRemovePrefixCommandChannelPermission(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command')!; + const channel = interaction.options.getChannel('channel')!; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + let foundCommands = await PrefixCommand.find({ name: command }); + if (!foundCommands || foundCommands.length > 1) { + foundCommands = await PrefixCommand.find({ aliases: { $in: [command] } }); + } + if (!foundCommands || foundCommands.length > 1) { + await interaction.followUp({ embeds: [noCommandEmbed(command)], ephemeral: true }); + return; + } + const [foundCommand] = foundCommands; + const { id: channelId } = channel; + + const existingChannelPermission = foundCommand.permissions.channels?.includes(channelId); + if (existingChannelPermission) { + foundCommand.permissions.channels = foundCommand.permissions.channels?.filter((id) => id !== channelId); + try { + await foundCommand.save(); + await refreshSinglePrefixCommandCache(foundCommand, foundCommand); + await interaction.followUp({ embeds: [successEmbed(command, channelId)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, command, channelId)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to remove prefix command channel <#${channel}> for command ${command}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(command, channelId)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [doesNotExistEmbed(command, channelId)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/removeRolePermission.ts b/src/commands/moderation/prefixCommands/functions/removeRolePermission.ts new file mode 100644 index 00000000..d72f465d --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/removeRolePermission.ts @@ -0,0 +1,109 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommand, Logger, makeEmbed, refreshSinglePrefixCommandCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Remove Role - No Connection', + description: 'Could not connect to the database. Unable to remove the prefix command role.', + color: Colors.Red, +}); + +const noCommandEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Remove Role - No Command', + description: `Failed to remove the prefix command role for command ${command} as the command does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const failedEmbed = (command: string, roleName: string) => makeEmbed({ + title: 'Prefix Commands - Remove Role - Failed', + description: `Failed to remove the prefix command role ${roleName} for command ${command}.`, + color: Colors.Red, +}); + +const doesNotExistEmbed = (command: string, roleName: string) => makeEmbed({ + title: 'Prefix Commands - Remove Role - Already exists', + description: `A prefix command role ${roleName} for command ${command} and role does not exist.`, + color: Colors.Red, +}); + +const successEmbed = (command: string, roleName: string) => makeEmbed({ + title: `Prefix command role ${roleName} removed for command ${command}.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, command: string, roleName: string) => makeEmbed({ + title: 'Remove prefix command role permission', + fields: [ + { + name: 'Command', + value: command, + }, + { + name: 'Role', + value: roleName, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + ], + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Remove Role - No Mod Log', + description: 'I can\'t find the mod logs role. Please check the role still exists.', + color: Colors.Red, +}); + +export async function handleRemovePrefixCommandRolePermission(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command')!; + const role = interaction.options.getRole('role')!; + const moderator = interaction.user; + + //Check if the mod logs role exists + const modLogsRole = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsRole) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + let foundCommands = await PrefixCommand.find({ name: command }); + if (!foundCommands || foundCommands.length > 1) { + foundCommands = await PrefixCommand.find({ aliases: { $in: [command] } }); + } + if (!foundCommands || foundCommands.length > 1) { + await interaction.followUp({ embeds: [noCommandEmbed(command)], ephemeral: true }); + return; + } + const [foundCommand] = foundCommands; + const { id: roleId, name: roleName } = role; + + const existingRolePermission = foundCommand.permissions.roles?.includes(roleId); + if (existingRolePermission) { + foundCommand.permissions.roles = foundCommand.permissions.roles?.filter((id) => id !== roleId); + try { + await foundCommand.save(); + await refreshSinglePrefixCommandCache(foundCommand, foundCommand); + await interaction.followUp({ embeds: [successEmbed(command, roleName)], ephemeral: true }); + if (modLogsRole) { + try { + await modLogsRole.send({ embeds: [modLogEmbed(moderator, command, roleName)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs role: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to remove prefix command role ${roleName} for command ${command}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(command, roleName)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [doesNotExistEmbed(command, roleName)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/setChannelDefaultVersion.ts b/src/commands/moderation/prefixCommands/functions/setChannelDefaultVersion.ts new file mode 100644 index 00000000..662c933c --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/setChannelDefaultVersion.ts @@ -0,0 +1,108 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommandVersion, PrefixCommandChannelDefaultVersion, Logger, makeEmbed, loadSinglePrefixCommandChannelDefaultVersionToCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Set Default Channel Version - No Connection', + description: 'Could not connect to the database. Unable to set the channel default version.', + color: Colors.Red, +}); + +const noVersionEmbed = (channel: string) => makeEmbed({ + title: 'Prefix Commands - Show Default Channel Version - No Version', + description: `Failed to show default channel version for channel ${channel} as the configured version does not exist.`, + color: Colors.Red, +}); + +const failedEmbed = (channel: string) => makeEmbed({ + title: 'Prefix Commands - Set Default Channel Version - Failed', + description: `Failed to set the channel default version for channel ${channel}.`, + color: Colors.Red, +}); + +const successEmbed = (channel: string, version: string, emoji: string) => makeEmbed({ + title: `Prefix Command Channel Default version set for channel ${channel} to version ${version} ${emoji}.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, channel: string, version: string, emoji: string) => makeEmbed({ + title: 'Prefix channel default version set', + fields: [ + { + name: 'Channel', + value: channel, + }, + { + name: 'Version', + value: `${version} - ${emoji}`, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + ], + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Set Default Channel Version - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleSetPrefixCommandChannelDefaultVersion(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const channel = interaction.options.getChannel('channel')!; + const version = interaction.options.getString('version')!; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + let foundVersion; + if (version !== 'GENERIC') { + foundVersion = await PrefixCommandVersion.findOne({ name: version }); + } + + if (foundVersion || version === 'GENERIC') { + const { id: channelId, name: channelName } = channel; + let versionId = ''; + let emoji = ''; + if (version === 'GENERIC') { + versionId = 'GENERIC'; + emoji = ''; + } else if (foundVersion) { + versionId = foundVersion.id; + emoji = foundVersion.emoji; + } + const foundChannelDefaultVersion = await PrefixCommandChannelDefaultVersion.findOne({ channelId }); + const channelDefaultVersion = foundChannelDefaultVersion || new PrefixCommandChannelDefaultVersion({ channelId, versionId }); + channelDefaultVersion.versionId = versionId; + try { + await channelDefaultVersion.save(); + await loadSinglePrefixCommandChannelDefaultVersionToCache(channelDefaultVersion); + await interaction.followUp({ embeds: [successEmbed(channelName, version, emoji)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, channelName, version, emoji)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to set the default channel version for channel ${channelName} to version ${version}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(channelName)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [noVersionEmbed(version)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/setCommandPermissionSettings.ts b/src/commands/moderation/prefixCommands/functions/setCommandPermissionSettings.ts new file mode 100644 index 00000000..888d2310 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/setCommandPermissionSettings.ts @@ -0,0 +1,122 @@ +import { ChatInputCommandInteraction, Colors, TextChannel, User } from 'discord.js'; +import { constantsConfig, getConn, Logger, makeEmbed, PrefixCommand, PrefixCommandPermissions, refreshSinglePrefixCommandCache } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Set Permission Settings - No Connection', + description: 'Could not connect to the database. Unable to set the permission settings.', + color: Colors.Red, +}); + +const noCommandEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Set Permission Settings - No Command', + description: `Failed to set default channel version for command ${command} as the command does not exist.`, + color: Colors.Red, +}); + +const failedEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Set Permission Settings - Failed', + description: `Failed to set the permission settings for command ${command}.`, + color: Colors.Red, +}); + +const successEmbed = (command: string) => makeEmbed({ + title: `Prefix Command permission settings set for command ${command}.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, command: string, rolesBlocklist: boolean, channelsBlocklist: boolean, quietErrors: boolean, verboseErrors: boolean) => makeEmbed({ + title: 'Prefix command permission set', + fields: [ + { + name: 'Command', + value: command, + }, + { + name: 'Roles Blocklist', + value: rolesBlocklist ? 'Enabled' : 'Disabled', + }, + { + name: 'Channels Blocklist', + value: channelsBlocklist ? 'Enabled' : 'Disabled', + }, + { + name: 'Quiet Errors', + value: quietErrors ? 'Enabled' : 'Disabled', + }, + { + name: 'Verbose Errors', + value: verboseErrors ? 'Enabled' : 'Disabled', + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + ], + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Set Permission Settings - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleSetPrefixCommandPermissionSettings(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command')!; + const rolesBlocklist = interaction.options.getBoolean('roles-blocklist') || false; + const channelsBlocklist = interaction.options.getBoolean('channels-blocklist') || false; + const quietErrors = interaction.options.getBoolean('quiet-errors') || false; + const verboseErrors = interaction.options.getBoolean('verbose-errors') || false; + const moderator = interaction.user; + + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + let foundCommands = await PrefixCommand.find({ name: command }); + if (!foundCommands || foundCommands.length > 1) { + foundCommands = await PrefixCommand.find({ aliases: { $in: [command] } }); + } + if (!foundCommands || foundCommands.length > 1) { + await interaction.followUp({ embeds: [noCommandEmbed(command)], ephemeral: true }); + return; + } + + const [foundCommand] = foundCommands; + if (foundCommand) { + if (!foundCommand.permissions) { + foundCommand.permissions = new PrefixCommandPermissions(); + } + foundCommand.permissions.rolesBlocklist = rolesBlocklist; + foundCommand.permissions.channelsBlocklist = channelsBlocklist; + foundCommand.permissions.quietErrors = quietErrors; + foundCommand.permissions.verboseErrors = verboseErrors; + try { + await foundCommand.save(); + await refreshSinglePrefixCommandCache(foundCommand, foundCommand); + await interaction.followUp({ embeds: [successEmbed(command)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, command, rolesBlocklist, channelsBlocklist, quietErrors, verboseErrors)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to set the permission settings for command ${command}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(command)], ephemeral: true }); + } + } else { + await interaction.followUp({ embeds: [noCommandEmbed(command)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/setContent.ts b/src/commands/moderation/prefixCommands/functions/setContent.ts new file mode 100644 index 00000000..20089239 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/setContent.ts @@ -0,0 +1,232 @@ +import { ActionRowBuilder, ChatInputCommandInteraction, Colors, ModalBuilder, TextChannel, TextInputBuilder, TextInputStyle, User } from 'discord.js'; +import { constantsConfig, getConn, PrefixCommandVersion, PrefixCommand, Logger, makeEmbed, refreshSinglePrefixCommandCache, PrefixCommandContent } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Set Content - No Connection', + description: 'Could not connect to the database. Unable to set the prefix command content.', + color: Colors.Red, +}); + +const noCommandEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Set Content - No Command', + description: `Failed to set command content for command ${command} as the command does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const noVersionEmbed = (version: string) => makeEmbed({ + title: 'Prefix Commands - Set Content - No Version', + description: `Failed to set command content for version ${version} as the version does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const failedEmbed = (command: string, version: string) => makeEmbed({ + title: 'Prefix Commands - Set Content - Failed', + description: `Failed to set command content for command ${command} and version ${version}.`, + color: Colors.Red, +}); + +const successEmbed = (command: string, version: string) => makeEmbed({ + title: `Prefix command content set for command ${command} and version ${version}.`, + color: Colors.Green, +}); + +const modLogEmbed = (moderator: User, command: string, version: string, title: string, content: string, image: string, commandId: string, versionId: string) => makeEmbed({ + title: 'Prefix command content set', + fields: [ + { + name: 'Command', + value: command, + }, + { + name: 'Version', + value: version, + }, + { + name: 'Title', + value: title, + }, + { + name: 'Content', + value: content, + }, + { + name: 'Image', + value: image, + }, + { + name: 'Moderator', + value: `${moderator}`, + }, + ], + footer: { text: `Command ID: ${commandId} - Version ID: ${versionId}` }, + color: Colors.Green, +}); + +const noModLogs = makeEmbed({ + title: 'Prefix Commands - Set Content - No Mod Log', + description: 'I can\'t find the mod logs channel. Please check the channel still exists.', + color: Colors.Red, +}); + +export async function handleSetPrefixCommandContent(interaction: ChatInputCommandInteraction<'cached'>) { + const conn = getConn(); + if (!conn) { + await interaction.reply({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command')!; + const version = interaction.options.getString('version')!; + const moderator = interaction.user; + + let foundCommands = await PrefixCommand.find({ name: command }); + if (!foundCommands || foundCommands.length !== 1) { + foundCommands = await PrefixCommand.find({ aliases: { $in: [command] } }); + } + if (!foundCommands || foundCommands.length !== 1) { + await interaction.reply({ embeds: [noCommandEmbed(command)], ephemeral: true }); + return; + } + + const foundCommand = foundCommands[0]; + const { _id: commandId } = foundCommand; + let versionId = ''; + let foundVersions = null; + if (version === 'GENERIC' || version === 'generic') { + versionId = 'GENERIC'; + } else { + foundVersions = await PrefixCommandVersion.find({ name: version }); + if (foundVersions && foundVersions.length === 1) { + [{ _id: versionId }] = foundVersions; + } else { + await interaction.reply({ embeds: [noVersionEmbed(version)], ephemeral: true }); + return; + } + } + + const foundContent = foundCommand.contents.find((c) => c.versionId.toString() === versionId.toString()); + const contentModal = new ModalBuilder({ + customId: 'commandContentModal', + title: `Content for ${command} - ${version}`, + }); + + const commandContentTitle = new TextInputBuilder() + .setCustomId('commandContentTitle') + .setLabel('Title') + .setPlaceholder('Provide a title for the command.') + .setStyle(TextInputStyle.Short) + .setMaxLength(255) + .setMinLength(0) + .setRequired(false) + .setValue(foundContent ? foundContent.title : ''); + + const commandContentContent = new TextInputBuilder() + .setCustomId('commandContentContent') + .setLabel('Content') + .setPlaceholder('Provide the content for the command.') + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(2048) + .setMinLength(0) + .setRequired(false) + .setValue(foundContent && foundContent.content ? foundContent.content : ''); + + const commandContentImageUrl = new TextInputBuilder() + .setCustomId('commandContentImageUrl') + .setLabel('Image URL') + .setPlaceholder('Provide an optional Image URL for the command.') + .setStyle(TextInputStyle.Short) + .setMaxLength(255) + .setMinLength(0) + .setRequired(false) + .setValue(foundContent && foundContent.image ? foundContent.image : ''); + + const titleActionRow = new ActionRowBuilder().addComponents(commandContentTitle); + const contentActionRow = new ActionRowBuilder().addComponents(commandContentContent); + const imageUrlActionRow = new ActionRowBuilder().addComponents(commandContentImageUrl); + + contentModal.addComponents(titleActionRow); + contentModal.addComponents(contentActionRow); + contentModal.addComponents(imageUrlActionRow); + + await interaction.showModal(contentModal); + + const filter = (interaction: { + customId: string; + user: { id: any; }; + }) => interaction.customId === 'commandContentModal' && interaction.user.id; + + let title = ''; + let content = ''; + let image = ''; + + try { + //Await a modal response + const modalSubmitInteraction = await interaction.awaitModalSubmit({ + filter, + time: 120000, + }); + + title = modalSubmitInteraction.fields.getTextInputValue('commandContentTitle').trim(); + content = modalSubmitInteraction.fields.getTextInputValue('commandContentContent').trim(); + image = modalSubmitInteraction.fields.getTextInputValue('commandContentImageUrl').trim(); + + if (!title && !content && !image) { + await modalSubmitInteraction.reply({ + content: 'You did not provide any content information and the change was not made.', + ephemeral: true, + }); + return; + } + await modalSubmitInteraction.reply({ + content: 'Processing command content data.', + ephemeral: true, + }); + } catch (error) { + //Handle the error if the user does not respond in time + Logger.error(error); + await interaction.followUp({ + content: 'You did not provide the necessary content information and the change was not made.', + ephemeral: true, + }); + return; + } + //Check if the mod logs channel exists + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + if (!modLogsChannel) { + await interaction.followUp({ embeds: [noModLogs], ephemeral: true }); + } + + if (foundContent) { + const foundData = foundCommand.contents.find((c) => c.versionId === foundContent.versionId); + try { + await foundData?.deleteOne(); + } catch (error) { + Logger.error(`Failed to delete existing content for prefix command ${command} and version ${version}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(command, version)], ephemeral: true }); + return; + } + } + const contentData = new PrefixCommandContent({ + versionId, + title, + content, + image, + }); + foundCommand.contents.push(contentData); + + try { + await foundCommand.save(); + await refreshSinglePrefixCommandCache(foundCommand, foundCommand); + await interaction.followUp({ embeds: [successEmbed(command, version)], ephemeral: true }); + if (modLogsChannel) { + try { + await modLogsChannel.send({ embeds: [modLogEmbed(moderator, command, version, title, content, image, commandId, versionId)] }); + } catch (error) { + Logger.error(`Failed to post a message to the mod logs channel: ${error}`); + } + } + } catch (error) { + Logger.error(`Failed to set prefix command content for command ${command} and version ${version}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(command, version)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/showChannelDefaultVersion.ts b/src/commands/moderation/prefixCommands/functions/showChannelDefaultVersion.ts new file mode 100644 index 00000000..03846f87 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/showChannelDefaultVersion.ts @@ -0,0 +1,76 @@ +import { ChatInputCommandInteraction, Colors } from 'discord.js'; +import { getConn, PrefixCommandChannelDefaultVersion, PrefixCommandVersion, Logger, makeEmbed } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Show Default Channel Version - No Connection', + description: 'Could not connect to the database. Unable to show the channel default version.', + color: Colors.Red, +}); + +const noVersionEmbed = (channel: string) => makeEmbed({ + title: 'Prefix Commands - Show Default Channel Version - No Version', + description: `Failed to show default channel version for channel ${channel} as the configured version does not exist.`, + color: Colors.Red, +}); + +const noChannelDefaultVersionEmbed = (channel: string) => makeEmbed({ + title: 'Prefix Commands - Show Default Channel Version - No Default Channel Version', + description: `Failed to show the channel default version for channel ${channel} as there is no default version set.`, + color: Colors.Red, +}); + +const failedEmbed = (channel: string) => makeEmbed({ + title: 'Prefix Commands - Show Default Channel Version - Failed', + description: `Failed to show the channel default version for channel ${channel}.`, + color: Colors.Red, +}); + +const contentEmbed = (channel: string, version: string, emoji: string, versionId: string) => makeEmbed({ + title: `Prefix Commands - Show Default Channel Version - ${channel} - ${version}`, + fields: [ + { + name: 'Channel', + value: channel, + }, + { + name: 'Version', + value: `${version} - ${emoji}`, + }, + ], + footer: { text: `Version ID: ${versionId}` }, + color: Colors.Green, +}); + +export async function handleShowPrefixCommandChannelDefaultVersion(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const channel = interaction.options.getChannel('channel')!; + const { id: channelId, name: channelName } = channel; + const foundChannelDefaultVersions = await PrefixCommandChannelDefaultVersion.find({ channelId }); + if (!foundChannelDefaultVersions || foundChannelDefaultVersions.length === 0 || foundChannelDefaultVersions.length > 1) { + await interaction.followUp({ embeds: [noChannelDefaultVersionEmbed(channelName)], ephemeral: true }); + return; + } + + const [foundChannelDefaultVersion] = foundChannelDefaultVersions; + const { versionId } = foundChannelDefaultVersion; + const foundVersion = await PrefixCommandVersion.findById(versionId); + if (!foundVersion) { + await interaction.followUp({ embeds: [noVersionEmbed(channelName)], ephemeral: true }); + return; + } + + const { name: version, emoji } = foundVersion; + try { + await interaction.followUp({ embeds: [contentEmbed(channelName, `${version}`, `${emoji}`, `${versionId}`)], ephemeral: false }); + } catch (error) { + Logger.error(`Failed to show the channel default version for channel ${channel} and version ${version}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(channelName)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/showCommandPermissions.ts b/src/commands/moderation/prefixCommands/functions/showCommandPermissions.ts new file mode 100644 index 00000000..b64bb990 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/showCommandPermissions.ts @@ -0,0 +1,104 @@ +import { ChatInputCommandInteraction, Colors } from 'discord.js'; +import { getConn, PrefixCommand, Logger, makeEmbed } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Show Command Permissions - No Connection', + description: 'Could not connect to the database. Unable to show the prefix command permissions.', + color: Colors.Red, +}); + +const noCommandEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Show Command Permissions - No Command', + description: `Failed to show command permissions for command ${command} as the command does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const failedEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Show Command Permissions - Failed', + description: `Failed to show command permissions for command ${command}.`, + color: Colors.Red, +}); + +const permissionEmbed = (command: string, roles: string[], rolesBlocklist: boolean, channels: string[], channelsBlocklist: boolean, quietErrors: boolean, verboseErrors: boolean) => makeEmbed({ + title: `Prefix Commands - Show Command Permissions - ${command}`, + fields: [ + { + name: 'Roles', + value: roles.length > 0 ? roles.join(', ') : 'None', + }, + { + name: 'Roles Blocklist', + value: rolesBlocklist ? 'Enabled' : 'Disabled', + }, + { + name: 'Channels', + value: channels.length > 0 ? channels.join(', ') : 'None', + }, + { + name: 'Channels Blocklist', + value: channelsBlocklist ? 'Enabled' : 'Disabled', + }, + { + name: 'Quiet Errors', + value: quietErrors ? 'Enabled' : 'Disabled', + }, + { + name: 'Verbose Errors', + value: verboseErrors ? 'Enabled' : 'Disabled', + }, + ], + color: Colors.Green, +}); + +export async function handleShowPrefixCommandPermissions(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command') || ''; + let foundCommands = await PrefixCommand.find({ name: command }); + if (!foundCommands || foundCommands.length > 1) { + foundCommands = await PrefixCommand.find({ aliases: { $in: [command] } }); + } + if (!foundCommands || foundCommands.length > 1) { + await interaction.followUp({ embeds: [noCommandEmbed(command)], ephemeral: true }); + return; + } + + const [foundCommand] = foundCommands; + const { permissions } = foundCommand; + const { roles, rolesBlocklist, channels, channelsBlocklist, quietErrors, verboseErrors } = permissions; + const roleNames = []; + const channelNames = []; + if (roles) { + for (const role of roles) { + // eslint-disable-next-line no-await-in-loop + const discordRole = await interaction.guild.roles.fetch(role); + if (discordRole) { + const { name } = discordRole; + roleNames.push(name); + } + } + } + if (channels) { + for (const channel of channels) { + // eslint-disable-next-line no-await-in-loop + const discordChannel = await interaction.guild.channels.fetch(channel); + if (discordChannel) { + const { id: channelId } = discordChannel; + channelNames.push(`<#${channelId}>`); + } + } + } + + try { + await interaction.followUp({ embeds: [permissionEmbed(command, roleNames, rolesBlocklist || false, channelNames, channelsBlocklist || false, quietErrors || false, verboseErrors || false)], ephemeral: false }); + } catch (error) { + Logger.error(`Failed to show prefix command content for command ${command}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(command)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/functions/showContent.ts b/src/commands/moderation/prefixCommands/functions/showContent.ts new file mode 100644 index 00000000..286d7997 --- /dev/null +++ b/src/commands/moderation/prefixCommands/functions/showContent.ts @@ -0,0 +1,102 @@ +import { ChatInputCommandInteraction, Colors } from 'discord.js'; +import { getConn, PrefixCommandVersion, PrefixCommand, Logger, makeEmbed } from '../../../../lib'; + +const noConnEmbed = makeEmbed({ + title: 'Prefix Commands - Show Content - No Connection', + description: 'Could not connect to the database. Unable to show the prefix command content.', + color: Colors.Red, +}); + +const noCommandEmbed = (command: string) => makeEmbed({ + title: 'Prefix Commands - Show Content - No Command', + description: `Failed to show command content for command ${command} as the command does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const noVersionEmbed = (version: string) => makeEmbed({ + title: 'Prefix Commands - Show Content - No Version', + description: `Failed to show command content for version ${version} as the version does not exist or there are more than one matching.`, + color: Colors.Red, +}); + +const noContentEmbed = (command: string, version: string) => makeEmbed({ + title: 'Prefix Commands - Show Content - No Content', + description: `Failed to show command content for command ${command} and version ${version} as the content does not exist.`, + color: Colors.Red, +}); + +const failedEmbed = (command: string, version: string) => makeEmbed({ + title: 'Prefix Commands - Show Content - Failed', + description: `Failed to show command content for command ${command} and version ${version}.`, + color: Colors.Red, +}); + +const contentEmbed = (command: string, version: string, title: string, content: string, image: string, commandId: string, versionId: string, contentId: string) => makeEmbed({ + title: `Prefix Commands - Show Content - ${command} - ${version}`, + fields: [ + { + name: 'Title', + value: title, + }, + { + name: 'Content', + value: content, + }, + { + name: 'Image', + value: image, + }, + ], + footer: { text: `Command ID: ${commandId} - Version ID: ${versionId} - Content ID: ${contentId}` }, + color: Colors.Green, +}); + +export async function handleShowPrefixCommandContent(interaction: ChatInputCommandInteraction<'cached'>) { + await interaction.deferReply({ ephemeral: true }); + + const conn = getConn(); + if (!conn) { + await interaction.followUp({ embeds: [noConnEmbed], ephemeral: true }); + return; + } + + const command = interaction.options.getString('command') || ''; + const version = interaction.options.getString('version') || ''; + let foundCommands = await PrefixCommand.find({ name: command }); + if (!foundCommands || foundCommands.length > 1) { + foundCommands = await PrefixCommand.find({ aliases: { $in: [command] } }); + } + if (!foundCommands || foundCommands.length > 1) { + await interaction.followUp({ embeds: [noCommandEmbed(command)], ephemeral: true }); + return; + } + + const [foundCommand] = foundCommands; + const { id: commandId } = foundCommand; + let versionId = ''; + if (version === 'GENERIC' || version === 'generic') { + versionId = 'GENERIC'; + } else { + const foundVersions = await PrefixCommandVersion.find({ name: version }); + if (foundVersions && foundVersions.length === 1) { + const [foundVersion] = foundVersions; + ({ id: versionId } = foundVersion); + } else { + await interaction.followUp({ embeds: [noVersionEmbed(version)], ephemeral: true }); + return; + } + } + + const foundContent = foundCommand.contents.find((content) => content.versionId === versionId); + if (!foundContent) { + await interaction.followUp({ embeds: [noContentEmbed(command, version)], ephemeral: true }); + return; + } + const { id: contentId, title, content, image } = foundContent; + try { + await interaction.followUp({ embeds: [contentEmbed(command, version, title || '', content || '', image || '', `${commandId}`, `${versionId}`, `${contentId}`)], ephemeral: false }); + } catch (error) { + Logger.error(`Failed to show prefix command content for command ${command} and version ${version}: ${error}`); + await interaction.followUp({ embeds: [failedEmbed(command, version)], ephemeral: true }); + } +} diff --git a/src/commands/moderation/prefixCommands/prefixCommandCacheUpdate.ts b/src/commands/moderation/prefixCommands/prefixCommandCacheUpdate.ts new file mode 100644 index 00000000..a80d5ad4 --- /dev/null +++ b/src/commands/moderation/prefixCommands/prefixCommandCacheUpdate.ts @@ -0,0 +1,74 @@ +import { ApplicationCommandType, Colors, EmbedField, TextChannel, User } from 'discord.js'; +import { constantsConfig, slashCommand, slashCommandStructure, makeEmbed, refreshAllPrefixCommandsCache, refreshAllPrefixCommandVersionsCache, refreshAllPrefixCommandCategoriesCache, refreshAllPrefixCommandChannelDefaultVersionsCache } from '../../../lib'; + +const data = slashCommandStructure({ + name: 'prefix-commands-cache-update', + description: 'Updates the in-memory prefix command cache of the bot.', + type: ApplicationCommandType.ChatInput, + default_member_permissions: constantsConfig.commandPermission.MANAGE_SERVER, //Overrides need to be added for admin, moderator and bot developer roles + dm_permission: false, + options: [], +}); + +const cacheUpdateEmbed = (fields: any, color: number) => makeEmbed({ + title: 'Prefix Command Cache Update', + fields, + color, +}); + +const noChannelEmbed = (channelName: string) => makeEmbed({ + title: `Prefix Command Cache Update - No ${channelName} channel`, + description: `The command was successful, but no message to ${channelName} was sent. Please check the channel still exists.`, + color: Colors.Yellow, +}); + +const cacheUpdateEmbedField = (moderator: User, duration: string): EmbedField[] => [ + { + name: 'Moderator', + value: `${moderator}`, + inline: true, + }, + { + name: 'Duration', + value: `${duration}s`, + inline: true, + }, +]; + +export default slashCommand(data, async ({ interaction }) => { + await interaction.deferReply({ ephemeral: true }); + + const modLogsChannel = interaction.guild.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; + const start = new Date().getTime(); + + await refreshAllPrefixCommandVersionsCache(); + await refreshAllPrefixCommandCategoriesCache(); + await refreshAllPrefixCommandsCache(); + await refreshAllPrefixCommandChannelDefaultVersionsCache(); + + const duration = ((new Date().getTime() - start) / 1000).toFixed(2); + + await interaction.editReply({ + embeds: [cacheUpdateEmbed( + cacheUpdateEmbedField( + interaction.user, + duration, + ), + Colors.Green, + )], + }); + + try { + await modLogsChannel.send({ + embeds: [cacheUpdateEmbed( + cacheUpdateEmbedField( + interaction.user, + duration, + ), + Colors.Green, + )], + }); + } catch (error) { + await interaction.followUp({ embeds: [noChannelEmbed('mod-log')] }); + } +}); diff --git a/src/commands/moderation/prefixCommands/prefixCommandPermissions.ts b/src/commands/moderation/prefixCommands/prefixCommandPermissions.ts new file mode 100644 index 00000000..b5f33950 --- /dev/null +++ b/src/commands/moderation/prefixCommands/prefixCommandPermissions.ts @@ -0,0 +1,249 @@ +import { ApplicationCommandOptionChoiceData, ApplicationCommandOptionType, ApplicationCommandType } from 'discord.js'; +import { AutocompleteCallback, constantsConfig, getConn, PrefixCommand, slashCommand, slashCommandStructure } from '../../../lib'; +import { handleAddPrefixCommandChannelPermission } from './functions/addChannelPermission'; +import { handleAddPrefixCommandRolePermission } from './functions/addRolePermission'; +import { handleRemovePrefixCommandChannelPermission } from './functions/removeChannelPermission'; +import { handleRemovePrefixCommandRolePermission } from './functions/removeRolePermission'; +import { handleSetPrefixCommandPermissionSettings } from './functions/setCommandPermissionSettings'; +import { handleShowPrefixCommandPermissions } from './functions/showCommandPermissions'; + +const colorChoices = []; +for (let i = 0; i < Object.keys(constantsConfig.colors).length; i++) { + const name = Object.keys(constantsConfig.colors)[i]; + const value = constantsConfig.colors[name]; + colorChoices.push({ name, value }); +} + +const data = slashCommandStructure({ + name: 'prefix-command-permissions', + description: 'Command to manage the permissions of prefix based commands.', + type: ApplicationCommandType.ChatInput, + default_member_permissions: constantsConfig.commandPermission.MANAGE_SERVER, //Overrides need to be added for admin and moderator roles + dm_permission: false, + options: [ + { + name: 'show', + description: 'Show the permissions of a prefix command.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + ], + }, + { + name: 'settings', + description: 'Manage prefix command permission settings.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'roles-blocklist', + description: 'Enable or disable the role blocklist.', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + { + name: 'channels-blocklist', + description: 'Enable or disable the channel blocklist.', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + { + name: 'quiet-errors', + description: 'Enable or disable quiet errors.', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + { + name: 'verbose-errors', + description: 'Enable or disable verbose errors.', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + ], + }, + { + name: 'channels', + description: 'Manage prefix command channel permissions.', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [ + { + name: 'add', + description: 'Add a channel permission for a prefix command.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'channel', + description: 'Provide the channel to add or remove from the selected list.', + type: ApplicationCommandOptionType.Channel, + required: true, + }, + ], + }, + { + name: 'remove', + description: 'Remove a channel permission for a prefix command.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'channel', + description: 'Provide the channel to add or remove from the selected list.', + type: ApplicationCommandOptionType.Channel, + required: true, + }, + ], + }, + ], + }, + { + name: 'roles', + description: 'Manage prefix command role permissions.', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [ + { + name: 'add', + description: 'Add a role permission for a prefix command.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'role', + description: 'Provide the role to add or remove from the selected list.', + type: ApplicationCommandOptionType.Role, + required: true, + }, + ], + }, + { + name: 'remove', + description: 'Remove a role permission for a prefix command.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'role', + description: 'Provide the role to add or remove from the selected list.', + type: ApplicationCommandOptionType.Role, + required: true, + }, + ], + }, + ], + }, + ], +}); + +const autocompleteCallback: AutocompleteCallback = async ({ interaction }) => { + const autoCompleteOption = interaction.options.getFocused(true); + const { name: optionName, value: searchText } = autoCompleteOption; + let choices: ApplicationCommandOptionChoiceData[] = []; + + const conn = getConn(); + + switch (optionName) { + case 'command': + if (!conn) { + return interaction.respond(choices); + } + const foundCommands = await PrefixCommand.find({ name: { $regex: searchText, $options: 'i' } }) + .sort({ name: 1 }) + .limit(25); + for (let i = 0; i < foundCommands.length; i++) { + const command = foundCommands[i]; + const { name } = command; + choices.push({ name, value: name }); + } + break; + default: + choices = []; + } + + return interaction.respond(choices); +}; + +export default slashCommand(data, async ({ interaction }) => { + const subcommandGroup = interaction.options.getSubcommandGroup(); + const subcommandName = interaction.options.getSubcommand(); + + switch (subcommandName) { + case 'show': + await handleShowPrefixCommandPermissions(interaction); + return; + case 'settings': + await handleSetPrefixCommandPermissionSettings(interaction); + return; + default: + } + + switch (subcommandGroup) { + case 'channels': + switch (subcommandName) { + case 'add': + await handleAddPrefixCommandChannelPermission(interaction); + break; + case 'remove': + await handleRemovePrefixCommandChannelPermission(interaction); + break; + default: + await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + } + break; + case 'roles': + switch (subcommandName) { + case 'add': + await handleAddPrefixCommandRolePermission(interaction); + break; + case 'remove': + await handleRemovePrefixCommandRolePermission(interaction); + break; + default: + await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + } + break; + default: + await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + } +}, autocompleteCallback); diff --git a/src/commands/moderation/prefixCommands/prefixCommands.ts b/src/commands/moderation/prefixCommands/prefixCommands.ts new file mode 100644 index 00000000..6662056d --- /dev/null +++ b/src/commands/moderation/prefixCommands/prefixCommands.ts @@ -0,0 +1,665 @@ +import { ApplicationCommandOptionChoiceData, ApplicationCommandOptionType, ApplicationCommandType } from 'discord.js'; +import { AutocompleteCallback, constantsConfig, getConn, PrefixCommand, PrefixCommandCategory, PrefixCommandVersion, slashCommand, slashCommandStructure } from '../../../lib'; +import { handleAddPrefixCommandCategory } from './functions/addCategory'; +import { handleModifyPrefixCommandCategory } from './functions/modifyCategory'; +import { handleDeletePrefixCommandCategory } from './functions/deleteCategory'; +import { handleListPrefixCommandCategories } from './functions/listCategories'; +import { handleAddPrefixCommandVersion } from './functions/addVersion'; +import { handleModifyPrefixCommandVersion } from './functions/modifyVersion'; +import { handleDeletePrefixCommandVersion } from './functions/deleteVersion'; +import { handleListPrefixCommandVersions } from './functions/listVersions'; +import { handleAddPrefixCommand } from './functions/addCommand'; +import { handleModifyPrefixCommand } from './functions/modifyCommand'; +import { handleDeletePrefixCommand } from './functions/deleteCommand'; +import { handleListPrefixCommands } from './functions/listCommands'; +import { handleShowPrefixCommandContent } from './functions/showContent'; +import { handleSetPrefixCommandContent } from './functions/setContent'; +import { handleDeletePrefixCommandContent } from './functions/deleteContent'; +import { handleShowPrefixCommandChannelDefaultVersion } from './functions/showChannelDefaultVersion'; +import { handleSetPrefixCommandChannelDefaultVersion } from './functions/setChannelDefaultVersion'; +import { handleDeletePrefixCommandChannelDefaultVersion } from './functions/deleteChannelDefaultVersion'; + +const colorChoices = []; +for (let i = 0; i < Object.keys(constantsConfig.colors).length; i++) { + const name = Object.keys(constantsConfig.colors)[i]; + const value = constantsConfig.colors[name]; + colorChoices.push({ name, value }); +} + +const data = slashCommandStructure({ + name: 'prefix-commands', + description: 'Command to manage prefix based commands.', + type: ApplicationCommandType.ChatInput, + default_member_permissions: constantsConfig.commandPermission.MANAGE_SERVER, //Overrides need to be added for admin and moderator roles + dm_permission: false, + options: [ + { + name: 'categories', + description: 'Manage prefix command categories.', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [ + { + name: 'add', + description: 'Add a prefix command category.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'name', + description: 'Provide a name for the prefix command category.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'emoji', + description: 'Provide an emoji to identify the prefix command category.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 128, + }, + ], + }, + { + name: 'modify', + description: 'Modify a prefix command category.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'category', + description: 'Provide the category name of the prefix command category.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'name', + description: 'Provide a name for the prefix command category.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 32, + }, + { + name: 'emoji', + description: 'Provide an emoji to identify the prefix command category.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 128, + }, + ], + }, + { + name: 'delete', + description: 'Delete a prefix command category.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'category', + description: 'Provide the category name of the prefix command category.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + ], + }, + { + name: 'list', + description: 'Get list of prefix command categories.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'search_text', + description: 'Provide an optional search term.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 32, + }, + ], + }, + ], + }, + { + name: 'versions', + description: 'Manage prefix command versions.', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [ + { + name: 'add', + description: 'Add a prefix command version.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'name', + description: 'Provide a name for the prefix command version.', + type: ApplicationCommandOptionType.String, + required: true, + max_length: 32, + }, + { + name: 'emoji', + description: 'Provide an emoji to identify the prefix command version.', + type: ApplicationCommandOptionType.String, + required: true, + max_length: 128, + }, + { + name: 'alias', + description: 'Provide an alias for the prefix command version.', + type: ApplicationCommandOptionType.String, + required: true, + max_length: 32, + }, + { + name: 'is_enabled', + description: 'Indicate wether this version is enabled.', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + ], + }, + { + name: 'modify', + description: 'Modify a prefix command version.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'version', + description: 'Provide the name of the prefix command version.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'name', + description: 'Provide a name for the prefix command version.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 32, + }, + { + name: 'emoji', + description: 'Provide an emoji to identify the prefix command version.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 128, + }, + { + name: 'alias', + description: 'Provide an alias for the prefix command version.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 32, + }, + { + name: 'is_enabled', + description: 'Indicate wether this version is enabled.', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + ], + }, + { + name: 'delete', + description: 'Delete a prefix command version.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'version', + description: 'Provide the name of the prefix command version.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'force', + description: 'Force delete the version even if it is used for command content.', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + ], + }, + { + name: 'list', + description: 'Get list of prefix command versions.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'search_text', + description: 'Provide an optional search term.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 32, + }, + ], + }, + ], + }, + { + name: 'commands', + description: 'Manage prefix commands.', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [ + { + name: 'add', + description: 'Add a prefix command.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'name', + description: 'Provide a name for the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + max_length: 32, + }, + { + name: 'category', + description: 'Provide the category for the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'description', + description: 'Provide a description for the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + max_length: 255, + }, + { + name: 'aliases', + description: 'Provide a comma separated list of aliases for the prefix command.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 255, + }, + { + name: 'is_embed', + description: 'Indicate wether this prefix command should print as an embed or regular message.', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + { + name: 'embed_color', + description: 'If this command results in an embed, specify the color.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 16, + choices: colorChoices, + }, + ], + }, + { + name: 'modify', + description: 'Modify a prefix command.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the command name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 24, + }, + { + name: 'name', + description: 'Provide a name for the prefix command.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 32, + }, + { + name: 'category', + description: 'Provide the category for the prefix command.', + type: ApplicationCommandOptionType.String, + required: false, + autocomplete: true, + max_length: 32, + }, + { + name: 'description', + description: 'Provide a description for the prefix command.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 255, + }, + { + name: 'aliases', + description: 'Provide a comma separated list of aliases for the prefix command.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 255, + }, + { + name: 'is_embed', + description: 'Indicate wether this prefix command should print as an embed or regular message.', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + { + name: 'embed_color', + description: 'If this command results in an embed, specify the color.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 16, + choices: colorChoices, + }, + ], + }, + { + name: 'delete', + description: 'Delete a prefix command.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the command name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 24, + }, + ], + }, + { + name: 'list', + description: 'Get list of prefix commands.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'search_text', + description: 'Provide an optional search term.', + type: ApplicationCommandOptionType.String, + required: false, + max_length: 32, + }, + ], + }, + ], + }, + { + name: 'content', + description: 'Manage prefix command content.', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [ + { + name: 'show', + description: 'Show the details of the content of a command.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'version', + description: 'Provide the name of the prefix command version. Use GENERIC for the generic content.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + ], + }, + { + name: 'set', + description: 'Set a prefix command\'s content for a specific version.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'version', + description: 'Provide the name of the prefix command version. Use GENERIC for the generic content.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + ], + }, + { + name: 'delete', + description: 'Delete a prefix command\'s content for a specific version.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'command', + description: 'Provide the name of the prefix command.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + { + name: 'version', + description: 'Provide the name of the prefix command version. Use GENERIC for the generic content.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + ], + }, + ], + }, + { + name: 'channel-default-version', + description: 'Manage prefix command default versions for channels.', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [ + { + name: 'show', + description: 'Show the default version for a channel.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'channel', + description: 'Provide the channel to show the default version for.', + type: ApplicationCommandOptionType.Channel, + required: true, + }, + ], + }, + { + name: 'set', + description: 'Set the default version for a channel.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'channel', + description: 'Provide the channel to set the default version for.', + type: ApplicationCommandOptionType.Channel, + required: true, + }, + { + name: 'version', + description: 'Provide the version to set as default.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true, + max_length: 32, + }, + ], + }, + { + name: 'delete', + description: 'Delete the default version for a channel.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'channel', + description: 'Provide the channel to unset the default version for.', + type: ApplicationCommandOptionType.Channel, + required: true, + }, + ], + }, + ], + }, + ], +}); + +const autocompleteCallback: AutocompleteCallback = async ({ interaction }) => { + const autoCompleteOption = interaction.options.getFocused(true); + const { name: optionName, value: searchText } = autoCompleteOption; + const choices: ApplicationCommandOptionChoiceData[] = []; + + const conn = getConn(); + + switch (optionName) { + case 'category': + if (!conn) { + return interaction.respond(choices); + } + const foundCategories = await PrefixCommandCategory.find({ name: { $regex: searchText, $options: 'i' } }) + .sort({ name: 1 }) + .limit(25); + for (let i = 0; i < foundCategories.length; i++) { + const category = foundCategories[i]; + const { name } = category; + choices.push({ name, value: name }); + } + break; + case 'command': + if (!conn) { + return interaction.respond(choices); + } + const foundCommands = await PrefixCommand.find({ name: { $regex: searchText, $options: 'i' } }) + .sort({ name: 1 }) + .limit(25); + for (let i = 0; i < foundCommands.length; i++) { + const command = foundCommands[i]; + const { name } = command; + choices.push({ name, value: name }); + } + break; + case 'version': + choices.push({ name: 'GENERIC', value: 'GENERIC' }); + if (!conn) { + return interaction.respond(choices); + } + const foundVersions = await PrefixCommandVersion.find({ name: { $regex: searchText, $options: 'i' } }) + .sort({ name: 1 }) + .limit(25); + for (let i = 0; i < foundVersions.length; i++) { + const version = foundVersions[i]; + const { name } = version; + choices.push({ name, value: name }); + } + break; + default: + break; + } + + return interaction.respond(choices); +}; + +export default slashCommand(data, async ({ interaction }) => { + const subcommandGroup = interaction.options.getSubcommandGroup(); + const subcommandName = interaction.options.getSubcommand(); + + switch (subcommandGroup) { + case 'categories': + switch (subcommandName) { + case 'add': + await handleAddPrefixCommandCategory(interaction); + break; + case 'modify': + await handleModifyPrefixCommandCategory(interaction); + break; + case 'delete': + await handleDeletePrefixCommandCategory(interaction); + break; + case 'list': + await handleListPrefixCommandCategories(interaction); + break; + default: + await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + } + break; + case 'versions': + switch (subcommandName) { + case 'add': + await handleAddPrefixCommandVersion(interaction); + break; + case 'modify': + await handleModifyPrefixCommandVersion(interaction); + break; + case 'delete': + await handleDeletePrefixCommandVersion(interaction); + break; + case 'list': + await handleListPrefixCommandVersions(interaction); + break; + default: + await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + } + break; + case 'commands': + switch (subcommandName) { + case 'add': + await handleAddPrefixCommand(interaction); + break; + case 'modify': + await handleModifyPrefixCommand(interaction); + break; + case 'delete': + await handleDeletePrefixCommand(interaction); + break; + case 'list': + await handleListPrefixCommands(interaction); + break; + default: + await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + } + break; + case 'content': + switch (subcommandName) { + case 'show': + await handleShowPrefixCommandContent(interaction); + break; + case 'set': + await handleSetPrefixCommandContent(interaction); + break; + case 'delete': + await handleDeletePrefixCommandContent(interaction); + break; + default: + await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + } + break; + case 'channel-default-version': + switch (subcommandName) { + case 'show': + await handleShowPrefixCommandChannelDefaultVersion(interaction); + break; + case 'set': + await handleSetPrefixCommandChannelDefaultVersion(interaction); + break; + case 'delete': + await handleDeletePrefixCommandChannelDefaultVersion(interaction); + break; + default: + await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + } + break; + default: + await interaction.reply({ content: 'Unknown subcommand', ephemeral: true }); + } +}, autocompleteCallback); diff --git a/src/commands/utils/prefixHelp.ts b/src/commands/utils/prefixHelp.ts new file mode 100644 index 00000000..da313819 --- /dev/null +++ b/src/commands/utils/prefixHelp.ts @@ -0,0 +1,160 @@ +import { ApplicationCommandOptionChoiceData, ApplicationCommandOptionType, ApplicationCommandType } from 'discord.js'; +import { makeEmbed, createPaginatedEmbedHandler, slashCommand, slashCommandStructure, getInMemoryCache, memoryCachePrefixCommand, AutocompleteCallback, memoryCachePrefixCategory, makeLines, memoryCachePrefixVersion, Logger, PrefixCommand, PrefixCommandVersion, PrefixCommandCategory, IPrefixCommand } from '../../lib'; + +const data = slashCommandStructure({ + name: 'prefix-help', + description: 'Display a list of all the prefix commands matching an optional search.', + type: ApplicationCommandType.ChatInput, + options: [{ + name: 'category', + description: 'The category to show the prefix commands for.', + type: ApplicationCommandOptionType.String, + max_length: 32, + autocomplete: true, + required: true, + }, + { + name: 'search', + description: 'The search term to filter the prefix commands by.', + type: ApplicationCommandOptionType.String, + max_length: 32, + autocomplete: true, + required: false, + }], +}); + +const autocompleteCallback: AutocompleteCallback = async ({ interaction }) => { + const autoCompleteOption = interaction.options.getFocused(true); + const { name: optionName, value: searchText } = autoCompleteOption; + const choices: ApplicationCommandOptionChoiceData[] = []; + + const inMemoryCache = getInMemoryCache(); + + switch (optionName) { + case 'category': + if (inMemoryCache) { + const foundCategories = await inMemoryCache.store.keys(); + for (const key of foundCategories) { + if (key.startsWith(memoryCachePrefixCategory) && key.includes(searchText.toLowerCase())) { + // eslint-disable-next-line no-await-in-loop + const categoryCached = await inMemoryCache.get(key); + if (categoryCached) { + const category = PrefixCommandCategory.hydrate(categoryCached); + const { name } = category; + choices.push({ name, value: name }); + if (choices.length >= 25) { + break; + } + } + } + } + } + break; + case 'search': + if (inMemoryCache) { + const foundCommands = await inMemoryCache.store.keys(); + for (const key of foundCommands) { + if (key.startsWith(memoryCachePrefixCommand) && key.includes(searchText.toLowerCase())) { + // Explicitly does not use the cache to hydrate the command to also capture aliases, resulting in commands + const commandName = key.split(':')[1]; + choices.push({ name: commandName, value: commandName }); + if (choices.length >= 25) { + break; + } + } + } + } + break; + default: + break; + } + + return interaction.respond(choices); +}; + +export default slashCommand(data, async ({ interaction }) => { + await interaction.deferReply({ ephemeral: true }); + + const categoryName = interaction.options.getString('category')!; + const search = interaction.options.getString('search') || ''; + + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) { + return interaction.reply({ + content: 'An error occurred while fetching commands.', + ephemeral: true, + }); + } + + const categoryCached = await inMemoryCache.get(`${memoryCachePrefixCategory}:${categoryName.toLowerCase()}`); + if (!categoryCached) { + return interaction.reply({ + content: 'Invalid category, please select an existing category.', + ephemeral: true, + }); + } + const category = PrefixCommandCategory.hydrate(categoryCached); + + const commands: { [key: string]: IPrefixCommand } = {}; + const keys = await inMemoryCache.store.keys(); + for (const key of keys) { + if (key.startsWith(memoryCachePrefixCommand) && key.includes(search.toLowerCase())) { + // eslint-disable-next-line no-await-in-loop + const commandCached = await inMemoryCache.get(key); + if (commandCached) { + const command = PrefixCommand.hydrate(commandCached); + const { name, categoryId: commandCategoryId } = command; + const { _id: categoryId } = category; + if (commandCategoryId.toString() === categoryId.toString() && !(name in commands)) { + commands[name] = command; + } + } + } + } + + const sortedCommands = Object.values(commands).sort((a, b) => a.name.localeCompare(b.name)); + + const pageLimit = 10; + const embeds = []; + for (let page = 0; page * pageLimit < sortedCommands.length; page++) { + const startIndex = page * pageLimit; + const endIndex = startIndex + pageLimit; + const currentCommands = sortedCommands.slice(startIndex, endIndex); + const totalPages = Math.ceil(sortedCommands.length / pageLimit); + + const descriptionLines: string[] = []; + for (const command of currentCommands) { + const { name, aliases, description, contents } = command; + const versionEmojis = []; + for (const content of contents) { + const { versionId } = content; + if (versionId !== 'GENERIC') { + Logger.debug(`Fetching version ${versionId} for command ${name}`); + // eslint-disable-next-line no-await-in-loop + const versionCached = await inMemoryCache.get(`${memoryCachePrefixVersion}:${versionId}`); + if (versionCached) { + const version = PrefixCommandVersion.hydrate(versionCached); + const { emoji } = version; + Logger.debug(`Found version ${versionId} for command ${name} with emoji ${emoji}`); + versionEmojis.push(emoji); + } + } + } + const sortedVersionEmojis = versionEmojis.sort((a, b) => a.localeCompare(b)); + descriptionLines.push(`**${name}** ${sortedVersionEmojis.join(', ')}`); + descriptionLines.push(description); + if (aliases.length > 0) descriptionLines.push(`Aliases: ${aliases.join(', ')}`); + descriptionLines.push(''); + } + + const { name: categoryName, emoji: categoryEmoji } = category; + const embed = makeEmbed({ + title: `${categoryEmoji || ''}${categoryName} Commands (${page + 1}/${totalPages})`, + description: makeLines(descriptionLines), + }); + + embeds.push(embed); + } + + return createPaginatedEmbedHandler(interaction, embeds); +}, autocompleteCallback); diff --git a/src/events/index.ts b/src/events/index.ts index 8de11afd..684f5870 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -7,6 +7,7 @@ import slashCommandHandler from './slashCommandHandler'; import contextInteractionHandler from './contextInteractionHandler'; import messageDelete from './logging/messageDelete'; import messageUpdate from './logging/messageUpdate'; +import messageCreateHandler from './messageCreateHandler'; import autocompleteHandler from './autocompleteHandler'; import buttonHandler from './buttonHandlers/buttonHandler'; @@ -18,6 +19,7 @@ export default [ contextInteractionHandler, messageDelete, messageUpdate, + messageCreateHandler, autocompleteHandler, buttonHandler, ] as Event[]; diff --git a/src/events/messageCreateHandler.ts b/src/events/messageCreateHandler.ts new file mode 100644 index 00000000..a5885922 --- /dev/null +++ b/src/events/messageCreateHandler.ts @@ -0,0 +1,298 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, EmbedBuilder, Interaction, Message } from 'discord.js'; +import { event, getInMemoryCache, memoryCachePrefixCommand, memoryCachePrefixVersion, memoryCachePrefixChannelDefaultVersion, Logger, Events, constantsConfig, makeEmbed, makeLines, PrefixCommand, PrefixCommandPermissions, PrefixCommandVersion } from '../lib'; + +const commandEmbed = (title: string, description: string, color: string, imageUrl: string = '') => makeEmbed({ + title, + description, + color: Number(color), + ...(imageUrl && { image: { url: imageUrl } }), +}); + +async function replyWithEmbed(msg: Message, embed: EmbedBuilder, buttonRow?: ActionRowBuilder) : Promise> { + return msg.fetchReference() + .then((res) => { + embed = EmbedBuilder.from(embed.data); + embed.setFooter({ text: `Executed by ${msg.author.tag} - ${msg.author.id}` }); + return res.reply({ + embeds: [embed], + components: buttonRow ? [buttonRow] : [], + }); + }) + .catch(() => msg.reply({ + embeds: [embed], + components: buttonRow ? [buttonRow] : [], + })); +} + +async function replyWithMsg(msg: Message, text: string, buttonRow?:ActionRowBuilder) : Promise> { + return msg.fetchReference() + .then((res) => res.reply({ + content: `${text}\n\n\`Executed by ${msg.author.tag} - ${msg.author.id}\``, + components: buttonRow ? [buttonRow] : [], + })) + .catch(() => msg.reply({ + content: text, + components: buttonRow ? [buttonRow] : [], + })); +} + +async function sendReply(message: Message, commandTitle: string, commandContent: string, isEmbed: boolean, embedColor: string, commandImage: string, versionButtonRow?: ActionRowBuilder) : Promise> { + try { + let actualCommandContent = commandContent; + if (!commandTitle && !commandContent && !commandImage) { + actualCommandContent = 'No content available.'; + } + if (isEmbed) { + return replyWithEmbed(message, commandEmbed(commandTitle, actualCommandContent, embedColor, commandImage), versionButtonRow); + } + const content: string[] = []; + if (commandTitle) { + content.push(`**${commandTitle}**`); + } + content.push(actualCommandContent); + return replyWithMsg(message, makeLines(content), versionButtonRow); + } catch (error) { + Logger.error(error); + return message.reply('An error occurred while processing the command.'); + } +} + +async function expireChoiceReply(message: Message, commandTitle: string, commandContent: string, isEmbed: boolean, embedColor: string, commandImage: string) : Promise> { + try { + let actualCommandContent = commandContent; + if (!commandTitle && !commandContent && !commandImage) { + actualCommandContent = 'No content available.'; + } + if (isEmbed) { + const commandEmbedData = commandEmbed(commandTitle, actualCommandContent, embedColor, commandImage); + const { footer } = message.embeds[0]; + const newFooter = footer?.text ? `${footer.text} - The choice has expired.` : 'The choice has expired.'; + commandEmbedData.setFooter({ text: newFooter }); + return message.edit({ embeds: [commandEmbedData], components: [] }); + } + + const content: string[] = []; + if (commandTitle) { + content.push(`**${commandTitle}**`); + } + content.push(actualCommandContent); + content.push('\n`The choice has expired.`'); + return message.edit({ + content: makeLines(content), + components: [], + }); + } catch (error) { + Logger.error(error); + return message.reply('An error occurred while updating the message.'); + } +} + +async function sendPermError(message: Message, errorText: string) { + if (constantsConfig.prefixCommandPermissionDelay > 0) { + errorText += `\n\nThis message & the original command message will be deleted in ${constantsConfig.prefixCommandPermissionDelay / 1000} seconds.`; + } + const permReply = await sendReply(message, 'Permission Error', errorText, true, constantsConfig.colors.FBW_RED, ''); + if (constantsConfig.prefixCommandPermissionDelay > 0) { + setTimeout(() => { + try { + permReply.delete(); + message.delete(); + } catch (error) { + Logger.error(`Error while deleting permission error message for command: ${error}`); + } + }, constantsConfig.prefixCommandPermissionDelay); + } +} + +export default event(Events.MessageCreate, async (_, message) => { + const { id: messageId, author, channel, content } = message; + const { id: authorId, bot } = author; + + if (bot || channel.isDMBased()) return; + const { id: channelId, guild } = channel; + const { id: guildId } = guild; + Logger.debug(`Processing message ${messageId} from user ${authorId} in channel ${channelId} of server ${guildId}.`); + + const inMemoryCache = getInMemoryCache(); + if (inMemoryCache && content.startsWith(constantsConfig.prefixCommandPrefix)) { + const commandTextMatch = content.match(`^\\${constantsConfig.prefixCommandPrefix}([\\w\\d-_]+)[^\\w\\d-_]*([\\w\\d-_]+)?`); + if (commandTextMatch) { + let [commandText] = commandTextMatch.slice(1); + const commandVersionExplicitGeneric = (commandText.toLowerCase() === 'generic'); + + // Step 1: Check if the command is actually a version alias + const commandCachedVersion = await inMemoryCache.get(`${memoryCachePrefixVersion}:${commandText.toLowerCase()}`); + let commandVersionId; + let commandVersionName; + let commandVersionEnabled; + if (commandCachedVersion) { + const commandVersion = PrefixCommandVersion.hydrate(commandCachedVersion); + ({ id: commandVersionId, name: commandVersionName, enabled: commandVersionEnabled } = commandVersion); + } else { + commandVersionId = 'GENERIC'; + commandVersionName = 'GENERIC'; + commandVersionEnabled = true; + } + + // Step 2: Check if there's a default version for the channel if commandVersionName is GENERIC + let channelDefaultVersionUsed = false; + if (commandVersionName === 'GENERIC' && !commandVersionExplicitGeneric) { + const channelDefaultVersionCached = await inMemoryCache.get(`${memoryCachePrefixChannelDefaultVersion}:${channelId}`); + if (channelDefaultVersionCached) { + const channelDefaultVersion = PrefixCommandVersion.hydrate(channelDefaultVersionCached); + ({ id: commandVersionId, name: commandVersionName, enabled: commandVersionEnabled } = channelDefaultVersion); + channelDefaultVersionUsed = true; + } + } + + // Drop execution if the version is disabled and we aren't using the default version for a channel + if (!commandVersionEnabled && !channelDefaultVersionUsed) { + if ((commandCachedVersion || commandVersionExplicitGeneric) && commandTextMatch[2]) { + [commandText] = commandTextMatch.slice(2); + } + Logger.debug(`Prefix Command - Version "${commandVersionName}" is disabled - Not executing command "${commandText}"`); + return; + } + // If the version is disabled and we are using the default version for a channel, switch to the generic version + if (!commandVersionEnabled && channelDefaultVersionUsed) { + commandVersionId = 'GENERIC'; + commandVersionName = 'GENERIC'; + commandVersionEnabled = true; + } + + // Step 2.5: If the first command was actually a version alias, take the actual command as CommandText + if ((commandCachedVersion || commandVersionExplicitGeneric) && commandTextMatch[2]) { + [commandText] = commandTextMatch.slice(2); + } + + // Step 3: Check if the command exists itself and process it + const cachedCommandDetails = await inMemoryCache.get(`${memoryCachePrefixCommand}:${commandText.toLowerCase()}`); + if (cachedCommandDetails) { + const commandDetails = PrefixCommand.hydrate(cachedCommandDetails); + const { name, contents, isEmbed, embedColor, permissions } = commandDetails; + const { roles: permRoles, rolesBlocklist, channels: permChannels, channelsBlocklist, quietErrors, verboseErrors } = permissions ?? new PrefixCommandPermissions(); + const authorMember = await guild.members.fetch(authorId); + + // Check permissions + const hasAnyRole = permRoles && permRoles.some((role) => authorMember.roles.cache.has(role)); + const isInChannel = permChannels && permChannels.includes(channelId); + const meetsRoleRequirements = !permRoles || permRoles.length === 0 + || (hasAnyRole && !rolesBlocklist) + || (!hasAnyRole && rolesBlocklist); + const meetsChannelRequirements = !permChannels || permChannels.length === 0 + || (isInChannel && !channelsBlocklist) + || (!isInChannel && channelsBlocklist); + + if (!meetsRoleRequirements) { + Logger.debug(`Prefix Command - User does not meet role requirements for command "${name}" based on user command "${commandText}"`); + if (quietErrors) return; + let errorText = ''; + if (verboseErrors && !rolesBlocklist) { + errorText = `You do not have the required role to execute this command. Required roles: ${permRoles.map((role) => guild.roles.cache.get(role)?.name).join(', ')}.`; + } else if (verboseErrors && rolesBlocklist) { + errorText = `You have a blocklisted role for this command. Blocklisted roles: ${permRoles.map((role) => guild.roles.cache.get(role)?.name).join(', ')}.`; + } else if (!verboseErrors && !rolesBlocklist) { + errorText = 'You do not have the required role to execute this command.'; + } else { + errorText = 'You have a blocklisted role for this command.'; + } + await sendPermError(message, errorText); + return; + } + + if (!meetsChannelRequirements) { + Logger.debug(`Prefix Command - Message does not meet channel requirements for command "${name}" based on user command "${commandText}"`); + if (quietErrors) return; + let errorText = ''; + if (verboseErrors && !channelsBlocklist) { + errorText = `This command is not available in this channel. Required channels: ${permChannels.map((channel) => guild.channels.cache.get(channel)?.toString()).join(', ')}.`; + } else if (verboseErrors && channelsBlocklist) { + errorText = `This command is blocklisted in this channel. Blocklisted channels: ${permChannels.map((channel) => guild.channels.cache.get(channel)?.toString()).join(', ')}.`; + } else if (!verboseErrors && !channelsBlocklist) { + errorText = 'This command is not available in this channel.'; + } else { + errorText = 'This command is blocklisted in this channel.'; + } + await sendPermError(message, errorText); + return; + } + + let commandContentData = contents.find(({ versionId }) => versionId === commandVersionId); + let enableButtons = true; + // If the version is not found, try to find the generic version + if (!commandContentData) { + commandContentData = contents.find(({ versionId }) => versionId === 'GENERIC'); + commandVersionName = 'GENERIC'; + enableButtons = false; + } + // If the generic version is not found, drop execution + if (!commandContentData) { + Logger.debug(`Prefix Command - Version "${commandVersionName}" not found for command "${name}" based on user command "${commandText}"`); + return; + } + const { title: commandTitle, content: commandContent, image: commandImage } = commandContentData; + // If generic requested and multiple versions, show the selection + // Note that this only applies if GENERIC is the version explicitly requested + // Otherwise, the options are not shown + if (enableButtons && commandVersionName === 'GENERIC' && contents.length > 1) { + Logger.debug(`Prefix Command - Multiple versions found for command "${name}" based on user command "${commandText}", showing version selection`); + const versionSelectionButtonData: { [key: string]: ButtonBuilder } = {}; + for (const { versionId: versionIdForButton } of contents) { + // eslint-disable-next-line no-await-in-loop + const versionCached = await inMemoryCache.get(`${memoryCachePrefixVersion}:${versionIdForButton}`); + if (versionCached) { + const version = PrefixCommandVersion.hydrate(versionCached); + const { emoji, enabled } = version; + if (enabled) { + versionSelectionButtonData[emoji] = new ButtonBuilder() + .setCustomId(`${versionIdForButton}`) + .setEmoji(emoji) + .setStyle(ButtonStyle.Primary); + } + } + } + const versionSelectionButtons: ButtonBuilder[] = Object.keys(versionSelectionButtonData) + .sort() + .map((key: string) => versionSelectionButtonData[key]); + const versionSelectButtonRow = new ActionRowBuilder().addComponents(versionSelectionButtons); + + if (versionSelectButtonRow.components.length < 1) { + Logger.debug(`Prefix Command - No enabled versions found for command "${name}" based on user command "${commandText}"`); + Logger.debug(`Prefix Command - Executing version "${commandVersionName}" for command "${name}" based on user command "${commandText}"`); + await sendReply(message, commandTitle, commandContent || '', isEmbed || false, embedColor || constantsConfig.colors.FBW_CYAN, commandImage || ''); + return; + } + const buttonMessage = await sendReply(message, commandTitle, commandContent || '', isEmbed || false, embedColor || constantsConfig.colors.FBW_CYAN, commandImage || '', versionSelectButtonRow); + + const filter = (interaction: Interaction) => interaction.user.id === authorId; + const collector = buttonMessage.createMessageComponentCollector({ filter, time: 60_000 }); + let buttonClicked = false; + collector.on('collect', async (collectedInteraction: ButtonInteraction) => { + buttonClicked = true; + await collectedInteraction.deferUpdate(); + Logger.debug(`Prefix Command - User selected button "${collectedInteraction.customId}" for command "${name}" based on user command "${commandText}"`); + await buttonMessage.delete(); + const { customId: selectedVersionId } = collectedInteraction; + const commandContentData = contents.find(({ versionId }) => versionId === selectedVersionId); + if (!commandContentData) { + Logger.debug(`Prefix Command - Version ID "${selectedVersionId}" not found for command "${name}" based on user command "${commandText}"`); + return; + } + const { title: commandTitle, content: commandContent, image: commandImage } = commandContentData; + await sendReply(message, commandTitle, commandContent || '', isEmbed || false, embedColor || constantsConfig.colors.FBW_CYAN, commandImage || ''); + }); + + collector.on('end', async (_: ButtonInteraction, reason: string) => { + if (!buttonClicked && reason === 'time') { + Logger.debug(`Prefix Command - User did not select a version for command "${name}" based on user command "${commandText}"`); + await expireChoiceReply(buttonMessage, commandTitle, commandContent || '', isEmbed || false, embedColor || constantsConfig.colors.FBW_CYAN, commandImage || ''); + } + }); + } else { + Logger.debug(`Prefix Command - Executing version "${commandVersionName}" for command "${name}" based on user command "${commandText}"`); + await sendReply(message, commandTitle, commandContent || '', isEmbed || false, embedColor || constantsConfig.colors.FBW_CYAN, commandImage || ''); + } + } + } + } +}); diff --git a/src/events/ready.ts b/src/events/ready.ts index d907af85..2572190f 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -9,6 +9,11 @@ import { Logger, imageBaseUrl, getScheduler, + setupInMemoryCache, + loadAllPrefixCommandsToCache, + loadAllPrefixCommandVersionsToCache, + loadAllPrefixCommandCategoriesToCache, + loadAllPrefixCommandChannelDefaultVersionsToCache, } from '../lib'; import { deployCommands } from '../scripts/deployCommands'; import commandArray from '../commands'; @@ -50,6 +55,18 @@ export default event(Events.ClientReady, async ({ log }, client) => { } } + // Setup cache manager + let inMemoryCacheSetup = false; + let inMemoryCacheError: Error | undefined; + await setupInMemoryCache() + .then(() => { + inMemoryCacheSetup = true; + }) + .catch((error) => { + inMemoryCacheError = error; + Logger.error(error); + }); + // Connect to MongoDB and set up scheduler let dbConnected = false; let dbError: Error | undefined; @@ -119,6 +136,73 @@ export default event(Events.ClientReady, async ({ log }, client) => { } } + const cacheRefreshInterval = process.env.CACHE_REFRESH_INTERVAL ? Number(process.env.CACHE_REFRESH_INTERVAL) : 1800; + // Set in memory cache refresh handler + if (schedulerConnected && cacheRefreshInterval) { + const scheduler = getScheduler(); + if (scheduler) { + const cacheJobList = await scheduler.jobs({ name: 'refreshInMemoryCache' }); + if (cacheJobList.length === 0) { + scheduler.every(`${cacheRefreshInterval} seconds`, 'refreshInMemoryCache', { interval: cacheRefreshInterval }); + Logger.info(`Cache refresh job scheduled with interval ${cacheRefreshInterval}`); + } else { + const cacheJob = cacheJobList[0]; + const { interval } = cacheJob.attrs.data as { interval: number }; + if (interval !== cacheRefreshInterval) { + await scheduler.cancel({ name: 'refreshInMemoryCache' }); + scheduler.every(`${cacheRefreshInterval} seconds`, 'refreshInMemoryCache', { interval: cacheRefreshInterval }); + Logger.info(`Cache refresh job rescheduled with new interval ${cacheRefreshInterval}`); + } else { + Logger.info('Cache refresh job already scheduled'); + } + } + } + } + + // Loading in-memory cache with prefix commands + if (inMemoryCacheSetup && dbConnected) { + await loadAllPrefixCommandsToCache() + .then(() => { + Logger.info('Loaded prefix commands to cache.'); + }) + .catch((error) => { + Logger.error(`Failed to load prefix commands to cache: ${error}`); + }); + } + + // Loading in-memory cache with prefix command versions + if (inMemoryCacheSetup && dbConnected) { + await loadAllPrefixCommandVersionsToCache() + .then(() => { + Logger.info('Loaded prefix command versions to cache.'); + }) + .catch((error) => { + Logger.error(`Failed to load prefix command versions to cache: ${error}`); + }); + } + + // Loading in-memory cache with prefix command categories + if (inMemoryCacheSetup && dbConnected) { + await loadAllPrefixCommandCategoriesToCache() + .then(() => { + Logger.info('Loaded prefix command categories to cache.'); + }) + .catch((error) => { + Logger.error(`Failed to load prefix command categories to cache: ${error}`); + }); + } + + // Loading in-memory cache with prefix command channel default versions + if (inMemoryCacheSetup && dbConnected) { + await loadAllPrefixCommandChannelDefaultVersionsToCache() + .then(() => { + Logger.info('Loaded prefix command channel default versions to cache.'); + }) + .catch((error) => { + Logger.error(`Failed to load prefix command channel default versions to cache: ${error}`); + }); + } + // Send bot status message to bot-dev channel const botDevChannel = client.channels.resolve(constantsConfig.channels.MOD_LOGS) as TextChannel; if (botDevChannel) { @@ -138,6 +222,11 @@ export default event(Events.ClientReady, async ({ log }, client) => { logMessage += ` - Scheduler Error: ${schedulerError.message}`; } + logMessage += ` - Cache State: ${inMemoryCacheSetup ? 'Setup' : 'Not Setup'}`; + if (!inMemoryCacheSetup && inMemoryCacheError) { + logMessage += ` - Cache Error: ${inMemoryCacheError.message}`; + } + await botDevChannel.send({ content: logMessage }); } else { log('Unable to find bot-dev channel. Cannot send bot status message.'); diff --git a/src/lib/cache/cacheManager.ts b/src/lib/cache/cacheManager.ts new file mode 100644 index 00000000..2002aea2 --- /dev/null +++ b/src/lib/cache/cacheManager.ts @@ -0,0 +1,356 @@ +import { Cache, caching } from 'cache-manager'; +import { getConn, IPrefixCommand, IPrefixCommandCategory, IPrefixCommandChannelDefaultVersion, IPrefixCommandVersion, Logger, PrefixCommand, PrefixCommandCategory, PrefixCommandChannelDefaultVersion, PrefixCommandVersion } from '../index'; + +let inMemoryCache: Cache; +const cacheSize = 10000; +const cacheRefreshInterval = process.env.CACHE_REFRESH_INTERVAL ? Number(process.env.CACHE_REFRESH_INTERVAL) : 1800; +const cacheTTL = cacheRefreshInterval * 2 * 1000; + +/** + * Cache Prefixes + */ + +export const memoryCachePrefixCommand = 'PF_COMMAND'; +export const memoryCachePrefixVersion = 'PF_VERSION'; +export const memoryCachePrefixCategory = 'PF_CATEGORY'; +export const memoryCachePrefixChannelDefaultVersion = 'PF_CHANNEL_VERSION'; + +/** + * Cache Management Functions + */ + +export async function setupInMemoryCache(callback = Logger.error) { + try { + inMemoryCache = await caching( + 'memory', + { + ttl: cacheTTL, + max: cacheSize, + }, + ); + Logger.info('In memory cache set up'); + } catch (err) { + callback(err); + } +} + +export function getInMemoryCache(callback = Logger.error) { + if (!inMemoryCache) { + callback(new Error('No in memory cache available.')); + return null; + } + return inMemoryCache; +} + +/** + * Prefix Command Cache Management Functions + */ + +export async function clearSinglePrefixCommandCache(command: IPrefixCommand) { + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) return; + + const { name, aliases } = command; + Logger.debug(`Clearing cache for command or alias "${name}"`); + for (const alias of aliases) { + // eslint-disable-next-line no-await-in-loop + await inMemoryCache.del(`${memoryCachePrefixCommand}:${alias.toLowerCase()}`); + } + await inMemoryCache.del(`${memoryCachePrefixCommand}:${name.toLowerCase()}`); +} + +export async function loadSinglePrefixCommandToCache(command: IPrefixCommand) { + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) return; + + const { name, aliases } = command; + Logger.debug(`Loading command ${name} to cache`); + await inMemoryCache.set(`${memoryCachePrefixCommand}:${name.toLowerCase()}`, command.toObject()); + for (const alias of aliases) { + // eslint-disable-next-line no-await-in-loop + await inMemoryCache.set(`${memoryCachePrefixCommand}:${alias.toLowerCase()}`, command.toObject()); + } +} + +export async function loadAllPrefixCommandsToCache() { + const conn = getConn(); + const inMemoryCache = getInMemoryCache(); + if (!conn || !inMemoryCache) return; + + const PrefixCommands = await PrefixCommand.find(); + for (const command of PrefixCommands) { + // eslint-disable-next-line no-await-in-loop + await loadSinglePrefixCommandToCache(command); + } +} + +export async function refreshSinglePrefixCommandCache(oldCommand: IPrefixCommand, newCommand: IPrefixCommand) { + await clearSinglePrefixCommandCache(oldCommand); + await loadSinglePrefixCommandToCache(newCommand); +} + +export async function refreshAllPrefixCommandsCache() { + const conn = getConn(); + const inMemoryCache = getInMemoryCache(); + if (!conn || !inMemoryCache) return; + + // Step 1: Get all commands from the database + const prefixCommands = await PrefixCommand.find(); + // Step 2: Get all commands from the cache + const cacheKeys = await inMemoryCache.store.keys(); + // Step 3: Loop over cached commands + for (const key of cacheKeys) { + if (key.startsWith(`${memoryCachePrefixCommand}:`)) { + const checkCommand = key.split(':')[1]; + // Step 3.a: Check if cached command exists in the database list + let found = false; + for (const dbCommand of prefixCommands) { + const { name: dbCommandName, aliases: dbCommandAliases } = dbCommand; + if (dbCommandName.toLowerCase() === checkCommand.toLowerCase() || dbCommandAliases.includes(checkCommand)) { + found = true; + break; + } + } + // Step 3.b: If not found, remove from cache + if (!found) { + Logger.debug(`Removing command or alias ${checkCommand} from cache`); + // eslint-disable-next-line no-await-in-loop + await inMemoryCache.del(key); + } + } + } + // Step 4: Loop over database commands and update cache + for (const dbCommand of prefixCommands) { + // eslint-disable-next-line no-await-in-loop + await loadSinglePrefixCommandToCache(dbCommand); + } +} + +/** + * Prefix Command Version Cache Management Functions + */ + +export async function clearSinglePrefixCommandVersionCache(version: IPrefixCommandVersion) { + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) return; + + const { alias, _id: versionId } = version; + Logger.debug(`Clearing cache for command version alias "${alias}"`); + await inMemoryCache.del(`${memoryCachePrefixVersion}:${alias.toLowerCase()}`); + await inMemoryCache.del(`${memoryCachePrefixVersion}:${versionId}`); +} + +export async function loadSinglePrefixCommandVersionToCache(version: IPrefixCommandVersion) { + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) return; + + const { alias, _id: versionId } = version; + Logger.debug(`Loading version with alias ${alias} to cache`); + await inMemoryCache.set(`${memoryCachePrefixVersion}:${alias.toLowerCase()}`, version.toObject()); + await inMemoryCache.set(`${memoryCachePrefixVersion}:${versionId}`, version.toObject()); +} + +export async function loadAllPrefixCommandVersionsToCache() { + const conn = getConn(); + const inMemoryCache = getInMemoryCache(); + if (!conn || !inMemoryCache) return; + + const PrefixCommandVersions = await PrefixCommandVersion.find(); + for (const version of PrefixCommandVersions) { + // eslint-disable-next-line no-await-in-loop + await loadSinglePrefixCommandVersionToCache(version); + } +} + +export async function refreshSinglePrefixCommandVersionCache(oldVersion: IPrefixCommandVersion, newVersion: IPrefixCommandVersion) { + await clearSinglePrefixCommandVersionCache(oldVersion); + await loadSinglePrefixCommandVersionToCache(newVersion); +} + +export async function refreshAllPrefixCommandVersionsCache() { + const conn = getConn(); + const inMemoryCache = getInMemoryCache(); + if (!conn || !inMemoryCache) return; + + // Step 1: Get all versions from the database + const prefixCommandVersions = await PrefixCommandVersion.find(); + // Step 2: Get all versions from the cache + const cacheKeys = await inMemoryCache.store.keys(); + // Step 3: Loop over cached versions + for (const key of cacheKeys) { + if (key.startsWith(`${memoryCachePrefixVersion}:`)) { + const checkVersion = key.split(':')[1]; + // Step 3.a: Check if cached version exists in the database list + let found = false; + for (const dbVersion of prefixCommandVersions) { + const { _id: dbVersionId, alias } = dbVersion; + if (dbVersionId.toString().toLowerCase() === checkVersion.toLowerCase() || alias.toLowerCase() === checkVersion.toLowerCase()) { + found = true; + break; + } + } + // Step 3.b: If not found, remove from cache + if (!found) { + Logger.debug(`Removing version with id ${checkVersion} from cache`); + // eslint-disable-next-line no-await-in-loop + await inMemoryCache.del(key); + } + } + } + // Step 4: Loop over database versions and update cache + for (const dbVersion of prefixCommandVersions) { + // eslint-disable-next-line no-await-in-loop + await loadSinglePrefixCommandVersionToCache(dbVersion); + } +} + +/** + * Prefix Command Category Cache Management Functions + */ + +export async function clearSinglePrefixCommandCategoryCache(category: IPrefixCommandCategory) { + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) return; + + const { name } = category; + Logger.debug(`Clearing cache for command category "${name}"`); + await inMemoryCache.del(`${memoryCachePrefixCategory}:${name.toLowerCase()}`); +} + +export async function loadSinglePrefixCommandCategoryToCache(category: IPrefixCommandCategory) { + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) return; + + const { name } = category; + Logger.debug(`Loading category ${name} to cache`); + await inMemoryCache.set(`${memoryCachePrefixCategory}:${name.toLowerCase()}`, category.toObject()); +} + +export async function loadAllPrefixCommandCategoriesToCache() { + const conn = getConn(); + const inMemoryCache = getInMemoryCache(); + if (!conn || !inMemoryCache) return; + + const PrefixCommandCategories = await PrefixCommandCategory.find(); + for (const category of PrefixCommandCategories) { + // eslint-disable-next-line no-await-in-loop + await loadSinglePrefixCommandCategoryToCache(category); + } +} + +export async function refreshSinglePrefixCommandCategoryCache(oldCategory: IPrefixCommandCategory, newCategory: IPrefixCommandCategory) { + await clearSinglePrefixCommandCategoryCache(oldCategory); + await loadSinglePrefixCommandCategoryToCache(newCategory); +} + +export async function refreshAllPrefixCommandCategoriesCache() { + const conn = getConn(); + const inMemoryCache = getInMemoryCache(); + if (!conn || !inMemoryCache) return; + + // Step 1: Get all catagories from the database + const prefixCommandCategories = await PrefixCommandCategory.find(); + // Step 2: Get all categories from the cache + const cacheKeys = await inMemoryCache.store.keys(); + // Step 3: Loop over cached categories + for (const key of cacheKeys) { + if (key.startsWith(`${memoryCachePrefixCategory}:`)) { + const categoryName = key.split(':')[1]; + // Step 3.a: Check if cached category exists in the database list + let found = false; + for (const dbCategory of prefixCommandCategories) { + const { name: dbCategoryName } = dbCategory; + if (dbCategoryName.toLowerCase() === categoryName.toLowerCase()) { + found = true; + break; + } + } + // Step 3.b: If not found, remove from cache + if (!found) { + Logger.debug(`Removing category ${categoryName} from cache`); + // eslint-disable-next-line no-await-in-loop + await inMemoryCache.del(key); + } + } + } + // Step 4: Loop over database categories and update cache + for (const dbCategory of prefixCommandCategories) { + // eslint-disable-next-line no-await-in-loop + await loadSinglePrefixCommandCategoryToCache(dbCategory); + } +} + +/** + * Prefix Command Channel Default Version Cache Management Functions + */ + +export async function clearSinglePrefixCommandChannelDefaultVersionCache(channelDefaultVersion: IPrefixCommandChannelDefaultVersion) { + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) return; + + const { channelId } = channelDefaultVersion; + Logger.debug(`Clearing cache for channel default version for channel "${channelId}"`); + await inMemoryCache.del(`${memoryCachePrefixChannelDefaultVersion}:${channelId}`); +} + +export async function loadSinglePrefixCommandChannelDefaultVersionToCache(channelDefaultVersion: IPrefixCommandChannelDefaultVersion) { + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) return; + + const { channelId, versionId } = channelDefaultVersion; + const version = await PrefixCommandVersion.findById(versionId); + if (version) { + Logger.debug(`Loading default version for channel ${channelId} to cache`); + await inMemoryCache.set(`${memoryCachePrefixChannelDefaultVersion}:${channelId}`, version.toObject()); + } +} + +export async function loadAllPrefixCommandChannelDefaultVersionsToCache() { + const conn = getConn(); + const inMemoryCache = getInMemoryCache(); + if (!conn || !inMemoryCache) return; + + const PrefixCommandChannelDefaultVersions = await PrefixCommandChannelDefaultVersion.find(); + + for (const defaultVersion of PrefixCommandChannelDefaultVersions) { + // eslint-disable-next-line no-await-in-loop + await loadSinglePrefixCommandChannelDefaultVersionToCache(defaultVersion); + } +} + +export async function refreshAllPrefixCommandChannelDefaultVersionsCache() { + const conn = getConn(); + const inMemoryCache = getInMemoryCache(); + if (!conn || !inMemoryCache) return; + + // Step 1: Get all channel default versions from the database + const prefixCommandChannelDefaultVersions = await PrefixCommandChannelDefaultVersion.find(); + // Step 2: Get all channel default versions from the cache + const cacheKeys = await inMemoryCache.store.keys(); + // Step 3: Loop over cached channel default versions + for (const key of cacheKeys) { + if (key.startsWith(`${memoryCachePrefixChannelDefaultVersion}:`)) { + const channelId = key.split(':')[1]; + // Step 3.a: Check if cached channel default version exists in the database list + let found = false; + for (const dbChannelDefaultVersion of prefixCommandChannelDefaultVersions) { + const { channelId: dbChannelId } = dbChannelDefaultVersion; + if (dbChannelId.toString().toLowerCase() === channelId.toLowerCase()) { + found = true; + break; + } + } + // Step 3.b: If not found, remove from cache + if (!found) { + Logger.debug(`Removing channel default version for channel ${channelId} from cache`); + // eslint-disable-next-line no-await-in-loop + await inMemoryCache.del(key); + } + } + } + // Step 4: Loop over database channel default versions and update cache + for (const dbChannelDefaultVersion of prefixCommandChannelDefaultVersions) { + // eslint-disable-next-line no-await-in-loop + await loadSinglePrefixCommandChannelDefaultVersionToCache(dbChannelDefaultVersion); + } +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 7a9e9760..cf36dae1 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -49,6 +49,8 @@ interface Config { roleGroups: { [x: string]: string[], }, + prefixCommandPrefix: string, + prefixCommandPermissionDelay: number, roles: { ADMIN_TEAM: string, BOT_DEVELOPER: string, diff --git a/src/lib/index.ts b/src/lib/index.ts index c5983c58..2f98d658 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -16,8 +16,13 @@ export * from './autocomplete'; export * from './schemas/infractionSchema'; export * from './schemas/faqSchema'; export * from './schemas/birthdaySchema'; +export * from './schemas/prefixCommandSchemas'; //Scheduler Jobs export * from './schedulerJobs/autoDisableSlowMode'; export * from './schedulerJobs/sendHeartbeat'; export * from './schedulerJobs/postBirthdays'; +export * from './schedulerJobs/refreshInMemoryCache'; + +//Cache Management +export * from './cache/cacheManager'; diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts index 95c674fc..ddc1f6a8 100644 --- a/src/lib/scheduler.ts +++ b/src/lib/scheduler.ts @@ -1,5 +1,5 @@ import { Agenda } from '@hokify/agenda'; -import { Logger, autoDisableSlowMode, sendHeartbeat, postBirthdays } from './index'; +import { Logger, autoDisableSlowMode, sendHeartbeat, postBirthdays, refreshInMemoryCache } from './index'; let scheduler: Agenda; @@ -18,6 +18,7 @@ export async function setupScheduler(name: string, url: string, callback = Logge scheduler.define('autoDisableSlowMode', autoDisableSlowMode); scheduler.define('sendHeartbeat', sendHeartbeat); scheduler.define('postBirthdays', postBirthdays); + scheduler.define('refreshInMemoryCache', refreshInMemoryCache); Logger.info('Scheduler set up'); } catch (err) { callback(err); diff --git a/src/lib/schedulerJobs/refreshInMemoryCache.ts b/src/lib/schedulerJobs/refreshInMemoryCache.ts new file mode 100644 index 00000000..fcb6a6ed --- /dev/null +++ b/src/lib/schedulerJobs/refreshInMemoryCache.ts @@ -0,0 +1,36 @@ +import { Job } from '@hokify/agenda'; +import { Logger, getInMemoryCache, getScheduler, refreshAllPrefixCommandCategoriesCache, refreshAllPrefixCommandChannelDefaultVersionsCache, refreshAllPrefixCommandVersionsCache, refreshAllPrefixCommandsCache } from '../index'; + +export async function refreshInMemoryCache(job: Job) { + const scheduler = getScheduler(); + if (!scheduler) { + Logger.error('Failed to get scheduler instance'); + return; + } + + const inMemoryCache = getInMemoryCache(); + if (!inMemoryCache) { + Logger.error('Failed to get in-memory cache instance'); + return; + } + + // Needed because of https://github.com/agenda/agenda/issues/401 + // eslint-disable-next-line no-underscore-dangle + const matchingJobs = await scheduler.jobs({ _id: job.attrs._id }); + if (matchingJobs.length !== 1) { + Logger.debug('Job has been deleted already, skipping execution.'); + return; + } + + const start = new Date().getTime(); + try { + await refreshAllPrefixCommandVersionsCache(); + await refreshAllPrefixCommandCategoriesCache(); + await refreshAllPrefixCommandsCache(); + await refreshAllPrefixCommandChannelDefaultVersionsCache(); + } catch (error) { + Logger.error('Failed to refresh the in memory cache:', error); + } + const duration = ((new Date().getTime() - start) / 1000).toFixed(2); + Logger.info(`In memory cache refreshed successfully, duration: ${duration}s`); +} diff --git a/src/lib/schemas/prefixCommandSchemas.ts b/src/lib/schemas/prefixCommandSchemas.ts new file mode 100644 index 00000000..c4dee8b5 --- /dev/null +++ b/src/lib/schemas/prefixCommandSchemas.ts @@ -0,0 +1,136 @@ +import mongoose, { Schema, Document } from 'mongoose'; + +export interface IPrefixCommandCategory extends Document { + categoryId: mongoose.Schema.Types.ObjectId; + name: string; + emoji: string; +} + +const prefixCommandCategorySchema = new Schema({ + categoryId: mongoose.Schema.Types.ObjectId, + name: { + type: String, + required: true, + unique: true, + }, + emoji: String, +}); + +export interface IPrefixCommandVersion extends Document { + versionId: mongoose.Schema.Types.ObjectId; + name: string; + emoji: string; + alias: string; + enabled: boolean; +} + +const prefixCommandVersionSchema = new Schema({ + versionId: mongoose.Schema.Types.ObjectId, + name: { + type: String, + required: true, + unique: true, + }, + emoji: { + type: String, + required: true, + unique: true, + }, + alias: { + type: String, + required: true, + unique: true, + }, + enabled: Boolean, +}); + +export interface IPrefixCommandChannelDefaultVersion extends Document { + channelId: string; + versionId: string; +} + +const prefixCommandChannelDefaultVersionSchema = new Schema({ + channelId: { + type: String, + required: true, + unique: true, + }, + versionId: { + type: String, + required: true, + }, +}); + +export interface IPrefixCommandContent extends Document{ + versionId: string; + title: string; + content?: string; + image?: string; +} + +const prefixCommandContentSchema = new Schema({ + versionId: { + type: String, + required: true, + }, + title: String, + content: String, + image: String, +}, { autoCreate: false }); + +export interface IPrefixCommandPermissions extends Document { + roles?: string[], + rolesBlocklist?: boolean, + channels?: string[], + channelsBlocklist?: boolean, + quietErrors?: boolean, + verboseErrors?: boolean, +} + +const prefixCommandPermissionsSchema = new Schema({ + roles: [String], + rolesBlocklist: Boolean, + channels: [String], + channelsBlocklist: Boolean, + quietErrors: Boolean, + verboseErrors: Boolean, +}, { autoCreate: false }); + +export interface IPrefixCommand extends Document { + commandId: mongoose.Schema.Types.ObjectId; + categoryId: mongoose.Schema.Types.ObjectId; + name: string; + description: string; + aliases: string[]; + isEmbed: boolean; + embedColor?: string; + contents: IPrefixCommandContent[]; + permissions: IPrefixCommandPermissions; +} + +const prefixCommandSchema = new Schema({ + commandId: mongoose.Schema.Types.ObjectId, + categoryId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'PrefixCommandCategory', + required: true, + }, + name: { + type: String, + required: true, + unique: true, + }, + description: String, + aliases: [{ type: String }], + isEmbed: Boolean, + embedColor: String, + contents: [prefixCommandContentSchema], + permissions: prefixCommandPermissionsSchema, +}); + +export const PrefixCommandCategory = mongoose.model('PrefixCommandCategory', prefixCommandCategorySchema); +export const PrefixCommandVersion = mongoose.model('PrefixCommandVersion', prefixCommandVersionSchema); +export const PrefixCommandContent = mongoose.model('PrefixCommandContent', prefixCommandContentSchema); +export const PrefixCommandPermissions = mongoose.model('PrefixCommandPermissions', prefixCommandPermissionsSchema); +export const PrefixCommandChannelDefaultVersion = mongoose.model('PrefixCommandChannelDefaultVersion', prefixCommandChannelDefaultVersionSchema); +export const PrefixCommand = mongoose.model('PrefixCommand', prefixCommandSchema);