From 89bd1456a0b55798b12a2ededd22b852c5331b89 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Fri, 21 Jul 2023 15:54:21 +0100 Subject: [PATCH] feat: initial commit --- .dockerignore | 25 ++ .github/workflows/docker-release.yml | 42 ++ .github/workflows/dotnet.yml | 25 ++ .github/workflows/prerelease.yml | 35 ++ .github/workflows/release.yml | 35 ++ .gitignore | 37 ++ CHANGELOG.md | 14 + CONTRIBUTING.md | 34 ++ Dockerfile | 18 + LICENSE.md | 7 + README.md | 75 ++++ SuggestionBot.sln | 21 + .../SuggestionAutocompleteProvider.cs | 29 ++ SuggestionBot/Commands/InfoCommand.cs | 55 +++ SuggestionBot/Commands/SuggestCommand.cs | 106 +++++ .../Commands/SuggestionCommand.Block.cs | 23 ++ .../Commands/SuggestionCommand.Implement.cs | 43 +++ .../Commands/SuggestionCommand.Reject.cs | 51 +++ .../Commands/SuggestionCommand.Unblock.cs | 21 + SuggestionBot/Commands/SuggestionCommand.cs | 24 ++ .../Configuration/GuildConfiguration.cs | 43 +++ SuggestionBot/Data/BlockedUser.cs | 34 ++ .../BlockedUserConfiguration.cs | 23 ++ .../SuggestionConfiguration.cs | 25 ++ SuggestionBot/Data/Suggestion.cs | 54 +++ SuggestionBot/Data/SuggestionContext.cs | 30 ++ SuggestionBot/Data/SuggestionStatus.cs | 11 + SuggestionBot/Interactivity/DiscordModal.cs | 87 +++++ .../Interactivity/DiscordModalBuilder.cs | 95 +++++ .../Interactivity/DiscordModalResponse.cs | 7 + .../Interactivity/DiscordModalTextInput.cs | 41 ++ .../Logging/ColorfulConsoleTarget.cs | 39 ++ SuggestionBot/Logging/LogFileTarget.cs | 40 ++ SuggestionBot/Program.cs | 35 ++ SuggestionBot/Services/BotService.cs | 71 ++++ .../Services/ConfigurationService.cs | 60 +++ SuggestionBot/Services/DatabaseService.cs | 45 +++ SuggestionBot/Services/DiscordLogService.cs | 126 ++++++ SuggestionBot/Services/LoggingService.cs | 94 +++++ SuggestionBot/Services/SuggestionService.cs | 362 ++++++++++++++++++ SuggestionBot/Services/UserBlockingService.cs | 174 +++++++++ SuggestionBot/SuggestionBot.csproj | 57 +++ USAGE.md | 67 ++++ config.example.json | 7 + docker-compose.yml | 16 + global.json | 7 + icon.png | Bin 0 -> 48048 bytes 47 files changed, 2370 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-release.yml create mode 100644 .github/workflows/dotnet.yml create mode 100644 .github/workflows/prerelease.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 SuggestionBot.sln create mode 100644 SuggestionBot/AutocompleteProviders/SuggestionAutocompleteProvider.cs create mode 100644 SuggestionBot/Commands/InfoCommand.cs create mode 100644 SuggestionBot/Commands/SuggestCommand.cs create mode 100644 SuggestionBot/Commands/SuggestionCommand.Block.cs create mode 100644 SuggestionBot/Commands/SuggestionCommand.Implement.cs create mode 100644 SuggestionBot/Commands/SuggestionCommand.Reject.cs create mode 100644 SuggestionBot/Commands/SuggestionCommand.Unblock.cs create mode 100644 SuggestionBot/Commands/SuggestionCommand.cs create mode 100644 SuggestionBot/Configuration/GuildConfiguration.cs create mode 100644 SuggestionBot/Data/BlockedUser.cs create mode 100644 SuggestionBot/Data/EntityConfigurations/BlockedUserConfiguration.cs create mode 100644 SuggestionBot/Data/EntityConfigurations/SuggestionConfiguration.cs create mode 100644 SuggestionBot/Data/Suggestion.cs create mode 100644 SuggestionBot/Data/SuggestionContext.cs create mode 100644 SuggestionBot/Data/SuggestionStatus.cs create mode 100644 SuggestionBot/Interactivity/DiscordModal.cs create mode 100644 SuggestionBot/Interactivity/DiscordModalBuilder.cs create mode 100644 SuggestionBot/Interactivity/DiscordModalResponse.cs create mode 100644 SuggestionBot/Interactivity/DiscordModalTextInput.cs create mode 100644 SuggestionBot/Logging/ColorfulConsoleTarget.cs create mode 100644 SuggestionBot/Logging/LogFileTarget.cs create mode 100644 SuggestionBot/Program.cs create mode 100644 SuggestionBot/Services/BotService.cs create mode 100644 SuggestionBot/Services/ConfigurationService.cs create mode 100644 SuggestionBot/Services/DatabaseService.cs create mode 100644 SuggestionBot/Services/DiscordLogService.cs create mode 100644 SuggestionBot/Services/LoggingService.cs create mode 100644 SuggestionBot/Services/SuggestionService.cs create mode 100644 SuggestionBot/Services/UserBlockingService.cs create mode 100644 SuggestionBot/SuggestionBot.csproj create mode 100644 USAGE.md create mode 100644 config.example.json create mode 100644 docker-compose.yml create mode 100644 global.json create mode 100644 icon.png diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..af50df1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 0000000..28319a4 --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,42 @@ +name: Docker Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..c945a45 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,25 @@ +name: .NET + +on: + push: + pull_request: + +jobs: + build: + name: "Build & Test" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + - name: Add NuGet source + run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json" + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore --configuration Release + - name: Test + run: dotnet test --no-build --verbosity normal diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 0000000..9c76953 --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,35 @@ +name: Tagged Pre-Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+-*" + +jobs: + prerelease: + name: "Tagged Pre-Release" + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + + - name: Add GitHub NuGet source + run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build -c Release + + - name: Create Release + uses: "marvinpinto/action-automatic-releases@latest" + with: + repo_token: "${{ secrets.GITHUB_TOKEN }}" + prerelease: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6e91b3a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Tagged Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +jobs: + release: + name: "Tagged Release" + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + + - name: Add GitHub NuGet source + run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build -c Release + + - name: Create Release + uses: "marvinpinto/action-automatic-releases@latest" + with: + repo_token: "${{ secrets.GITHUB_TOKEN }}" + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a437a65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Visual Studio Code +.vscode + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +# Visual Studio 2015 +.vs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d9936af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2023-07-21 + +### Added + +- Initial release + +[1.0.0]: https://github.com/BrackeysBot/SuggestionBot/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..559a38c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contribution Guidelines + +When contributing to this repository, please first discuss the change you wish to make by contacting one of the bot developers in the Discord server, or by creating a [discussion](https://github.com/oliverbooth/BrackeysBot/discussions) here in this repository. + +Please note we have a code of conduct, please follow it in all your interactions with the project. + +## Pull Request Process +1. Update [README.md](README.md) outlining any necessary changes made to the project - do not leave this down to the repository owners. +2. Do not increase any version numbers. This process is done by us when we feel it necessary to do so. +3. This repository, and its child repositories, follow Microsoft's [C# coding conventions](https://docs.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions) and [.NET design guidelines](https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/). Please adhere to these conventions and guidelines. Pull requests which do not fall in line with the code style will be left open until this is adhered to (and may be closed at any time if we feel the changes will not be agreed upon.) + +## Code Style +Where Microsoft's conventions do not suffice, an .editorconfig is provided in the repository which should integrate with Visual Studio or Rider to automate the process. This .editorconfig should roughly coincide with [StyleCop rules](https://github.com/DotNetAnalyzers/StyleCopAnalyzers/tree/master/documentation), with some minor exceptions. + +### Comments +**Please use comments sparingly!** The use of comments to outline what a particular block of code is doing is usually indicative of the code is not being clear enough in its own right. If you feel that a comment is required to clarify logic, consider refactoring the code to includes having meaningfully named variables and methods so that a comment is redundant. + +An example of comment types which would be unacceptable: +```cs +foreach (char character in someString) // loop through every char in the string +{ + Console.WriteLine(character); // print out each character on a new line +} +``` + +The exception to this is if the comment is explaining the "why" rather than the "what". A comment which outlines the rationale behind a specific solution is acceptable. For example: +```cs +for (var index = 0; index < someString.Length; index++) // cheaper than foreach, no allocation of CharEnumerator +{ + char character = someString[index]; + Console.WriteLine(character); +} +``` +In such a case, the comment is not explaining what the code does - but why it does it that way, rather than a different way. This type of comment is accepted and encouraged. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b6e233 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM mcr.microsoft.com/dotnet/runtime:7.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +WORKDIR /src +COPY ["SuggestionBot/SuggestionBot.csproj", "SuggestionBot/"] +RUN dotnet restore "SuggestionBot/SuggestionBot.csproj" +COPY . . +WORKDIR "/src/SuggestionBot" +RUN dotnet build "SuggestionBot.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "SuggestionBot.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "SuggestionBot.dll"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..34de789 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2022 Oliver Booth + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b26e77c --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +

SuggestionBot

+

+

A Discord bot for suggestions.

+

+ +GitHub Workflow Status +GitHub Issues +MIT License +

+ +## About +SuggestionBot is a Discord bot which allows users to submit suggestions. + +## Installing and configuring SuggestionBot +SuggestionBot runs in a Docker container, and there is a [docker-compose.yml](docker-compose.yml) file which simplifies this process. + +### Clone the repository +To start off, clone the repository into your desired directory: +```bash +git clone https://github.com/BrackeysBot/SuggestionBot.git +``` +Step into the SuggestionBot directory using `cd SuggestionBot`, and continue with the steps below. + +### Setting things up +The bot's token is passed to the container using the `DISCORD_TOKEN` environment variable. Create a file named `.env`, and add the following line: +``` +DISCORD_TOKEN=your_token_here +``` + +Two directories are required to exist for Docker compose to mount as container volumes, `data` and `logs`: +```bash +mkdir data +mkdir logs +``` +Copy the example `config.example.json` to `data/config.json`, and assign the necessary config keys. Below is breakdown of the config.json layout: +```json +{ + "GUILD_ID": { + "logChannel": /* The ID of the log channel */, + "suggestionChannel": /* The ID of the channel in which suggestions are posted */, + "suggestedColor": /* The default color for suggestions, as a 24-bit RGB integer. Defaults to #FFFF00 */, + "implementedColor": /* The color for implemented suggestions, as a 24-bit RGB integer. Defaults to #191970 */, + "rejectedColor": /* The color for rejected suggestions, as a 24-bit RGB integer. Defaults to #FF0000 */, + "cooldown": /* The cooldown between suggestion posting. Defaults to 3600 */ + } +} +``` +The `logs` directory is used to store logs in a format similar to that of a Minecraft server. `latest.log` will contain the log for the current day and current execution. All past logs are archived. + +The `data` directory is used to store persistent state of the bot, such as config values and the infraction database. + +### Launch SuggestionBot +To launch SuggestionBot, simply run the following commands: +```bash +sudo docker-compose build +sudo docker-compose up --detach +``` + +## Updating SuggestionBot +To update SuggestionBot, simply pull the latest changes from the repo and restart the container: +```bash +git pull +sudo docker-compose stop +sudo docker-compose build +sudo docker-compose up --detach +``` + +## Using SuggestionBot +For further usage breakdown and explanation of commands, see [USAGE.md](USAGE.md). + +## License +This bot is under the [MIT License](LICENSE.md). + +## Disclaimer +This bot is tailored for use within the [Brackeys Discord server](https://discord.gg/brackeys). While this bot is open source and you are free to use it in your own servers, you accept responsibility for any mishaps which may arise from the use of this software. Use at your own risk. diff --git a/SuggestionBot.sln b/SuggestionBot.sln new file mode 100644 index 0000000..c1961d7 --- /dev/null +++ b/SuggestionBot.sln @@ -0,0 +1,21 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SuggestionBot", "SuggestionBot\SuggestionBot.csproj", "{81628814-A6CE-4CD8-9938-726D2A666872}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{33635704-EADC-4619-A034-E90F7F84EA6B}" + ProjectSection(SolutionItems) = preProject + .dockerignore = .dockerignore + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {81628814-A6CE-4CD8-9938-726D2A666872}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {81628814-A6CE-4CD8-9938-726D2A666872}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81628814-A6CE-4CD8-9938-726D2A666872}.Release|Any CPU.ActiveCfg = Release|Any CPU + {81628814-A6CE-4CD8-9938-726D2A666872}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/SuggestionBot/AutocompleteProviders/SuggestionAutocompleteProvider.cs b/SuggestionBot/AutocompleteProviders/SuggestionAutocompleteProvider.cs new file mode 100644 index 0000000..84a6f60 --- /dev/null +++ b/SuggestionBot/AutocompleteProviders/SuggestionAutocompleteProvider.cs @@ -0,0 +1,29 @@ +using DSharpPlus.Entities; +using DSharpPlus.SlashCommands; +using Microsoft.Extensions.DependencyInjection; +using SuggestionBot.Data; +using SuggestionBot.Services; + +namespace SuggestionBot.AutocompleteProviders; + +internal sealed class SuggestionAutocompleteProvider : IAutocompleteProvider +{ + /// + public Task> Provider(AutocompleteContext context) + { + var suggestionService = context.Services.GetRequiredService(); + IEnumerable suggestions = suggestionService.GetSuggestions(context.Guild, true); + + return Task.FromResult(suggestions.OrderByDescending(i => i.Timestamp).Take(10).Select(infraction => + { + string summary = GetSuggestionSummary(infraction); + return new DiscordAutoCompleteChoice(summary, infraction.Id); + })); + } + + private static string GetSuggestionSummary(Suggestion suggestion) + { + string content = suggestion.Content[..Math.Min(20, suggestion.Content.Length)]; + return $"{suggestion.Id:N} by {suggestion.AuthorId}: {content}..."; + } +} diff --git a/SuggestionBot/Commands/InfoCommand.cs b/SuggestionBot/Commands/InfoCommand.cs new file mode 100644 index 0000000..eb04bdb --- /dev/null +++ b/SuggestionBot/Commands/InfoCommand.cs @@ -0,0 +1,55 @@ +using System.Text; +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.SlashCommands; +using DSharpPlus.SlashCommands.Attributes; +using Humanizer; +using SuggestionBot.Services; +using X10D.DSharpPlus; + +namespace SuggestionBot.Commands; + +/// +/// Represents a class which implements the info command. +/// +internal sealed class InfoCommand : ApplicationCommandModule +{ + private readonly BotService _botService; + + /// + /// Initializes a new instance of the class. + /// + /// The bot service. + public InfoCommand(BotService botService) + { + _botService = botService; + } + + [SlashCommand("info", "Displays information about the bot.")] + [SlashRequireGuild] + public async Task InfoAsync(InteractionContext context) + { + DiscordClient client = context.Client; + DiscordMember member = (await client.CurrentUser.GetAsMemberOfAsync(context.Guild).ConfigureAwait(false))!; + string botVersion = _botService.Version; + + var embed = new DiscordEmbedBuilder(); + embed.WithAuthor(member); + embed.WithColor(member.Color); + embed.WithThumbnail(member.AvatarUrl); + embed.WithTitle($"SuggestionBot v{botVersion}"); + embed.AddField("Ping", client.Ping, true); + embed.AddField("Uptime", (DateTimeOffset.UtcNow - _botService.StartedAt).Humanize(), true); + + var builder = new StringBuilder(); + builder.AppendLine($"SuggestionBot: {botVersion}"); + builder.AppendLine($"D#+: {client.VersionString}"); + builder.AppendLine($"Gateway: {client.GatewayVersion}"); + builder.AppendLine($"CLR: {Environment.Version.ToString(3)}"); + builder.AppendLine($"Host: {Environment.OSVersion}"); + + embed.AddField("Version", Formatter.BlockCode(builder.ToString())); + + await context.CreateResponseAsync(embed, true).ConfigureAwait(false); + } +} diff --git a/SuggestionBot/Commands/SuggestCommand.cs b/SuggestionBot/Commands/SuggestCommand.cs new file mode 100644 index 0000000..bfc4bb7 --- /dev/null +++ b/SuggestionBot/Commands/SuggestCommand.cs @@ -0,0 +1,106 @@ +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.SlashCommands; +using DSharpPlus.SlashCommands.Attributes; +using Humanizer; +using SuggestionBot.Configuration; +using SuggestionBot.Data; +using SuggestionBot.Interactivity; +using SuggestionBot.Services; + +namespace SuggestionBot.Commands; + +internal sealed class SuggestCommand : ApplicationCommandModule +{ + private readonly ConfigurationService _configurationService; + private readonly SuggestionService _suggestionService; + private readonly UserBlockingService _userBlockingService; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + public SuggestCommand(ConfigurationService configurationService, SuggestionService suggestionService, + UserBlockingService userBlockingService) + { + _configurationService = configurationService; + _suggestionService = suggestionService; + _userBlockingService = userBlockingService; + } + + [SlashCommand("suggest", "Submit a new suggestion.")] + [SlashRequireGuild] + public async Task SuggestAsync(InteractionContext context) + { + if (_userBlockingService.IsUserBlocked(context.Guild, context.User)) + { + var builder = new DiscordInteractionResponseBuilder(); + builder.WithContent("You are blocked from posting suggestions in this server."); + await context.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, builder) + .ConfigureAwait(false); + return; + } + + if (ValidateCooldown(context, out DateTimeOffset expiration)) + { + TimeSpan remaining = expiration - DateTimeOffset.UtcNow; + + var builder = new DiscordInteractionResponseBuilder(); + builder.WithContent($"You are on cooldown. You can suggest again in {remaining.Humanize()}."); + await context.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, builder) + .ConfigureAwait(false); + return; + } + + var modal = new DiscordModalBuilder(context.Client); + modal.WithTitle("New Suggestion"); + + DiscordModalTextInput input = modal.AddInput("What is your suggestion", "e.g. The server should have [...]", + inputStyle: TextInputStyle.Paragraph, maxLength: 4000); + DiscordModalResponse response = + await modal.Build().RespondToAsync(context.Interaction, TimeSpan.FromMinutes(5)).ConfigureAwait(false); + + if (response != DiscordModalResponse.Success) return; + + var followUp = new DiscordFollowupMessageBuilder(); + if (string.IsNullOrWhiteSpace(input.Value)) + { + followUp.WithContent("No content provided. Suggestion cancelled."); + await context.FollowUpAsync(followUp).ConfigureAwait(false); + return; + } + + Suggestion suggestion = _suggestionService.CreateSuggestion(context.Member, input.Value); + DiscordMessage? message = await _suggestionService.PostSuggestionAsync(suggestion).ConfigureAwait(false); + if (message == null) + { + followUp.WithContent("Failed to post suggestion. If this issue persists, please contact ModMail."); + await context.FollowUpAsync(followUp).ConfigureAwait(false); + return; + } + + _suggestionService.UpdateSuggestionMessage(suggestion, message); + followUp.WithContent($"Your suggestion has been created and can be viewed here: {message.JumpLink}"); + await context.FollowUpAsync(followUp).ConfigureAwait(false); + } + + private bool ValidateCooldown(InteractionContext context, out DateTimeOffset expiration) + { + if (!_configurationService.TryGetGuildConfiguration(context.Guild, out GuildConfiguration? configuration)) + { + configuration = new GuildConfiguration(); + } + + if (configuration.Cooldown <= 0) + { + expiration = default; + return false; + } + + DateTimeOffset lastSuggestionTime = _suggestionService.GetLastSuggestionTime(context.Guild, context.User); + expiration = lastSuggestionTime + TimeSpan.FromSeconds(configuration.Cooldown); + return expiration > DateTimeOffset.UtcNow; + } +} diff --git a/SuggestionBot/Commands/SuggestionCommand.Block.cs b/SuggestionBot/Commands/SuggestionCommand.Block.cs new file mode 100644 index 0000000..e186742 --- /dev/null +++ b/SuggestionBot/Commands/SuggestionCommand.Block.cs @@ -0,0 +1,23 @@ +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.SlashCommands; + +namespace SuggestionBot.Commands; + +internal sealed partial class SuggestionCommand +{ + [SlashCommand("block", "Blocks a user from posting suggestions.", false)] + public async Task BlockAsync(InteractionContext context, + [Option("user", "The user to block.")] DiscordUser user, + [Option("reason", "The reason for blocking the user.")] + string? reason = null) + { + _userBlockingService.BlockUser(context.Guild, user, context.Member, reason); + + var builder = new DiscordInteractionResponseBuilder(); + builder.WithContent($"The user {user.Mention} has been blocked from posting suggestions."); + builder.AsEphemeral(); + await context.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, builder) + .ConfigureAwait(false); + } +} diff --git a/SuggestionBot/Commands/SuggestionCommand.Implement.cs b/SuggestionBot/Commands/SuggestionCommand.Implement.cs new file mode 100644 index 0000000..0ece0c2 --- /dev/null +++ b/SuggestionBot/Commands/SuggestionCommand.Implement.cs @@ -0,0 +1,43 @@ +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.SlashCommands; +using SuggestionBot.AutocompleteProviders; +using SuggestionBot.Data; + +namespace SuggestionBot.Commands; + +internal sealed partial class SuggestionCommand +{ + [SlashCommand("implement", "Implements a suggestion.", false)] + public async Task ImplementAsync(InteractionContext context, + [Option("suggestion", "The suggestion to implement."), Autocomplete(typeof(SuggestionAutocompleteProvider))] + string rawSuggestionId) + { + Suggestion? suggestion = null; + ulong guildId = context.Guild.Id; + + if (long.TryParse(rawSuggestionId, out long suggestionId) && + _suggestionService.TryGetSuggestion(guildId, suggestionId, out suggestion)) + { + } + else if (ulong.TryParse(rawSuggestionId, out ulong messageId) && + _suggestionService.TryGetSuggestion(guildId, messageId, out suggestion)) + { + } + + var response = new DiscordInteractionResponseBuilder(); + if (suggestion is null) + { + response.WithContent("The suggestion could not be found."); + } + else + { + _suggestionService.UpdateSuggestionStatus(suggestion, SuggestionStatus.Implemented, context.Member); + await _suggestionService.UpdateSuggestionAsync(suggestion).ConfigureAwait(false); + response.WithContent("The suggestion has been updated."); + } + + await context.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, response) + .ConfigureAwait(false); + } +} diff --git a/SuggestionBot/Commands/SuggestionCommand.Reject.cs b/SuggestionBot/Commands/SuggestionCommand.Reject.cs new file mode 100644 index 0000000..5e68416 --- /dev/null +++ b/SuggestionBot/Commands/SuggestionCommand.Reject.cs @@ -0,0 +1,51 @@ +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.SlashCommands; +using NLog; +using SuggestionBot.AutocompleteProviders; +using SuggestionBot.Data; + +namespace SuggestionBot.Commands; + +internal sealed partial class SuggestionCommand +{ + [SlashCommand("reject", "Rejects a suggestion.", false)] + public async Task RejectAsync(InteractionContext context, + [Option("suggestion", "The suggestion to reject."), Autocomplete(typeof(SuggestionAutocompleteProvider))] + string rawSuggestionId) + { + try + { + Suggestion? suggestion = null; + ulong guildId = context.Guild.Id; + + if (long.TryParse(rawSuggestionId, out long suggestionId) && + _suggestionService.TryGetSuggestion(guildId, suggestionId, out suggestion)) + { + } + else if (ulong.TryParse(rawSuggestionId, out ulong messageId) && + _suggestionService.TryGetSuggestion(guildId, messageId, out suggestion)) + { + } + + var response = new DiscordInteractionResponseBuilder(); + if (suggestion is null) + { + response.WithContent("The suggestion could not be found."); + } + else + { + _suggestionService.UpdateSuggestionStatus(suggestion, SuggestionStatus.Rejected, context.Member); + await _suggestionService.UpdateSuggestionAsync(suggestion).ConfigureAwait(false); + response.WithContent("The suggestion has been updated."); + } + + await context.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, response) + .ConfigureAwait(false); + } + catch (Exception exception) + { + LogManager.GetCurrentClassLogger().Error(exception); + } + } +} diff --git a/SuggestionBot/Commands/SuggestionCommand.Unblock.cs b/SuggestionBot/Commands/SuggestionCommand.Unblock.cs new file mode 100644 index 0000000..055a455 --- /dev/null +++ b/SuggestionBot/Commands/SuggestionCommand.Unblock.cs @@ -0,0 +1,21 @@ +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.SlashCommands; + +namespace SuggestionBot.Commands; + +internal sealed partial class SuggestionCommand +{ + [SlashCommand("unblock", "Unblocks a user from posting suggestions.", false)] + public async Task UnblockAsync(InteractionContext context, + [Option("user", "The user to unblock.")] DiscordUser user) + { + _userBlockingService.UnblockUser(context.Guild, user, context.Member); + + var builder = new DiscordInteractionResponseBuilder(); + builder.WithContent($"The user {user.Mention} has been unblocked from posting suggestions."); + builder.AsEphemeral(); + await context.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, builder) + .ConfigureAwait(false); + } +} diff --git a/SuggestionBot/Commands/SuggestionCommand.cs b/SuggestionBot/Commands/SuggestionCommand.cs new file mode 100644 index 0000000..0429980 --- /dev/null +++ b/SuggestionBot/Commands/SuggestionCommand.cs @@ -0,0 +1,24 @@ +using DSharpPlus.SlashCommands; +using DSharpPlus.SlashCommands.Attributes; +using SuggestionBot.Services; + +namespace SuggestionBot.Commands; + +[SlashCommandGroup("suggestion", "Manage suggestions.", false)] +[SlashRequireGuild] +internal sealed partial class SuggestionCommand : ApplicationCommandModule +{ + private readonly SuggestionService _suggestionService; + private readonly UserBlockingService _userBlockingService; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public SuggestionCommand(SuggestionService suggestionService, UserBlockingService userBlockingService) + { + _suggestionService = suggestionService; + _userBlockingService = userBlockingService; + } +} diff --git a/SuggestionBot/Configuration/GuildConfiguration.cs b/SuggestionBot/Configuration/GuildConfiguration.cs new file mode 100644 index 0000000..28f5d07 --- /dev/null +++ b/SuggestionBot/Configuration/GuildConfiguration.cs @@ -0,0 +1,43 @@ +namespace SuggestionBot.Configuration; + +/// +/// Represents the configuration for a guild. +/// +public sealed class GuildConfiguration +{ + /// + /// Gets or sets the cooldown for posting suggestions. + /// + /// The cooldown for posting suggestions. + public int Cooldown { get; set; } = 3600; + + /// + /// Gets or sets the embed color for implemented suggestions. + /// + /// The embed color for implemented suggestions. + public int ImplementedColor { get; set; } = 0x191970; + + /// + /// Gets or sets the log channel ID. + /// + /// The log channel ID. + public ulong LogChannel { get; set; } + + /// + /// Gets or sets the embed color for rejected suggestions. + /// + /// The embed color for rejected suggestions. + public int RejectedColor { get; set; } = 0xFF0000; + + /// + /// Gets or sets the channel ID for posting suggestions. + /// + /// The channel ID for posting suggestions. + public ulong SuggestionChannel { get; set; } + + /// + /// Gets or sets the embed color for new suggestions. + /// + /// The embed color for new suggestions. + public int SuggestedColor { get; set; } = 0xFFFF00; +} diff --git a/SuggestionBot/Data/BlockedUser.cs b/SuggestionBot/Data/BlockedUser.cs new file mode 100644 index 0000000..d0bf688 --- /dev/null +++ b/SuggestionBot/Data/BlockedUser.cs @@ -0,0 +1,34 @@ +namespace SuggestionBot.Data; + +internal sealed class BlockedUser +{ + /// + /// Gets or sets the ID of the guild where the user is blocked. + /// + /// The guild ID. + public ulong GuildId { get; set; } + + /// + /// Gets or sets the reason for blocking the user. + /// + /// The reason for blocking the user. + public string? Reason { get; set; } + + /// + /// Gets or sets the ID of the staff member who blocked the user. + /// + /// The ID of the staff member who blocked the user. + public ulong StaffMemberId { get; set; } + + /// + /// Gets or sets the timestamp when the user was blocked. + /// + /// The timestamp when the user was blocked. + public DateTimeOffset Timestamp { get; set; } + + /// + /// Gets or sets the ID of the blocked user. + /// + /// The ID of the blocked user. + public ulong UserId { get; set; } +} diff --git a/SuggestionBot/Data/EntityConfigurations/BlockedUserConfiguration.cs b/SuggestionBot/Data/EntityConfigurations/BlockedUserConfiguration.cs new file mode 100644 index 0000000..71d7522 --- /dev/null +++ b/SuggestionBot/Data/EntityConfigurations/BlockedUserConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace SuggestionBot.Data.EntityConfigurations; + +/// +/// Represents the configuration for the entity. +/// +internal sealed class BlockedUserConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.GuildId, e.UserId }); + + builder.Property(e => e.GuildId).IsRequired(); + builder.Property(e => e.UserId).IsRequired(); + builder.Property(e => e.Reason); + builder.Property(e => e.StaffMemberId).IsRequired(); + builder.Property(e => e.Timestamp).HasConversion().IsRequired(); + } +} diff --git a/SuggestionBot/Data/EntityConfigurations/SuggestionConfiguration.cs b/SuggestionBot/Data/EntityConfigurations/SuggestionConfiguration.cs new file mode 100644 index 0000000..c76490d --- /dev/null +++ b/SuggestionBot/Data/EntityConfigurations/SuggestionConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SuggestionBot.Data.EntityConfigurations; + +/// +/// Represents the configuration for the entity. +/// +internal sealed class SuggestionConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + + builder.Property(e => e.Id).IsRequired(); + builder.Property(e => e.AuthorId).IsRequired(); + builder.Property(e => e.Content).IsRequired(); + builder.Property(e => e.GuildId).IsRequired(); + builder.Property(e => e.MessageId).IsRequired(); + builder.Property(e => e.Status).IsRequired(); + builder.Property(e => e.Timestamp).IsRequired(); + builder.Property(e => e.StaffMemberId); + } +} diff --git a/SuggestionBot/Data/Suggestion.cs b/SuggestionBot/Data/Suggestion.cs new file mode 100644 index 0000000..e122192 --- /dev/null +++ b/SuggestionBot/Data/Suggestion.cs @@ -0,0 +1,54 @@ +namespace SuggestionBot.Data; + +/// +/// Represents a suggestion. +/// +public sealed class Suggestion +{ + /// + /// Gets or sets the ID of the author of the suggestion. + /// + /// The author ID. + public ulong AuthorId { get; set; } + + /// + /// Gets or sets the content of the suggestion. + /// + public string Content { get; set; } = string.Empty; + + /// + /// Gets or sets the ID of the guild in which the suggestion was made. + /// + /// The guild ID. + public ulong GuildId { get; set; } + + /// + /// Gets or sets the ID of the suggestion. + /// + /// The suggestion ID. + public long Id { get; set; } + + /// + /// Gets or sets the ID of the message that represents the suggestion. + /// + /// The message ID. + public ulong MessageId { get; set; } + + /// + /// Gets or sets the ID of the staff member who implemented or rejected the suggestion. + /// + /// The staff member ID. + public ulong? StaffMemberId { get; set; } + + /// + /// Gets or sets the status of the suggestion. + /// + /// The suggestion status. + public SuggestionStatus Status { get; set; } = SuggestionStatus.Suggested; + + /// + /// Gets or sets the date and time at which the suggestion was made. + /// + /// The suggestion timestamp. + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/SuggestionBot/Data/SuggestionContext.cs b/SuggestionBot/Data/SuggestionContext.cs new file mode 100644 index 0000000..f9be6ec --- /dev/null +++ b/SuggestionBot/Data/SuggestionContext.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using SuggestionBot.Data.EntityConfigurations; + +namespace SuggestionBot.Data; + +/// +/// Represents a session with the database. +/// +internal sealed class SuggestionContext : DbContext +{ + public DbSet BlockedUsers { get; internal set; } = null!; + + /// + /// Gets the set of suggestions. + /// + public DbSet Suggestions { get; internal set; } = null!; + + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlite("Data Source=data/suggestions.db"); + } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new BlockedUserConfiguration()); + modelBuilder.ApplyConfiguration(new SuggestionConfiguration()); + } +} diff --git a/SuggestionBot/Data/SuggestionStatus.cs b/SuggestionBot/Data/SuggestionStatus.cs new file mode 100644 index 0000000..8ce56e0 --- /dev/null +++ b/SuggestionBot/Data/SuggestionStatus.cs @@ -0,0 +1,11 @@ +namespace SuggestionBot.Data; + +/// +/// An enumeration of suggestion statuses. +/// +public enum SuggestionStatus +{ + Suggested, + Rejected, + Implemented +} diff --git a/SuggestionBot/Interactivity/DiscordModal.cs b/SuggestionBot/Interactivity/DiscordModal.cs new file mode 100644 index 0000000..ccdefd0 --- /dev/null +++ b/SuggestionBot/Interactivity/DiscordModal.cs @@ -0,0 +1,87 @@ +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; + +namespace SuggestionBot.Interactivity; + +/// +/// Represents a modal that can be displayed to the user. +/// +public sealed class DiscordModal +{ + private readonly DiscordClient _discordClient; + private readonly string _customId = Guid.NewGuid().ToString("N"); + private readonly Dictionary _inputs = new(); + private TaskCompletionSource _taskCompletionSource = new(); + + internal DiscordModal(string title, IEnumerable inputs, DiscordClient discordClient) + { + _discordClient = discordClient; + Title = title; + discordClient.ModalSubmitted += OnModalSubmitted; + + foreach (DiscordModalTextInput input in inputs) + _inputs.Add(input.CustomId, input); + } + + /// + /// Gets the title of this modal. + /// + /// The title. + public string Title { get; } + + /// + /// Responds with this modal to the specified interaction. + /// + /// The interaction to which the modal will respond. + /// How long to wait + /// is . + public async Task RespondToAsync(DiscordInteraction interaction, TimeSpan timeout) + { + if (interaction is null) throw new ArgumentNullException(nameof(interaction)); + + var builder = new DiscordInteractionResponseBuilder(); + builder.WithTitle(Title); + builder.WithCustomId(_customId); + + foreach ((_, DiscordModalTextInput input) in _inputs) + builder.AddComponents(input.InputComponent); + + _taskCompletionSource = new TaskCompletionSource(); + await interaction.CreateResponseAsync(InteractionResponseType.Modal, builder).ConfigureAwait(false); + + var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Token.Register(() => _taskCompletionSource.TrySetCanceled()); + if (timeout != Timeout.InfiniteTimeSpan) + cancellationTokenSource.CancelAfter(timeout); + + try + { + await _taskCompletionSource.Task.ConfigureAwait(false); + return DiscordModalResponse.Success; + } + catch (TaskCanceledException) + { + return DiscordModalResponse.Timeout; + } + } + + private Task OnModalSubmitted(DiscordClient sender, ModalSubmitEventArgs e) + { + if (e.Interaction.Data.CustomId != _customId) + return Task.CompletedTask; + + _discordClient.ModalSubmitted -= OnModalSubmitted; + + IEnumerable components = e.Interaction.Data.Components.SelectMany(c => c.Components); + IEnumerable inputComponents = components.OfType(); + foreach (TextInputComponent inputComponent in inputComponents) + { + if (_inputs.TryGetValue(inputComponent.CustomId, out DiscordModalTextInput? input)) + input.Value = inputComponent.Value; + } + + _taskCompletionSource.TrySetResult(); + return e.Interaction.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource); + } +} diff --git a/SuggestionBot/Interactivity/DiscordModalBuilder.cs b/SuggestionBot/Interactivity/DiscordModalBuilder.cs new file mode 100644 index 0000000..b92dcf4 --- /dev/null +++ b/SuggestionBot/Interactivity/DiscordModalBuilder.cs @@ -0,0 +1,95 @@ +using DSharpPlus; +using DSharpPlus.Entities; + +namespace SuggestionBot.Interactivity; + +/// +/// Represents a class which can construct a . +/// +public sealed class DiscordModalBuilder +{ + private readonly DiscordClient _discordClient; + private readonly List _inputs = new(); + private string _title = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The discord client. + public DiscordModalBuilder(DiscordClient discordClient) + { + _discordClient = discordClient; + } + + /// + /// Gets or sets the title of this modal. + /// + /// The title. + public string Title + { + get => _title; + set + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Value cannot be null or whitespace.", nameof(value)); + + _title = value; + } + } + + /// + /// Adds a new text input component to this modal. + /// + /// The label of the input. + /// The placeholder text. + /// The initial value of the input. + /// + /// if this input requires a value; otherwise, . + /// + /// The input style. + /// The minimum length of the input. + /// The maximum length of the input. + /// The which was created. + public DiscordModalTextInput AddInput(string label, string? placeholder = null, string? initialValue = null, + bool isRequired = true, TextInputStyle inputStyle = TextInputStyle.Short, int minLength = 0, int? maxLength = null) + { + var customId = Guid.NewGuid().ToString("N"); + var input = new DiscordModalTextInput(new TextInputComponent(label, customId, placeholder, initialValue, isRequired, + inputStyle, minLength, maxLength)); + _inputs.Add(input); + return input; + } + + /// + /// Sets the title of this modal. + /// + /// The title. + /// The current instance of . + public DiscordModalBuilder WithTitle(string title) + { + Title = title; + return this; + } + + /// + /// Implicitly converts a to a . + /// + /// The builder to build. + /// The converted . + /// is . + public static implicit operator DiscordModal(DiscordModalBuilder builder) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + return builder.Build(); + } + + /// + /// Builds the modal. + /// + /// The newly-constructed . + public DiscordModal Build() + { + if (string.IsNullOrWhiteSpace(_title)) throw new InvalidOperationException("Title cannot be null or whitespace."); + return new DiscordModal(Title, _inputs, _discordClient); + } +} diff --git a/SuggestionBot/Interactivity/DiscordModalResponse.cs b/SuggestionBot/Interactivity/DiscordModalResponse.cs new file mode 100644 index 0000000..102b3f5 --- /dev/null +++ b/SuggestionBot/Interactivity/DiscordModalResponse.cs @@ -0,0 +1,7 @@ +namespace SuggestionBot.Interactivity; + +public enum DiscordModalResponse +{ + Success, + Timeout +} diff --git a/SuggestionBot/Interactivity/DiscordModalTextInput.cs b/SuggestionBot/Interactivity/DiscordModalTextInput.cs new file mode 100644 index 0000000..22d7f49 --- /dev/null +++ b/SuggestionBot/Interactivity/DiscordModalTextInput.cs @@ -0,0 +1,41 @@ +using DSharpPlus.Entities; + +namespace SuggestionBot.Interactivity; + +/// +/// Represents an input component on a . +/// +public sealed class DiscordModalTextInput +{ + internal DiscordModalTextInput(TextInputComponent component) + { + InputComponent = component; + + CustomId = component.CustomId; + Value = component.Value; + } + + /// + /// Gets the label of the input. + /// + /// The label. + public string Label => InputComponent.Label; + + /// + /// Gets the placeholder of the input. + /// + /// The placeholder. + public string? Placeholder => InputComponent.Placeholder; + + /// + /// Gets the value of the input. + /// + /// The value. + public string? Value { get; internal set; } + + internal string CustomId { get; } + + internal TextInputComponent InputComponent { get; } + + internal DiscordModal? Modal { get; set; } +} diff --git a/SuggestionBot/Logging/ColorfulConsoleTarget.cs b/SuggestionBot/Logging/ColorfulConsoleTarget.cs new file mode 100644 index 0000000..c7d3898 --- /dev/null +++ b/SuggestionBot/Logging/ColorfulConsoleTarget.cs @@ -0,0 +1,39 @@ +using System.Text; +using NLog; +using NLog.Targets; +using LogLevel = NLog.LogLevel; + +namespace SuggestionBot.Logging; + +/// +/// Represents an NLog target which supports colorful output to stdout. +/// +internal sealed class ColorfulConsoleTarget : TargetWithLayout +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the log target. + public ColorfulConsoleTarget(string name) + { + Name = name; + } + + /// + protected override void Write(LogEventInfo logEvent) + { + var message = new StringBuilder(); + message.Append(Layout.Render(logEvent)); + + if (logEvent.Level == LogLevel.Warn) + Console.ForegroundColor = ConsoleColor.Yellow; + else if (logEvent.Level == LogLevel.Error || logEvent.Level == LogLevel.Fatal) + Console.ForegroundColor = ConsoleColor.Red; + + if (logEvent.Exception is { } exception) + message.Append($": {exception}"); + + Console.WriteLine(message); + Console.ResetColor(); + } +} diff --git a/SuggestionBot/Logging/LogFileTarget.cs b/SuggestionBot/Logging/LogFileTarget.cs new file mode 100644 index 0000000..c0ad1a7 --- /dev/null +++ b/SuggestionBot/Logging/LogFileTarget.cs @@ -0,0 +1,40 @@ +using System.Text; +using NLog; +using NLog.Targets; +using SuggestionBot.Services; + +namespace SuggestionBot.Logging; + +/// +/// Represents an NLog target which writes its output to a log file on disk. +/// +internal sealed class LogFileTarget : TargetWithLayout +{ + private readonly LoggingService _loggingService; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the log target. + /// The . + public LogFileTarget(string name, LoggingService loggingService) + { + _loggingService = loggingService; + Name = name; + } + + /// + protected override void Write(LogEventInfo logEvent) + { + _loggingService.ArchiveLogFilesAsync(false).GetAwaiter().GetResult(); + + using FileStream stream = _loggingService.LogFile.Open(FileMode.Append, FileAccess.Write); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(Layout.Render(logEvent)); + + if (logEvent.Exception is { } exception) + writer.Write($": {exception}"); + + writer.WriteLine(); + } +} diff --git a/SuggestionBot/Program.cs b/SuggestionBot/Program.cs new file mode 100644 index 0000000..1f5fee7 --- /dev/null +++ b/SuggestionBot/Program.cs @@ -0,0 +1,35 @@ +using DSharpPlus; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NLog.Extensions.Logging; +using SuggestionBot.Data; +using SuggestionBot.Services; +using X10D.Hosting.DependencyInjection; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Configuration.AddJsonFile("data/config.json", true, true); + +builder.Logging.ClearProviders(); +builder.Logging.AddNLog(); + +builder.Services.AddSingleton(new DiscordClient(new DiscordConfiguration +{ + Token = Environment.GetEnvironmentVariable("DISCORD_TOKEN"), + LoggerFactory = new NLogLoggerFactory(), + Intents = DiscordIntents.AllUnprivileged | DiscordIntents.GuildMembers | DiscordIntents.GuildMessages +})); + +builder.Services.AddHostedService(); + +builder.Services.AddDbContextFactory(); +builder.Services.AddHostedService(); +builder.Services.AddHostedSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedSingleton(); +builder.Services.AddHostedSingleton(); +builder.Services.AddHostedSingleton(); + +IHost app = builder.Build(); +await app.RunAsync(); diff --git a/SuggestionBot/Services/BotService.cs b/SuggestionBot/Services/BotService.cs new file mode 100644 index 0000000..c5c7358 --- /dev/null +++ b/SuggestionBot/Services/BotService.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using DSharpPlus; +using DSharpPlus.SlashCommands; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SuggestionBot.Commands; + +namespace SuggestionBot.Services; + +/// +/// Represents a service which manages the bot's Discord connection. +/// +internal sealed class BotService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly DiscordClient _discordClient; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The service provider. + /// The Discord client. + public BotService(ILogger logger, IServiceProvider serviceProvider, DiscordClient discordClient) + { + _logger = logger; + _serviceProvider = serviceProvider; + _discordClient = discordClient; + + var attribute = typeof(BotService).Assembly.GetCustomAttribute(); + Version = attribute?.InformationalVersion ?? "Unknown"; + } + + /// + /// Gets the date and time at which the bot was started. + /// + /// The start timestamp. + public DateTimeOffset StartedAt { get; private set; } + + /// + /// Gets the bot version. + /// + /// The bot version. + public string Version { get; } + + /// + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _discordClient.DisconnectAsync().ConfigureAwait(false); + await base.StopAsync(cancellationToken).ConfigureAwait(false); + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + StartedAt = DateTimeOffset.UtcNow; + _logger.LogInformation("SuggestionBot v{Version} is starting...", Version); + + SlashCommandsExtension? slashCommands = _discordClient.UseSlashCommands(new SlashCommandsConfiguration + { + Services = _serviceProvider + }); + + slashCommands.RegisterCommands(); + slashCommands.RegisterCommands(); + slashCommands.RegisterCommands(); + + return _discordClient.ConnectAsync(); + } +} diff --git a/SuggestionBot/Services/ConfigurationService.cs b/SuggestionBot/Services/ConfigurationService.cs new file mode 100644 index 0000000..d4f442c --- /dev/null +++ b/SuggestionBot/Services/ConfigurationService.cs @@ -0,0 +1,60 @@ +using System.Diagnostics.CodeAnalysis; +using DSharpPlus.Entities; +using Microsoft.Extensions.Configuration; +using SuggestionBot.Configuration; + +namespace SuggestionBot.Services; + +/// +/// Represents a service which can read configuration values from a file. +/// +internal sealed class ConfigurationService +{ + private readonly IConfiguration _configuration; + + /// + /// Initializes a new instance of the class. + /// + /// The app configuration. + public ConfigurationService(IConfiguration configuration) + { + _configuration = configuration; + } + + /// + /// Gets the bot configuration for the specified guild. + /// + /// The guild whose configuration to retrieve. + /// + /// A containing the configuration, or if no configuration is + /// defined. + /// + /// is . + public GuildConfiguration? GetGuildConfiguration(DiscordGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + return _configuration.GetSection(guild.Id.ToString())?.Get(); + } + + /// + /// Attempts to get the bot configuration for the specified guild. + /// + /// The guild whose configuration to retrieve. + /// + /// When this method returns, contains the for the specified guild, or + /// if no configuration is defined. + /// + /// + /// if the specified guild has a configuration; otherwise, . + /// + public bool TryGetGuildConfiguration(DiscordGuild guild, [NotNullWhen(true)] out GuildConfiguration? configuration) + { + configuration = null; + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (guild is null) return false; + + configuration = GetGuildConfiguration(guild); + return configuration is not null; + } +} diff --git a/SuggestionBot/Services/DatabaseService.cs b/SuggestionBot/Services/DatabaseService.cs new file mode 100644 index 0000000..720fcb3 --- /dev/null +++ b/SuggestionBot/Services/DatabaseService.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SuggestionBot.Data; + +namespace SuggestionBot.Services; + +/// +/// Represents a service which connects to the SuggestionBot database. +/// +internal sealed class DatabaseService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IDbContextFactory _dbContextFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The DbContext factory. + public DatabaseService(ILogger logger, IDbContextFactory dbContextFactory) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + return CreateDatabaseAsync(); + } + + private async Task CreateDatabaseAsync() + { + Directory.CreateDirectory("data"); + + await using SuggestionContext context = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); + + _logger.LogInformation("Creating database"); + await context.Database.EnsureCreatedAsync().ConfigureAwait(false); + + _logger.LogInformation("Applying migrations"); + await context.Database.MigrateAsync().ConfigureAwait(false); + } +} diff --git a/SuggestionBot/Services/DiscordLogService.cs b/SuggestionBot/Services/DiscordLogService.cs new file mode 100644 index 0000000..f41da35 --- /dev/null +++ b/SuggestionBot/Services/DiscordLogService.cs @@ -0,0 +1,126 @@ +using System.Diagnostics.CodeAnalysis; +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using SuggestionBot.Configuration; + +namespace SuggestionBot.Services; + +/// +/// Represents a service which can send embeds to a log channel. +/// +internal sealed class DiscordLogService : BackgroundService +{ + private readonly IConfiguration _configuration; + private readonly DiscordClient _discordClient; + private readonly ConfigurationService _configurationService; + private readonly Dictionary _logChannels = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The configuration. + /// The Discord client. + /// The configuration service. + public DiscordLogService(IConfiguration configuration, DiscordClient discordClient, + ConfigurationService configurationService) + { + _configuration = configuration; + _discordClient = discordClient; + _configurationService = configurationService; + } + + /// + /// Sends an embed to the log channel of the specified guild. + /// + /// The guild whose log channel in which to post the embed. + /// The embed to post. + /// + /// or is . + /// + public async Task LogAsync(DiscordGuild guild, DiscordEmbed embed) + { + if (guild is null) + { + throw new ArgumentNullException(nameof(guild)); + } + + if (embed is null) + { + throw new ArgumentNullException(nameof(embed)); + } + + if (_logChannels.TryGetValue(guild, out DiscordChannel? logChannel)) + { + if (embed.Timestamp is null) + { + embed = new DiscordEmbedBuilder(embed).WithTimestamp(DateTimeOffset.UtcNow); + } + + await logChannel.SendMessageAsync(embed).ConfigureAwait(false); + } + } + + /// + /// Gets the log channel for a specified guild. + /// + /// The guild whose log channel to retrieve. + /// + /// When this method returns, contains the log channel; or if no such channel is found. + /// + /// if the log channel was successfully found; otherwise, . + /// is . + public bool TryGetLogChannel(DiscordGuild guild, [NotNullWhen(true)] out DiscordChannel? channel) + { + if (guild is null) + { + throw new ArgumentNullException(nameof(guild)); + } + + if (!_configurationService.TryGetGuildConfiguration(guild, out GuildConfiguration? configuration)) + { + channel = null; + return false; + } + + if (!_logChannels.TryGetValue(guild, out channel)) + { + channel = guild.GetChannel(configuration.LogChannel); + _logChannels.Add(guild, channel); + } + + return channel is not null; + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _discordClient.GuildAvailable += OnGuildAvailable; + return Task.CompletedTask; + } + + private async Task OnGuildAvailable(DiscordClient sender, GuildCreateEventArgs e) + { + var logChannel = _configuration.GetSection(e.Guild.Id.ToString())?.GetSection("logChannel")?.Get(); + if (!logChannel.HasValue) + { + return; + } + + try + { + DiscordChannel? channel = await _discordClient.GetChannelAsync(logChannel.Value).ConfigureAwait(false); + + if (channel is not null) + { + _logChannels[e.Guild] = channel; + } + } + catch + { + // ignored + } + } +} diff --git a/SuggestionBot/Services/LoggingService.cs b/SuggestionBot/Services/LoggingService.cs new file mode 100644 index 0000000..66e25c2 --- /dev/null +++ b/SuggestionBot/Services/LoggingService.cs @@ -0,0 +1,94 @@ +using System.IO.Compression; +using Microsoft.Extensions.Hosting; +using NLog; +using NLog.Config; +using NLog.LayoutRenderers; +using NLog.Layouts; +using SuggestionBot.Logging; +using LogLevel = NLog.LogLevel; + +namespace SuggestionBot.Services; + +/// +/// Represents a class which implements a logging service that supports multiple log targets. +/// +/// +/// This class implements a logging structure similar to that of Minecraft, where historic logs are compressed to a .gz and +/// the latest log is found in logs/latest.log. +/// +internal sealed class LoggingService : BackgroundService +{ + private const string LogFileName = "logs/latest.log"; + + /// + /// Initializes a new instance of the class. + /// + public LoggingService() + { + LogFile = new FileInfo(LogFileName); + } + + /// + /// Gets or sets the log file. + /// + /// The log file. + public FileInfo LogFile { get; set; } + + /// + /// Archives any existing log files. + /// + public async Task ArchiveLogFilesAsync(bool archiveToday = true) + { + var latestFile = new FileInfo(LogFile.FullName); + if (!latestFile.Exists) return; + + DateTime lastWrite = latestFile.LastWriteTime; + string lastWriteDate = $"{lastWrite:yyyy-MM-dd}"; + var version = 0; + string name; + + if (!archiveToday && lastWrite.Date == DateTime.Today) return; + + while (File.Exists(name = Path.Combine(LogFile.Directory!.FullName, $"{lastWriteDate}-{++version}.log.gz"))) + { + // body ignored + } + + await using (FileStream source = latestFile.OpenRead()) + { + await using FileStream output = File.Create(name); + await using var gzip = new GZipStream(output, CompressionMode.Compress); + await source.CopyToAsync(gzip); + } + + latestFile.Delete(); + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + LogFile.Directory?.Create(); + + LayoutRenderer.Register("TheTime", info => info.TimeStamp.ToString("HH:mm:ss")); + LayoutRenderer.Register("ServiceName", info => info.LoggerName); + + Layout? layout = Layout.FromString("[${TheTime} ${level:uppercase=true}] [${ServiceName}] ${message}"); + var config = new LoggingConfiguration(); + var fileLogger = new LogFileTarget("FileLogger", this) {Layout = layout}; + var consoleLogger = new ColorfulConsoleTarget("ConsoleLogger") {Layout = layout}; + +#if DEBUG + LogLevel minLevel = LogLevel.Debug; +#else + LogLevel minLevel = LogLevel.Info; + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ENABLE_DEBUG_LOGGING"))) + minLevel = LogLevel.Debug; +#endif + config.AddRule(minLevel, LogLevel.Fatal, consoleLogger); + config.AddRule(minLevel, LogLevel.Fatal, fileLogger); + + LogManager.Configuration = config; + + return ArchiveLogFilesAsync(); + } +} diff --git a/SuggestionBot/Services/SuggestionService.cs b/SuggestionBot/Services/SuggestionService.cs new file mode 100644 index 0000000..181cc94 --- /dev/null +++ b/SuggestionBot/Services/SuggestionService.cs @@ -0,0 +1,362 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; +using Humanizer; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SuggestionBot.Configuration; +using SuggestionBot.Data; +using X10D.DSharpPlus; + +namespace SuggestionBot.Services; + +internal sealed class SuggestionService : BackgroundService +{ + private readonly ConcurrentDictionary> _suggestions = new(); + private readonly ILogger _logger; + private readonly IDbContextFactory _contextFactory; + private readonly DiscordClient _discordClient; + private readonly ConfigurationService _configurationService; + private readonly DiscordLogService _logService; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + /// The . + public SuggestionService(ILogger logger, + IDbContextFactory contextFactory, + DiscordClient discordClient, + ConfigurationService configurationService, + DiscordLogService logService) + { + _logger = logger; + _contextFactory = contextFactory; + _discordClient = discordClient; + _configurationService = configurationService; + _logService = logService; + } + + /// + /// Creates a new suggestion. + /// + /// The member who created the suggestion. + /// The suggestion content. + /// The created suggestion. + /// + /// or is . + /// + public Suggestion CreateSuggestion(DiscordMember member, string content) + { + if (member == null) throw new ArgumentNullException(nameof(member)); + if (content == null) throw new ArgumentNullException(nameof(content)); + + ulong guildId = member.Guild.Id; + + var suggestion = new Suggestion + { + AuthorId = member.Id, + Content = content, + GuildId = guildId + }; + + _suggestions.AddOrUpdate(guildId, new List { suggestion }, (_, suggestions) => + { + suggestions.Add(suggestion); + return suggestions; + }); + + using SuggestionContext context = _contextFactory.CreateDbContext(); + context.Suggestions.Add(suggestion); + context.SaveChanges(); + + _logger.LogInformation("Created suggestion {SuggestionId} in {Guild}.", suggestion.Id, member.Guild); + return suggestion; + } + + /// + /// Gets the last time a user made a suggestion in the specified guild. + /// + /// The guild. + /// The user. + /// The last time the user made a suggestion. + public DateTimeOffset GetLastSuggestionTime(DiscordGuild guild, DiscordUser user) + { + if (!_suggestions.TryGetValue(guild.Id, out List? suggestions)) + { + return DateTimeOffset.MinValue; + } + + return suggestions.Where(s => s.GuildId == guild.Id && s.AuthorId == user.Id).Max(s => s.Timestamp); + } + + /// + /// Gets the suggestions for the specified guild. + /// + /// The guild. + /// Whether to only return open suggestions. + /// A read-only view of the suggestions. + /// is . + public IReadOnlyList GetSuggestions(DiscordGuild guild, bool onlyReturnOpen = false) + { + if (guild is null) throw new ArgumentNullException(nameof(guild)); + + if (!_suggestions.TryGetValue(guild.Id, out List? suggestions)) + { + using SuggestionContext context = _contextFactory.CreateDbContext(); + suggestions = context.Suggestions.Where(s => s.GuildId == guild.Id).ToList(); + _suggestions.TryAdd(guild.Id, suggestions); + } + + if (onlyReturnOpen) + { + suggestions = suggestions.Where(s => s.Status == SuggestionStatus.Suggested).ToList(); + } + + return suggestions.AsReadOnly(); + } + + /// + /// Posts a suggestion to the suggestion channel of the guild in which it was made. + /// + /// The suggestion to post. + /// is . + /// + /// . is not a valid value. + /// + public async Task PostSuggestionAsync(Suggestion suggestion) + { + if (suggestion is null) throw new ArgumentNullException(nameof(suggestion)); + if (!_discordClient.Guilds.TryGetValue(suggestion.GuildId, out DiscordGuild? guild)) + { + _logger.LogTrace("Guild {GuildId} does not exist", suggestion.GuildId); + return null; + } + + if (!_configurationService.TryGetGuildConfiguration(guild, out GuildConfiguration? configuration)) + { + _logger.LogTrace("{Guild} is not configured", guild); + return null; + } + + if (guild.GetChannel(configuration.SuggestionChannel) is not { } channel) + { + _logger.LogTrace("Channel {ChannelId} does not exist in {Guild}", configuration.SuggestionChannel, guild); + return null; + } + + DiscordMessage message = await channel.SendMessageAsync("...").ConfigureAwait(false); + UpdateSuggestionMessage(suggestion, message); + await UpdateSuggestionAsync(suggestion).ConfigureAwait(false); + + await message.CreateReactionAsync(DiscordEmoji.FromUnicode("👍")).ConfigureAwait(false); + await message.CreateReactionAsync(DiscordEmoji.FromUnicode("👎")).ConfigureAwait(false); + + return message; + } + + /// + /// Attempts to get a suggestion by its message ID. + /// + /// The ID of the guild in which the suggestion was made. + /// The message ID of the suggestion. + /// + /// When this method returns, contains the suggestion if it was found; otherwise, . + /// + /// if the suggestion was found; otherwise, . + public bool TryGetSuggestion(ulong guildId, ulong messageId, [NotNullWhen(true)] out Suggestion? suggestion) + { + if (_suggestions.TryGetValue(guildId, out List? suggestions)) + { + suggestion = suggestions.FirstOrDefault(s => s.MessageId == messageId); + return suggestion != null; + } + + suggestion = null; + return false; + } + + /// + /// Attempts to get a suggestion by its ID. + /// + /// The ID of the guild in which the suggestion was made. + /// The ID of the suggestion. + /// + /// When this method returns, contains the suggestion if it was found; otherwise, . + /// + /// if the suggestion was found; otherwise, . + public bool TryGetSuggestion(ulong guildId, long id, [NotNullWhen(true)] out Suggestion? suggestion) + { + if (_suggestions.TryGetValue(guildId, out List? suggestions)) + { + suggestion = suggestions.FirstOrDefault(s => s.Id == id); + return suggestion != null; + } + + suggestion = null; + return false; + } + + /// + /// Updates the message of a suggestion. + /// + /// The suggestion to update. + /// + /// or is . + /// + public async Task UpdateSuggestionAsync(Suggestion suggestion) + { + if (suggestion is null) throw new ArgumentNullException(nameof(suggestion)); + if (suggestion.MessageId == 0) return; + + if (!_discordClient.Guilds.TryGetValue(suggestion.GuildId, out DiscordGuild? guild)) return; + if (!_configurationService.TryGetGuildConfiguration(guild, out GuildConfiguration? configuration)) return; + DiscordUser author = await _discordClient.GetUserAsync(suggestion.AuthorId); + + var embed = new DiscordEmbedBuilder(); + string authorName = author.GetUsernameWithDiscriminator(); + embed.WithAuthor($"Suggestion from {authorName}", iconUrl: author.GetAvatarUrl(ImageFormat.Png)); + embed.WithThumbnail(guild.GetIconUrl(ImageFormat.Png)); + embed.WithColor(suggestion.Status switch + { + SuggestionStatus.Suggested => configuration.SuggestedColor, + SuggestionStatus.Rejected => configuration.RejectedColor, + SuggestionStatus.Implemented => configuration.ImplementedColor, + _ => throw new ArgumentOutOfRangeException(nameof(suggestion), suggestion.Status, null) + }); + + embed.WithDescription(suggestion.Content); + embed.WithFooter($"Suggestion {suggestion.Id}"); + + string emoji = suggestion.Status switch + { + SuggestionStatus.Suggested => "🗳️", + SuggestionStatus.Rejected => "❌", + SuggestionStatus.Implemented => "✅", + _ => throw new ArgumentOutOfRangeException(nameof(suggestion), suggestion.Status, null) + }; + + embed.AddField("Status", $"{emoji} **{suggestion.Status.Humanize(LetterCasing.AllCaps)}**", true); + + DiscordChannel? channel = guild.GetChannel(configuration.SuggestionChannel); + if (channel is null) return; + + DiscordMessage? message = await channel.GetMessageAsync(suggestion.MessageId).ConfigureAwait(false); + if (message is null) return; + + await message.ModifyAsync(m => m.Embed = embed).ConfigureAwait(false); + + if (suggestion.Status != SuggestionStatus.Suggested) + { + await message.DeleteAllReactionsAsync().ConfigureAwait(false); + } + } + + /// + /// Updates the message of a suggestion. + /// + /// The suggestion to update. + /// The new message of the suggestion. + /// + /// or is . + /// + public void UpdateSuggestionMessage(Suggestion suggestion, DiscordMessage message) + { + if (suggestion is null) throw new ArgumentNullException(nameof(suggestion)); + if (message is null) throw new ArgumentNullException(nameof(message)); + if (suggestion.MessageId != 0) return; + + suggestion.MessageId = message.Id; + + using SuggestionContext context = _contextFactory.CreateDbContext(); + context.Suggestions.Update(suggestion); + context.SaveChanges(); + } + + /// + /// Updates the status of a suggestion. + /// + /// The suggestion to update. + /// The new status of the suggestion. + /// The staff member who updated the suggestion. + /// is . + public void UpdateSuggestionStatus(Suggestion suggestion, SuggestionStatus status, DiscordMember staffMember) + { + if (suggestion is null) throw new ArgumentNullException(nameof(suggestion)); + + suggestion.Status = status; + suggestion.StaffMemberId = staffMember.Id; + + using SuggestionContext context = _contextFactory.CreateDbContext(); + context.Suggestions.Update(suggestion); + context.SaveChanges(); + + var embed = new DiscordEmbedBuilder(); + embed.WithColor(DiscordColor.CornflowerBlue); + embed.WithTitle("Suggestion Updated"); + embed.WithDescription($"Suggestion {suggestion.Id} has been updated by {staffMember.Mention}."); + embed.AddField("Status", $"{status.Humanize(LetterCasing.AllCaps)}", true); + if (_configurationService.TryGetGuildConfiguration(staffMember.Guild, out GuildConfiguration? configuration)) + { + ulong suggestionChannelId = configuration.SuggestionChannel; + var url = $"https://discord.com/channels/{suggestion.GuildId}/{suggestionChannelId}/{suggestion.MessageId}"; + embed.AddField("View Suggestion", $"[Click here]({url})"); + } + + _ = _logService.LogAsync(staffMember.Guild, embed); + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _discordClient.GuildAvailable += OnGuildAvailable; + Load(); + return Task.CompletedTask; + } + + private Task OnGuildAvailable(DiscordClient sender, GuildCreateEventArgs args) + { + DiscordGuild guild = args.Guild; + + if (_configurationService.TryGetGuildConfiguration(guild, out GuildConfiguration? configuration)) + { + DiscordChannel? logChannel = guild.GetChannel(configuration.LogChannel); + if (logChannel is null) + { + _logger.LogWarning("Log channel {LogChannel} does not exist in {Guild}", configuration.LogChannel, + guild); + } + else + { + _logger.LogInformation("{Channel} found in {Guild}", logChannel, guild); + } + } + else + { + _logger.LogWarning("{Guild} is not configured!", guild); + } + + return Task.CompletedTask; + } + + private void Load() + { + using SuggestionContext context = _contextFactory.CreateDbContext(); + foreach (IGrouping group in context.Suggestions.GroupBy(s => s.GuildId)) + { + _suggestions.AddOrUpdate(group.Key, _ => group.ToList(), (_, suggestions) => + { + suggestions.AddRange(group); + return suggestions; + }); + } + + _logger.LogInformation("Loaded {SuggestionCount} suggestions", _suggestions.Sum(s => s.Value.Count)); + } +} diff --git a/SuggestionBot/Services/UserBlockingService.cs b/SuggestionBot/Services/UserBlockingService.cs new file mode 100644 index 0000000..2e624b4 --- /dev/null +++ b/SuggestionBot/Services/UserBlockingService.cs @@ -0,0 +1,174 @@ +using System.Collections.Concurrent; +using DSharpPlus.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SuggestionBot.Data; +using X10D.DSharpPlus; + +namespace SuggestionBot.Services; + +internal sealed class UserBlockingService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IDbContextFactory _dbContextFactory; + private readonly DiscordLogService _logService; + + private readonly ConcurrentDictionary> _blockedUsers = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The database context factory. + /// The log service. + public UserBlockingService(ILogger logger, + IDbContextFactory dbContextFactory, + DiscordLogService logService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _logService = logService; + } + + /// + /// Blocks a user from using the bot in the specified guild. + /// + /// The member to block. + /// The staff member who blocked the user. + /// The reason for blocking the user. + public void BlockUser(DiscordMember member, DiscordMember staffMember, string? reason) + { + BlockUser(member.Guild, member, staffMember, reason); + } + + /// + /// Blocks a user from using the bot in the specified guild. + /// + /// The guild. + /// The user to block. + /// The staff member who blocked the user. + /// The reason for blocking the user. + public void BlockUser(DiscordGuild guild, DiscordUser user, DiscordMember staffMember, string? reason) + { + _blockedUsers.AddOrUpdate(guild.Id, _ => new List { user.Id }, (_, list) => + { + list.Add(user.Id); + return list; + }); + + using SuggestionContext context = _dbContextFactory.CreateDbContext(); + context.BlockedUsers.Add(new BlockedUser + { + GuildId = guild.Id, + UserId = user.Id, + StaffMemberId = staffMember.Id, + Reason = reason, + Timestamp = DateTimeOffset.UtcNow + }); + + context.SaveChanges(); + + _logger.LogInformation("{StaffMember} blocked user {user} in {Guild}. Reason: {Reason}", staffMember, user, + guild, reason ?? "None"); + + var embed = new DiscordEmbedBuilder(); + embed.WithAuthor(user); + embed.WithTitle("User Blocked"); + embed.WithColor(DiscordColor.Red); + embed.WithTimestamp(DateTimeOffset.UtcNow); + embed.WithDescription($"The user {user.Mention} has been blocked from posting suggestions."); + embed.AddField("Staff Member", staffMember.Mention, true); + embed.AddField("Reason", reason ?? "None", true); + + _ = _logService.LogAsync(guild, embed); + } + + /// + /// Returns a value indicating whether the specified member is blocked from using the bot. + /// + /// The member. + /// if the member is blocked; otherwise, . + public bool IsUserBlocked(DiscordMember member) + { + return IsUserBlocked(member.Guild, member); + } + + /// + /// Returns a value indicating whether the specified user is blocked from using the bot in the specified guild. + /// + /// The guild. + /// The user. + /// if the user is blocked; otherwise, . + public bool IsUserBlocked(DiscordGuild guild, DiscordUser user) + { + return _blockedUsers.TryGetValue(guild.Id, out List? blockedUsers) && blockedUsers.Contains(user.Id); + } + + /// + /// Unblocks a user from using the bot in the specified guild. + /// + /// The member to unblock. + /// The staff member who unblocked the user. + public void UnblockUser(DiscordMember member, DiscordMember staffMember) + { + UnblockUser(member.Guild, member, staffMember); + } + + /// + /// Unblocks a user from using the bot in the specified guild. + /// + /// The guild. + /// The user to unblock. + /// The staff member who unblocked the user. + public void UnblockUser(DiscordGuild guild, DiscordUser user, DiscordMember staffMember) + { + if (_blockedUsers.TryGetValue(guild.Id, out List? blockedUsers)) + { + blockedUsers.Remove(user.Id); + } + + using SuggestionContext context = _dbContextFactory.CreateDbContext(); + BlockedUser? blockedUser = context.BlockedUsers.Find(guild.Id, user.Id); + if (blockedUser is not null) + { + context.BlockedUsers.Remove(blockedUser); + context.SaveChanges(); + } + + _logger.LogInformation("{StaffMember} unblocked user {user} in {Guild}", staffMember, user, guild); + + var embed = new DiscordEmbedBuilder(); + embed.WithAuthor(user); + embed.WithTitle("User Unblocked"); + embed.WithColor(DiscordColor.Green); + embed.WithTimestamp(DateTimeOffset.UtcNow); + embed.WithDescription($"The user {user.Mention} has been unblocked from posting suggestions."); + embed.AddField("Staff Member", staffMember.Mention, true); + + _ = _logService.LogAsync(guild, embed); + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + Load(); + return Task.CompletedTask; + } + + private void Load() + { + using SuggestionContext context = _dbContextFactory.CreateDbContext(); + foreach (IGrouping group in context.BlockedUsers.GroupBy(u => u.GuildId)) + { + IEnumerable userIds = group.Select(u => u.UserId); + _blockedUsers.AddOrUpdate(group.Key, _ => userIds.ToList(), (_, list) => + { + list.AddRange(userIds); + return list; + }); + } + + _logger.LogInformation("Loaded {Count} blocked users", _blockedUsers.Sum(kvp => kvp.Value.Count)); + } +} diff --git a/SuggestionBot/SuggestionBot.csproj b/SuggestionBot/SuggestionBot.csproj new file mode 100644 index 0000000..67d81e2 --- /dev/null +++ b/SuggestionBot/SuggestionBot.csproj @@ -0,0 +1,57 @@ + + + + Exe + net7.0 + enable + enable + Linux + 1.0.0 + + + + true + + + + $(VersionPrefix)-$(VersionSuffix) + $(VersionPrefix).0 + $(VersionPrefix).0 + + + + $(VersionPrefix)-$(VersionSuffix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + + + + $(VersionPrefix) + $(VersionPrefix).0 + $(VersionPrefix).0 + + + + + + + + + + + + + + + + + + + docker-compose.yml + + + Dockerfile + + + + diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..4377fc9 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,67 @@ +# Slash Commands + +Below is an outline of every slash command currently implemented in SuggestionBot, along with their descriptions and +parameters. + +The primary command used by the public is `/suggest`. + +### `/suggest` + +Allows a user to submit a suggestion. This presents the user with a modal to fill out the suggestion details. + +| Parameter | Required | Type | Description | +|:----------|:---------|:-----|:------------| +| - | - | - | - | + +## User Blocking + +Users have the ability to post suggestions. However, this opens up the potential to be abused. If a user is +sending too many frivolous suggestions, their suggestions can be blocked so that their suggestions are no longer +acknowledged. + +### `/suggestion block` + +Prevent a user from sending suggestions. + +| Parameter | Required | Type | Description | +|:----------|:---------|:-------------------|:-------------------------------------| +| user | ✅ Yes | User mention or ID | The user whose suggestions to block. | +| reason | ❌ No | String | The reason for the block. | + +### `/suggestion unblock` + +Allow a user to send suggestions again. + +| Parameter | Required | Type | Description | +|:----------|:---------|:-------------------|:---------------------------------------| +| user | ✅ Yes | User mention or ID | The user whose suggestions to unblock. | + +## Implementing and Rejecting Suggestions + +### `/suggestion implement` + +Mark a suggestion as implemented. + +| Parameter | Required | Type | Description | +|:-----------|:---------|:-------------------------|:-----------------------------| +| suggestion | ✅ Yes | Suggestion or Message ID | The suggestion to implement. | + +### `/suggestion reject` + +Mark a suggestion as rejected. + +| Parameter | Required | Type | Description | +|:-----------|:---------|:-------------------------|:--------------------------| +| suggestion | ✅ Yes | Suggestion or Message ID | The suggestion to reject. | + +# Ephemeral responses + +Below is a table outlining all the commands and whether or not they have ephemeral responses. + +| Command | Ephemeral Response | +|:------------------------|:-------------------| +| `/suggest` | ❌ No | +| `/suggestion implement` | ❌ No | +| `/suggestion reject` | ❌ No | +| `/suggestion block` | ✅ Yes | +| `/suggestion unblock` | ✅ Yes | diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..8fac44b --- /dev/null +++ b/config.example.json @@ -0,0 +1,7 @@ +{ + "0": { + "logChannel": 0, + "suggestionChannel": 0 + } + } + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d605e5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.9' +services: + suggestionbot: + container_name: SuggestionBot + pull_policy: build + build: . + volumes: + - type: bind + source: /var/log/brackeysbot/suggestionbot + target: /app/logs + - type: bind + source: /etc/brackeysbot/suggestionbot + target: /app/data + restart: always + environment: + - DISCORD_TOKEN=${DISCORD_TOKEN} diff --git a/global.json b/global.json new file mode 100644 index 0000000..7cd6a1f --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "7.0.0", + "rollForward": "latestMajor", + "allowPrerelease": true + } +} \ No newline at end of file diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dfa649c2ecacdff7de5e4802ce8c999ec05c439a GIT binary patch literal 48048 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

|E$*d&@u^X5Q}8RXsM^B}=8CDn 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 literal 0 HcmV?d00001