From ad58b3ed3d27f0adeeb84b0b4715038b19d55923 Mon Sep 17 00:00:00 2001 From: Alejandro Lopez-Lago Date: Wed, 9 Sep 2020 20:23:53 +0000 Subject: [PATCH 1/2] Merged PR 122: v3 update: remove explicit text/voice channel pairing in config file, and several improvements - Text and voice channels can be paired dynamically. This means that a server owner can now add the bot directly to their channel without contacting the bot owner. - The bot now stores guild/text channel settings in a database. The commands !pairChannels and !unpairChannel will pair and unpair text and voice channels together, respectively. - "supportedChannels" is now a deprecated field as a result. It is only used by the command !mapConfigToDatabase to add the channel pairings to the database. Only bot owners can run this command. - Add !checkPermissions to see if the existing channel has the right permissions. Only server administrators can run this, and this can be used to troubleshoot issues. - The buzz pattern now ignores whitespace. This means that messages like " buzz " will now be recognized as a buzz. - Fixed a bug where calling !score in a channel where the bot lacked the Embed Link permissions would throw an exception. - The bot no longer waits to mute the reader before prompting the user --- APACHE_LICENSE.txt | 204 ++++++++++ QuizBowlDiscordScoreTracker/Bot.cs | 57 ++- .../BotConfiguration.cs | 51 +-- .../Commands/AdminCommands.cs | 78 ++++ .../Commands/BotCommandBase.cs | 17 +- .../Commands/BotCommandHandler.cs | 275 +++++++++++++- .../Commands/BotOwnerCommands.cs | 27 ++ .../Commands/GeneralCommands.cs | 8 +- .../Commands/HelpCommand.cs | 32 +- .../Commands/ReaderCommands.cs | 8 +- .../Database/BotConfigurationContext.cs | 66 ++++ .../Database/DatabaseAction.cs | 191 ++++++++++ .../Database/GuildSetting.cs | 15 + .../Database/IDatabaseActionFactory.cs | 7 + .../Database/SqliteDatabaseActionFactory.cs | 34 ++ .../Database/TextChannelSetting.cs | 17 + .../20200829064343_InitialCreate.Designer.cs | 68 ++++ .../20200829064343_InitialCreate.cs | 69 ++++ .../BotConfigurationContextModelSnapshot.cs | 66 ++++ .../QuizBowlDiscordScoreTracker.csproj | 19 +- QuizBowlDiscordScoreTracker/Verify.cs | 15 + QuizBowlDiscordScoreTracker/Web/Startup.cs | 3 + .../BotCommandHandlerTests.cs | 348 ++++++++++++++++-- .../DatabaseActionTests.cs | 117 ++++++ .../IGuildTextChannel.cs | 9 + .../InMemoryBotConfigurationContextFactory.cs | 57 +++ ...uizBowlDiscordScoreTrackerUnitTests.csproj | 10 +- README.md | 11 +- 28 files changed, 1770 insertions(+), 109 deletions(-) create mode 100644 APACHE_LICENSE.txt create mode 100644 QuizBowlDiscordScoreTracker/Commands/AdminCommands.cs create mode 100644 QuizBowlDiscordScoreTracker/Commands/BotOwnerCommands.cs create mode 100644 QuizBowlDiscordScoreTracker/Database/BotConfigurationContext.cs create mode 100644 QuizBowlDiscordScoreTracker/Database/DatabaseAction.cs create mode 100644 QuizBowlDiscordScoreTracker/Database/GuildSetting.cs create mode 100644 QuizBowlDiscordScoreTracker/Database/IDatabaseActionFactory.cs create mode 100644 QuizBowlDiscordScoreTracker/Database/SqliteDatabaseActionFactory.cs create mode 100644 QuizBowlDiscordScoreTracker/Database/TextChannelSetting.cs create mode 100644 QuizBowlDiscordScoreTracker/Migrations/20200829064343_InitialCreate.Designer.cs create mode 100644 QuizBowlDiscordScoreTracker/Migrations/20200829064343_InitialCreate.cs create mode 100644 QuizBowlDiscordScoreTracker/Migrations/BotConfigurationContextModelSnapshot.cs create mode 100644 QuizBowlDiscordScoreTracker/Verify.cs create mode 100644 QuizBowlDiscordScoreTrackerUnitTests/DatabaseActionTests.cs create mode 100644 QuizBowlDiscordScoreTrackerUnitTests/IGuildTextChannel.cs create mode 100644 QuizBowlDiscordScoreTrackerUnitTests/InMemoryBotConfigurationContextFactory.cs diff --git a/APACHE_LICENSE.txt b/APACHE_LICENSE.txt new file mode 100644 index 0000000..9899888 --- /dev/null +++ b/APACHE_LICENSE.txt @@ -0,0 +1,204 @@ +This applies to the EntityFramework and Sqlite DLLs (Microsoft.EntityFramework.*.dll, SQLite*.dll) + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/QuizBowlDiscordScoreTracker/Bot.cs b/QuizBowlDiscordScoreTracker/Bot.cs index a1fd28c..467b29f 100644 --- a/QuizBowlDiscordScoreTracker/Bot.cs +++ b/QuizBowlDiscordScoreTracker/Bot.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; @@ -17,6 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using QuizBowlDiscordScoreTracker.Database; using QuizBowlDiscordScoreTracker.Web; using Serilog; @@ -25,7 +27,7 @@ namespace QuizBowlDiscordScoreTracker // TODO: Refactor this so that most of the methods are in a separate class that is easily testable. public sealed class Bot : BackgroundService { - private static readonly Regex BuzzRegex = new Regex("^bu?z+$", RegexOptions.IgnoreCase); + private static readonly Regex BuzzRegex = new Regex("^\\s*bu?z+\\s*$", RegexOptions.IgnoreCase); // TODO: We may need a lock for this, and this lock would need to be accessible form BotCommands. We could wrap // this in an object which would do the locking for us. @@ -37,6 +39,7 @@ public sealed class Bot : BackgroundService private readonly ILogger logger; private readonly DiscordNetEventLogger discordNetEventLogger; private readonly IDisposable configurationChangeCallback; + private readonly IDatabaseActionFactory dbActionFactory; [SuppressMessage("Code Quality", "CA2213:Disposable fields should be disposed", Justification = "Dispose method is inaccessible")] private readonly CommandService commandService; @@ -58,6 +61,7 @@ public Bot(IOptionsMonitor options, IHubContext hu this.hubContext = hubContext; this.gameStateManager = new GameStateManager(); this.options = options; + this.dbActionFactory = new SqliteDatabaseActionFactory(this.options.CurrentValue.DatabaseDataSource); // TODO: Rewrite this so that // #1: buzz emojis are server-dependent, since emojis are @@ -75,6 +79,7 @@ public Bot(IOptionsMonitor options, IHubContext hu serviceCollection.AddSingleton(this.client); serviceCollection.AddSingleton(this.gameStateManager); serviceCollection.AddSingleton(this.options); + serviceCollection.AddSingleton(this.dbActionFactory); this.serviceProvider = serviceCollection.BuildServiceProvider(); this.commandService = new CommandService(new CommandServiceConfig() @@ -109,6 +114,7 @@ public override void Dispose() this.discordNetEventLogger.Dispose(); this.configurationChangeCallback.Dispose(); this.client.Dispose(); + base.Dispose(); } private static IEnumerable BuildBuzzEmojiRegexes(BotConfiguration options) @@ -135,10 +141,19 @@ private static IEnumerable BuildBuzzEmojiRegexes(BotConfiguration options protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // Make sure the database exists + using (DatabaseAction action = this.dbActionFactory.Create()) + { + await action.MigrateAsync(); + } + + stoppingToken.ThrowIfCancellationRequested(); + // TODO: If we go to a more proper service architecture, move more of the initialization logic from the // constructor to here, since we could start/stop the client multiple times. string token = this.options.CurrentValue.BotToken; await this.client.LoginAsync(TokenType.Bot, token); + stoppingToken.ThrowIfCancellationRequested(); await this.client.StartAsync(); } @@ -148,19 +163,31 @@ public override Task StopAsync(CancellationToken cancellationToken) return base.StopAsync(cancellationToken); } - private async Task> MuteReader( - ITextChannel textChannel, string voiceChannelName, ulong? readerId) + private async Task> MuteReader(ITextChannel textChannel, ulong? readerId) { - IGuildUser reader = null; + Stopwatch stopwatch = new Stopwatch(); + stopwatch.Start(); + ulong? voiceChannelId = null; + using (DatabaseAction action = this.dbActionFactory.Create()) + { + voiceChannelId = await action.GetPairedVoiceChannelIdOrNullAsync(textChannel.Id); + } + + stopwatch.Stop(); + this.logger.Verbose($"Time to get paired channel: {stopwatch.ElapsedMilliseconds} ms"); - IReadOnlyCollection voiceChannels = await textChannel.Guild.GetVoiceChannelsAsync(); - IVoiceChannel voiceChannel = voiceChannels.FirstOrDefault(channel => channel.Name == voiceChannelName); + if (voiceChannelId == null) + { + return null; + } + + IVoiceChannel voiceChannel = await textChannel.Guild.GetVoiceChannelAsync(voiceChannelId.Value); if (voiceChannel == null) { return null; } - reader = await textChannel.Guild.GetUserAsync(readerId.Value); + IGuildUser reader = await textChannel.Guild.GetUserAsync(readerId.Value); try { // Make sure the reader didn't mute themselves or leave the voice channel @@ -187,18 +214,15 @@ private async Task PromptNextPlayer(GameState state, ITextChannel textChannel) { if (state.TryGetNextPlayer(out ulong userId)) { - Tuple voiceChannelReaderPair = null; - if (this.options.CurrentValue.TryGetVoiceChannelName( - textChannel.Guild.Name, textChannel.Name, out string voiceChannelName)) - { - voiceChannelReaderPair = await this.MuteReader(textChannel, voiceChannelName, state.ReaderId); - } - IGuildUser user = await textChannel.Guild.GetUserAsync(userId); - await textChannel.SendMessageAsync(user.Mention); - await this.hubContext.Clients.Group(GroupFromChannel(textChannel)) + Task> getVoiceChannelReaderPair = this.MuteReader( + textChannel, state.ReaderId); + Task sendMessage = textChannel.SendMessageAsync(user.Mention); + Task alertWebSocket = this.hubContext.Clients.Group(GroupFromChannel(textChannel)) .SendAsync("PlayerBuzz", user.Nickname ?? user.Username); + await Task.WhenAll(getVoiceChannelReaderPair, sendMessage, alertWebSocket); + Tuple voiceChannelReaderPair = getVoiceChannelReaderPair.Result; if (voiceChannelReaderPair != null) { // We want to run this on a separate thread and not block the event handler @@ -232,7 +256,6 @@ private async Task OnMessageCreated(SocketMessage message) // Some commands may need to be taken in DM channels. Everything for handling buzzes and scoring should be // on the main channel if (!(userMessage.Channel is ITextChannel channel && - this.options.CurrentValue.IsTextSupportedChannel(channel.Guild.Name, channel.Name) && this.gameStateManager.TryGet(channel.Id, out GameState state))) { return; diff --git a/QuizBowlDiscordScoreTracker/BotConfiguration.cs b/QuizBowlDiscordScoreTracker/BotConfiguration.cs index 10d2725..16ec3f2 100644 --- a/QuizBowlDiscordScoreTracker/BotConfiguration.cs +++ b/QuizBowlDiscordScoreTracker/BotConfiguration.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; namespace QuizBowlDiscordScoreTracker { @@ -15,10 +14,6 @@ public class BotConfiguration // "muteDelayMs": 500, // "buzzEmojis": [":buzz:"], // "webBaseUrl": "https://localhost:8080/index.html" - // "supportedChannels": { - // "server1": [{ "text": "channel1", "voice": "Voice Channel" }], - // "server2": [{ "text": "packet", "voice": "Packet Voice" }, { text: "channel2" }], - // } // } // Token currently comes from a separate file, since it should eventually be encrypted and not included with // the config file. @@ -27,8 +22,8 @@ public BotConfiguration() // Use defaults this.WaitForRejoinMs = 1000; this.MuteDelayMs = 500; + this.DatabaseDataSource = null; this.BuzzEmojis = Array.Empty(); - this.SupportedChannels = new Dictionary(); this.BotToken = string.Empty; this.WebBaseURL = null; } @@ -39,17 +34,26 @@ public BotConfiguration() /// public int WaitForRejoinMs { get; set; } + /// + /// The amount of time, in milliseconds, to mute the reader after a buzz occurs. + /// public int MuteDelayMs { get; set; } /// - /// The channels which the bot will listen to. It maps guild/server names to channels supported on that server. + /// The DataSource to use for the database storing guild-specific settings. This is generally a file path, but + /// it can be ":memory:" if you don't want to store anything on disk. If this isn't defined, use the default + /// data source location. + /// + public virtual string DatabaseDataSource { get; set; } + + // TODO: Add an upgrade script with the normal bot, so teams will have their channels paired automatically. + /// + /// DEPRECATED. The channels which the bot will listen to. It maps guild/server names to channels supported on that server. /// If this is null, then every channel is supported. - /// A version which uses guild/channel IDs instead may be supported later. /// [SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Needed for deserializer")] + [Obsolete("Users should use the !pairChannels/!unpairChannel commands instead")] public IDictionary SupportedChannels { get; set; } - - /// /// The emojis which represent buzzes. They should be of the form ":buzz:", which is the emoji text the user /// types. /// @@ -61,32 +65,5 @@ public BotConfiguration() public string BotToken { get; set; } public Uri WebBaseURL { get; set; } - - public bool IsTextSupportedChannel(string guildName, string channelName) - { - if (this.SupportedChannels == null) - { - return true; - } - - // We could convert supportedChannels into a Dictionary in the constructor so we can do these - // lookups more efficiently. In general there shouldn't be too many supported channels per guild so - // this shouldn't be bad performance-wise. - return this.SupportedChannels.TryGetValue(guildName, out ChannelPair[] supportedChannels) && - supportedChannels.Select(pair => pair.Text).Contains(channelName); - } - - public bool TryGetVoiceChannelName(string guildName, string textChannelName, out string voiceChannelName) - { - if (!this.SupportedChannels.TryGetValue(guildName, out ChannelPair[] supportedChannels)) - { - voiceChannelName = null; - return false; - } - - ChannelPair channelPair = supportedChannels.FirstOrDefault(pair => pair.Text == textChannelName); - voiceChannelName = channelPair.Voice; - return voiceChannelName != null; - } } } diff --git a/QuizBowlDiscordScoreTracker/Commands/AdminCommands.cs b/QuizBowlDiscordScoreTracker/Commands/AdminCommands.cs new file mode 100644 index 0000000..5dd3d7f --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Commands/AdminCommands.cs @@ -0,0 +1,78 @@ +using System.Threading.Tasks; +using Discord; +using Discord.Commands; +using Microsoft.Extensions.Options; +using QuizBowlDiscordScoreTracker.Database; + +namespace QuizBowlDiscordScoreTracker.Commands +{ + [RequireUserPermission(GuildPermission.Administrator)] + public class AdminCommands : BotCommandBase + { + public AdminCommands( + GameStateManager manager, + IOptionsMonitor options, + IDatabaseActionFactory dbActionFactory) + : base(manager, options, dbActionFactory) + { + } + + [Command("checkPermissions")] + [Summary("Checks if the bot has all the required permissions.")] + public Task CheckPermissions() + { + return this.HandleCommandAsync(handler => handler.CheckPermissions()); + } + + [Command("clearTeamRolePrefix")] + [Summary("Disables pairing players together based on sharing a role with the same prefix. Only server " + + "admins can invoke this.")] + public Task ClearTeamRolePrefix() + { + return this.HandleCommandAsync(handler => handler.ClearTeamRolePrefix()); + } + + [Command("getPairedChannel")] + [Summary("Gets the name of the paired voice channel, if it exists. Only server admins can invoke this.")] + public Task GetPairedChannel([Summary("Text channel mention (#textChannelName)")] ITextChannel textChannel) + { + return this.HandleCommandAsync(handler => handler.GetPairedChannel(textChannel)); + } + + [Command("getTeamRolePrefix")] + [Summary("Posts the prefix for the role name used to assign teams, if it exists. Only server admins can " + + "invoke this.")] + public Task GetTeamRolePrefix() + { + return this.HandleCommandAsync(handler => handler.GetTeamRolePrefix()); + } + + [Command("pairChannels")] + [Summary("Pairs a text channel with a voice channel, so buzzes will mute the reader. Only server admins can" + + "invoke this.")] + public Task PairChannels( + [Summary("Text channel mention (#textChannelName)")] ITextChannel textChannel, + [Remainder][Summary("Name of the voice channel (no # included)")] string voiceChannel) + { + return this.HandleCommandAsync(handler => handler.PairChannels(textChannel, voiceChannel)); + } + + [Command("setTeamRolePrefix")] + [Summary("Players who have a role whose name shares the specified prefix will be on the same team. For " + + @"example, if a user has the role ""Team Alpha"", and the prefix is set to ""Team"", then the player " + + @"will be on a team with everyone else who has the role ""Team Alpha"". Only server admins can invoke this.")] + public Task SetTeamRolePrefix( + [Remainder][Summary("Prefix for roles that are used to group players into teams")] string prefix) + { + return this.HandleCommandAsync(handler => handler.SetTeamRolePrefix(prefix)); + } + + [Command("unpairChannel")] + [Summary("Unpairs a text channel with its voice channel. Only server admins can invoke this.")] + public Task UnpairChannel( + [Summary("Text channel mention (#textChannelName)")] ITextChannel textChannel) + { + return this.HandleCommandAsync(handler => handler.UnpairChannel(textChannel)); + } + } +} diff --git a/QuizBowlDiscordScoreTracker/Commands/BotCommandBase.cs b/QuizBowlDiscordScoreTracker/Commands/BotCommandBase.cs index b322819..e35bafb 100644 --- a/QuizBowlDiscordScoreTracker/Commands/BotCommandBase.cs +++ b/QuizBowlDiscordScoreTracker/Commands/BotCommandBase.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Discord.Commands; using Microsoft.Extensions.Options; +using QuizBowlDiscordScoreTracker.Database; using Serilog; namespace QuizBowlDiscordScoreTracker.Commands @@ -13,21 +14,20 @@ public abstract class BotCommandBase : ModuleBase private readonly GameStateManager manager; private readonly IOptionsMonitor options; + private readonly IDatabaseActionFactory databaseActionFactory; - public BotCommandBase(GameStateManager manager, IOptionsMonitor options) + public BotCommandBase( + GameStateManager manager, + IOptionsMonitor options, + IDatabaseActionFactory dbActionFactory) { this.manager = manager; this.options = options; + this.databaseActionFactory = dbActionFactory; } protected Task HandleCommandAsync(Func handleCommandFunction) { - // If we're not in a supported channel, we should exit early - if (!this.options.CurrentValue.IsTextSupportedChannel(this.Context.Guild.Name, this.Context.Channel.Name)) - { - return Task.CompletedTask; - } - if (!this.manager.TryGet(this.Context.Channel.Id, out GameState gameState)) { gameState = null; @@ -37,7 +37,8 @@ protected Task HandleCommandAsync(Func handleCommandFun // tournament lock may block certain commands, and other commands are just long-running (like !start). // To work around this (and to keep the command handler unblocked), we have to run the task in a separate // thread, which requires us running it through Task.Run. - BotCommandHandler commandHandler = new BotCommandHandler(this.Context, this.manager, gameState, Logger, this.options); + BotCommandHandler commandHandler = new BotCommandHandler( + this.Context, this.manager, gameState, Logger, this.options, this.databaseActionFactory); Task.Run(async () => { try diff --git a/QuizBowlDiscordScoreTracker/Commands/BotCommandHandler.cs b/QuizBowlDiscordScoreTracker/Commands/BotCommandHandler.cs index e8f7f18..b5ea26f 100644 --- a/QuizBowlDiscordScoreTracker/Commands/BotCommandHandler.cs +++ b/QuizBowlDiscordScoreTracker/Commands/BotCommandHandler.cs @@ -1,14 +1,19 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text; using System.Threading.Tasks; using Discord; using Discord.Commands; using Microsoft.Extensions.Options; +using QuizBowlDiscordScoreTracker.Database; using Serilog; namespace QuizBowlDiscordScoreTracker.Commands { + // TODO: Look into splitting this off into command handlers for each command class. The refactor may not be worth + // the improved readability, though. public class BotCommandHandler { private readonly ICommandContext context; @@ -16,14 +21,256 @@ public class BotCommandHandler private readonly GameState currentGame; private readonly ILogger logger; private readonly IOptionsMonitor options; + private readonly IDatabaseActionFactory dbActionFactory; - public BotCommandHandler(ICommandContext context, GameStateManager manager, GameState currentGame, ILogger logger, IOptionsMonitor options) + public BotCommandHandler( + ICommandContext context, + GameStateManager manager, + GameState currentGame, + ILogger logger, + IOptionsMonitor options, + IDatabaseActionFactory dbActionFactory) { this.context = context; this.manager = manager; this.currentGame = currentGame; this.logger = logger; this.options = options; + this.dbActionFactory = dbActionFactory; + } + + public async Task CheckPermissions() + { + if (!(this.context.Channel is IGuildChannel guildChannel)) + { + return; + } + + IGuildUser guildBotUser = await this.context.Guild.GetCurrentUserAsync(); + ChannelPermissions channelPermissions = guildBotUser.GetPermissions(guildChannel); + + StringBuilder builder = new StringBuilder(); + + // This is probably impossible to hit, since we must've heard the command in the guild. But maybe we'll add + // a version that accepts a channel argument. + if (!channelPermissions.ViewChannel) + { + builder.AppendLine( + "> - Cannot view the channel. Add the \"Read Text Channels & See Voice Channels\" permission in " + + "the guild setting or \"Read Messages\" in the channel settings."); + } + + if (!channelPermissions.SendMessages) + { + builder.AppendLine("> - Cannot send messages in the channel. Add the \"Send Messages\" permission to " + + "the role in the guild or channel settings."); + } + + if (!channelPermissions.EmbedLinks) + { + builder.AppendLine("> - Cannot add embeds. Add the \"Embed Links\" permission in the guild or " + + "channel settings."); + } + + ulong? voiceChannelId; + using (DatabaseAction action = this.dbActionFactory.Create()) + { + voiceChannelId = await action.GetPairedVoiceChannelIdOrNullAsync(this.context.Channel.Id); + } + + if (voiceChannelId.HasValue) + { + IVoiceChannel pairedVoiceChannel = await this.context.Guild.GetVoiceChannelAsync(voiceChannelId.Value); + if (pairedVoiceChannel == null) + { + builder.AppendLine("> - Paired voice channel no longer exists. Please use !pairChannels to " + + "pair this channel to a new voice channel."); + } + else if (pairedVoiceChannel is IGuildChannel pairedGuildChannel && + !guildBotUser.GetPermissions(pairedGuildChannel).MuteMembers) + { + builder.AppendLine($"> - Cannot mute reader in paired voice channel \"{pairedGuildChannel.Name}\"." + + " Please add the \"Mute Members\" permission to the role in the guild or channel settings."); + } + } + + if (builder.Length == 0) + { + await this.context.Channel.SendMessageAsync("All permissions are set up correctly."); + return; + } + + bool sendDm = !channelPermissions.ViewChannel || !channelPermissions.SendMessages; + if (sendDm) + { + await this.context.User.SendMessageAsync(builder.ToString()); + return; + } + + await this.context.Channel.SendMessageAsync(builder.ToString()); + } + + // This converts the channel pair mappings in the config file into database entries + public async Task MapConfigToDatabase() + { +#pragma warning disable CS0618 // Type or member is obsolete. This method is used to help users move away from this obsolete setting + IDictionary guildChannelPairs = this.options.CurrentValue.SupportedChannels; +#pragma warning restore CS0618 // Type or member is obsolete + if (guildChannelPairs == null) + { + return; + } + + IReadOnlyCollection guilds = await this.context.Client.GetGuildsAsync(); + IEnumerable<(IGuild, ChannelPair[])> guildsWithPairs = guildChannelPairs + .Join( + guilds, + kvp => kvp.Key, + guild => guild.Name, + (kvp, guild) => (guild, kvp.Value)); + + List<(ulong textChannelId, ulong voiceChannelId)> pairChannels = new List<(ulong, ulong)>(); + IReadOnlyCollection textChannels = await this.context.Guild.GetTextChannelsAsync(); + IReadOnlyCollection voiceChannels = await this.context.Guild.GetVoiceChannelsAsync(); + + using (DatabaseAction action = this.dbActionFactory.Create()) + { + List pairChannelTasks = new List(); + foreach ((IGuild, ChannelPair[]) guildWithPair in guildsWithPairs) + { + foreach (ChannelPair pair in guildWithPair.Item2) + { + // If there's no voice channel, then don't add them to the database + if (string.IsNullOrEmpty(pair.Voice)) + { + continue; + } + + // Unfortunately, Discord can support multiple channels with the same name, so we can't just + // convert the text/voice channels to a dictionary of name -> ID. + ITextChannel textChannel = textChannels.FirstOrDefault(channel => channel.Name == pair.Text); + IVoiceChannel voiceChannel = voiceChannels.FirstOrDefault(channel => channel.Name == pair.Voice); + if (textChannel != null && voiceChannel != null) + { + pairChannels.Add((textChannel.Id, voiceChannel.Id)); + } + } + + pairChannelTasks.Add( + action.PairChannelsAsync(guildWithPair.Item1.Id, pairChannels.ToArray())); + } + + await Task.WhenAll(pairChannelTasks); + } + + await this.context.Channel.SendMessageAsync("Configurations mapped for all servers the bot is in."); + } + + public async Task GetPairedChannel(ITextChannel textChannel) + { + if (textChannel == null) + { + this.logger.Information($"Null text channel passed in to GetPairedChannel"); + return; + } + + ulong? voiceChannelId; + using (DatabaseAction action = this.dbActionFactory.Create()) + { + voiceChannelId = await action.GetPairedVoiceChannelIdOrNullAsync(textChannel.Id); + } + + if (voiceChannelId == null) + { + await this.context.Channel.SendMessageAsync("Channel isn't paired"); + return; + } + + IVoiceChannel voiceChannel = await this.context.Guild.GetVoiceChannelAsync(voiceChannelId.Value); + string message = voiceChannel == null ? + "The paired voice channel no longer exists" : + @$"Paired voice channel: ""{voiceChannel.Name}"""; + await this.context.Channel.SendMessageAsync(message); + } + + public async Task PairChannels(ITextChannel textChannel, string voiceChannelName) + { + if (textChannel == null || voiceChannelName == null) + { + this.logger.Information($"Null text channel or voice channel name passed in to PairChannels"); + return; + } + + IReadOnlyCollection voiceChannels = await this.context.Guild.GetVoiceChannelsAsync(); + IVoiceChannel voiceChannel = voiceChannels + .FirstOrDefault(channel => channel.Name.Trim().Equals( + voiceChannelName.Trim(), StringComparison.InvariantCultureIgnoreCase)); + if (voiceChannel == null) + { + this.logger.Information("Could not find voice channel with the given name"); + await this.context.Channel.SendMessageAsync("Cannot find a voice channel with that name"); + return; + } + + using (DatabaseAction action = this.dbActionFactory.Create()) + { + await action.PairChannelsAsync(this.context.Guild.Id, textChannel.Id, voiceChannel.Id); + } + + this.logger.Information($"Channels {textChannel.Id} and {voiceChannel.Id} paired successfully"); + await this.context.Channel.SendMessageAsync("Text and voice channel paired successfully"); + } + + public async Task UnpairChannel(ITextChannel textChannel) + { + if (textChannel == null) + { + this.logger.Information($"Null text channel name passed in to UnpairChannels"); + return; + } + + using (DatabaseAction action = this.dbActionFactory.Create()) + { + await action.UnpairChannelAsync(textChannel.Id); + } + + this.logger.Information($"Channel {textChannel.Id} unpaired successfully"); + await this.context.Channel.SendMessageAsync("Text and voice channel unpaired successfully"); + } + + public async Task ClearTeamRolePrefix() + { + using (DatabaseAction action = this.dbActionFactory.Create()) + { + await action.ClearTeamRolePrefixAsync(this.context.Guild.Id); + } + + this.logger.Information($"Team prefix cleared in guild {this.context.Guild.Id}"); + await this.context.Channel.SendMessageAsync("Prefix unset. Roles no longer determine who is on a team."); + } + + public async Task GetTeamRolePrefix() + { + string prefix; + using (DatabaseAction action = this.dbActionFactory.Create()) + { + prefix = await action.GetTeamRolePrefixAsync(this.context.Guild.Id); + } + + string message = prefix == null ? "No team prefix used" : @$"Team prefix: ""{prefix}"""; + await this.context.Channel.SendMessageAsync(message); + } + + public async Task SetTeamRolePrefix(string prefix) + { + using (DatabaseAction action = this.dbActionFactory.Create()) + { + await action.SetTeamRolePrefixAsync(this.context.Guild.Id, prefix); + } + + this.logger.Information($"Team prefix set in guild {this.context.Guild.Id}"); + await this.context.Channel.SendMessageAsync( + @$"Prefix set. Players who have the same role starting with ""{prefix}"" will be on the same team."); } public async Task SetReader() @@ -55,6 +302,16 @@ public async Task SetReader() "Game started in guild '{0}' in channel '{1}'", guildChannel.Guild.Name, guildChannel.Name); } + // Prevent a cold start on the first buzz, and eagerly get the team prefix and channel pair + Task[] dbTasks = new Task[2]; + using (DatabaseAction action = this.dbActionFactory.Create()) + { + dbTasks[0] = action.GetPairedVoiceChannelIdOrNullAsync(this.context.Channel.Id); + dbTasks[1] = action.GetTeamRolePrefixAsync(this.context.Guild.Id); + } + + await Task.WhenAll(dbTasks); + string message = this.options.CurrentValue.WebBaseURL == null ? $"{this.context.User.Mention} is the reader." : $"{this.context.User.Mention} is the reader. Please visit {this.options.CurrentValue.WebBaseURL}?{this.context.Channel.Id} to hear buzzes."; @@ -113,6 +370,20 @@ public async Task GetScore() { if (this.currentGame?.ReaderId != null) { + if (!(this.context.Channel is IGuildChannel guildChannel)) + { + return; + } + + IGuildUser guildBotUser = await this.context.Guild.GetCurrentUserAsync(); + ChannelPermissions channelPermissions = guildBotUser.GetPermissions(guildChannel); + if (!channelPermissions.EmbedLinks) + { + await this.context.Channel.SendMessageAsync( + "This bot must have \"Embed Links\" permissions to show the score"); + return; + } + IEnumerable> scores = this.currentGame.GetScores(); EmbedBuilder builder = new EmbedBuilder diff --git a/QuizBowlDiscordScoreTracker/Commands/BotOwnerCommands.cs b/QuizBowlDiscordScoreTracker/Commands/BotOwnerCommands.cs new file mode 100644 index 0000000..30f3253 --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Commands/BotOwnerCommands.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Discord.Commands; +using Microsoft.Extensions.Options; +using QuizBowlDiscordScoreTracker.Database; + +namespace QuizBowlDiscordScoreTracker.Commands +{ + [RequireOwner] + public class BotOwnerCommands : BotCommandBase + { + public BotOwnerCommands( + GameStateManager manager, + IOptionsMonitor options, + IDatabaseActionFactory dbActionFactory) + : base(manager, options, dbActionFactory) + { + } + + [Command("mapConfigToDatabase")] + [Summary("Maps configuration information from config.txt to the database, so users can control their own " + + "guild-specific settings.")] + public Task MapConfigToDatabase() + { + return this.HandleCommandAsync(handler => handler.MapConfigToDatabase()); + } + } +} diff --git a/QuizBowlDiscordScoreTracker/Commands/GeneralCommands.cs b/QuizBowlDiscordScoreTracker/Commands/GeneralCommands.cs index 3f35af4..440762a 100644 --- a/QuizBowlDiscordScoreTracker/Commands/GeneralCommands.cs +++ b/QuizBowlDiscordScoreTracker/Commands/GeneralCommands.cs @@ -1,13 +1,17 @@ using System.Threading.Tasks; using Discord.Commands; using Microsoft.Extensions.Options; +using QuizBowlDiscordScoreTracker.Database; namespace QuizBowlDiscordScoreTracker.Commands { public class GeneralCommands : BotCommandBase { - public GeneralCommands(GameStateManager manager, IOptionsMonitor options) - : base(manager, options) + public GeneralCommands( + GameStateManager manager, + IOptionsMonitor options, + IDatabaseActionFactory dbActionFactory) + : base(manager, options, dbActionFactory) { } diff --git a/QuizBowlDiscordScoreTracker/Commands/HelpCommand.cs b/QuizBowlDiscordScoreTracker/Commands/HelpCommand.cs index f76a713..23e92b2 100644 --- a/QuizBowlDiscordScoreTracker/Commands/HelpCommand.cs +++ b/QuizBowlDiscordScoreTracker/Commands/HelpCommand.cs @@ -34,7 +34,7 @@ public Task Help([Remainder][Summary("Command name")] string rawCommandName) return this.SendHelpInformation(rawCommandName); } - private Task SendHelpInformation(string rawCommandName = null) + private async Task SendHelpInformation(string rawCommandName = null) { EmbedBuilder embedBuilder = new EmbedBuilder(); IEnumerable commands = this.commandService.Commands @@ -47,15 +47,35 @@ private Task SendHelpInformation(string rawCommandName = null) .Where(command => command.Name.Equals(commandName, StringComparison.CurrentCultureIgnoreCase)); } - foreach (CommandInfo commandInfo in commands) + EmbedFieldBuilder[] embedFields = await Task.WhenAll(commands + .Select(commandInfo => this.GetEmbedFieldOrNull(commandInfo))); + foreach (EmbedFieldBuilder embedField in embedFields.Where(field => field != null)) { - string parameters = string.Join(' ', commandInfo.Parameters.Select(parameter => $"*{parameter.Name}*")); - string name = $"{commandInfo.Name} {parameters}"; - embedBuilder.AddField(name, commandInfo.Summary ?? ""); + embedBuilder.AddField(embedField); } // DM the user, so that we can't spam the channel. - return this.Context.User.SendMessageAsync(embed: embedBuilder.Build()); + await this.Context.User.SendMessageAsync(embed: embedBuilder.Build()); + } + + private async Task GetEmbedFieldOrNull(CommandInfo commandInfo) + { + // Only show the bot owner commands to the bot owner. Instead of checking each precondition, just cheat and + // see if we require the bot owner. + if (commandInfo.Module.Preconditions.Any(attribute => attribute is RequireOwnerAttribute) && + this.Context.User.Id != (await this.Context.Client.GetApplicationInfoAsync()).Owner.Id) + { + return null; + } + + // We could try to limit who can see admin commands, but that doesn't work if they ask for help in a DM. + + string parameters = string.Join(' ', commandInfo.Parameters.Select(parameter => $"*{parameter.Name}*")); + return new EmbedFieldBuilder() + { + Name = $"{commandInfo.Name} {parameters}", + Value = commandInfo.Summary ?? "" + }; } } } diff --git a/QuizBowlDiscordScoreTracker/Commands/ReaderCommands.cs b/QuizBowlDiscordScoreTracker/Commands/ReaderCommands.cs index a117b72..f9c277f 100644 --- a/QuizBowlDiscordScoreTracker/Commands/ReaderCommands.cs +++ b/QuizBowlDiscordScoreTracker/Commands/ReaderCommands.cs @@ -2,14 +2,18 @@ using Discord; using Discord.Commands; using Microsoft.Extensions.Options; +using QuizBowlDiscordScoreTracker.Database; namespace QuizBowlDiscordScoreTracker.Commands { [RequireReader] public class ReaderCommands : BotCommandBase { - public ReaderCommands(GameStateManager manager, IOptionsMonitor options) - : base(manager, options) + public ReaderCommands( + GameStateManager manager, + IOptionsMonitor options, + IDatabaseActionFactory dbActionFactory) + : base(manager, options, dbActionFactory) { } diff --git a/QuizBowlDiscordScoreTracker/Database/BotConfigurationContext.cs b/QuizBowlDiscordScoreTracker/Database/BotConfigurationContext.cs new file mode 100644 index 0000000..66c2a47 --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Database/BotConfigurationContext.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; +using System.Reflection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace QuizBowlDiscordScoreTracker.Database +{ + // To perform migrations with the CLI: + // (Make sure you have run this first: dotnet tool install --global dotnet-ef) + // dotnet add package Microsoft.EntityFrameworkCore.Design + // dotnet ef migrations add InitialCreate + // dotnet ef database update + // + // To perform migrations with Visual Studio (Package Manager Console) + // Add-Migration InitialCreate + // Update-Database + // + // When you migrate, consult the site below to make sure migrations are safe. + // https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/managing?tabs=dotnet-core-cli + + // Using EF Core for now. If we need more speed, then switch to Dapper. + public class BotConfigurationContext : DbContext + { + public const string DefaultDatabase = "botConfiguration.db"; + + // Needed for migrations + public BotConfigurationContext() : this((string)null) + { + } + + public BotConfigurationContext(string dataSource) + { + this.DataSource = dataSource ?? Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), DefaultDatabase); + } + + public BotConfigurationContext(DbContextOptions options) : base(options) + { + } + + public DbSet Guilds { get; set; } + + public DbSet TextChannels { get; set; } + + private string DataSource { get; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (optionsBuilder == null) + { + throw new ArgumentNullException(nameof(optionsBuilder)); + } + + if (!optionsBuilder.IsConfigured) + { + // Use SQL Lite for now, since traffic should be low and write operations should be infrequent. + SqliteConnectionStringBuilder connectionStringBuilder = new SqliteConnectionStringBuilder() + { + DataSource = this.DataSource + }; + optionsBuilder.UseSqlite(connectionStringBuilder.ToString()); + } + } + } +} diff --git a/QuizBowlDiscordScoreTracker/Database/DatabaseAction.cs b/QuizBowlDiscordScoreTracker/Database/DatabaseAction.cs new file mode 100644 index 0000000..b95a9e8 --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Database/DatabaseAction.cs @@ -0,0 +1,191 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace QuizBowlDiscordScoreTracker.Database +{ + public sealed class DatabaseAction : IDisposable, IAsyncDisposable + { + public DatabaseAction(string dataSource = null) : this(new BotConfigurationContext(dataSource)) + { + } + + public DatabaseAction(BotConfigurationContext context) + { + this.Context = context; + } + + private BotConfigurationContext Context { get; } + + private bool IsDisposed { get; set; } + + public async Task ClearTeamRolePrefixAsync(ulong guildId) + { + GuildSetting guild = await this.Context.FindAsync(guildId); + if (guild == null) + { + return; + } + + guild.TeamRolePrefix = null; + await this.RemoveGuildIfEmpty(guild); + await this.Context.SaveChangesAsync(); + } + + public void Dispose() + { + if (!this.IsDisposed) + { + this.Context.Dispose(); + this.IsDisposed = true; + } + } + + public async ValueTask DisposeAsync() + { + if (!this.IsDisposed) + { + this.IsDisposed = true; + await this.Context.DisposeAsync(); + } + } + + public Task MigrateAsync() + { + return this.Context.Database.MigrateAsync(); + } + + public async Task GetPairedVoiceChannelIdOrNullAsync(ulong textChannelId) + { + TextChannelSetting textChannel = await this.Context.FindAsync(textChannelId); + return textChannel?.VoiceChannelId; + } + + public async Task GetTeamRolePrefixAsync(ulong guildId) + { + GuildSetting guild = await this.AddOrGetGuildAsync(guildId); + return guild.TeamRolePrefix; + } + + public async Task PairChannelsAsync(ulong guildId, ulong textChannelId, ulong voiceChannelId) + { + TextChannelSetting textChannel = await this.AddOrGetTextChannelAsync(guildId, textChannelId); + textChannel.VoiceChannelId = voiceChannelId; + await this.Context.SaveChangesAsync(); + } + + public async Task PairChannelsAsync(ulong guildId, (ulong textChannelId, ulong voiceChannelId)[] channelPairs) + { + Verify.IsNotNull(channelPairs, nameof(channelPairs)); + + TextChannelSetting[] textChannels = await Task.WhenAll( + channelPairs.Select(pair => this.AddOrGetTextChannelAsync(guildId, pair.textChannelId))); + for (int i = 0; i < textChannels.Length; i++) + { + textChannels[i].VoiceChannelId = channelPairs[i].voiceChannelId; + } + + await this.Context.SaveChangesAsync(); + } + + public async Task SetTeamRolePrefixAsync(ulong guildId, string prefix) + { + GuildSetting guild = await this.AddOrGetGuildAsync(guildId); + guild.TeamRolePrefix = prefix; + await this.Context.SaveChangesAsync(); + } + + public async Task UnpairChannelAsync(ulong textChannelId) + { + TextChannelSetting textChannel = await this.Context.FindAsync(textChannelId); + if (textChannel == null) + { + return; + } + + textChannel.VoiceChannelId = null; + await this.RemoveTextChannelIfEmpty(textChannel); + await this.Context.SaveChangesAsync(); + } + + private async Task AddOrGetTextChannelAsync(ulong guildId, ulong textChannelId) + { + TextChannelSetting textChannel = await this.Context.FindAsync(textChannelId); + if (textChannel != null) + { + return textChannel; + } + + // Ensure we have a guild. We need this to exist for the foreign key validation to pass + await this.AddOrGetGuildAsync(guildId); + + textChannel = new TextChannelSetting() + { + TextChannelSettingId = textChannelId, + GuildSettingId = guildId + }; + this.Context.TextChannels.Add(textChannel); + + await this.Context.SaveChangesAsync(); + return textChannel; + } + + private async Task AddOrGetGuildAsync(ulong guildId) + { + GuildSetting guild = await this.Context.FindAsync(guildId); + if (guild != null) + { + return guild; + } + + guild = new GuildSetting() + { + GuildSettingId = guildId + }; + this.Context.Guilds.Add(guild); + + await this.Context.SaveChangesAsync(); + return guild; + } + + private async Task RemoveGuildIfEmpty(GuildSetting guild) + { + if (guild.TeamRolePrefix != null) + { + return; + } + + // The guild may not have included the text channels, so check if there are any + GuildSetting guildWithTextChannels = await this.Context.Guilds + .Include(g => g.TextChannels) + .FirstOrDefaultAsync(g => g.GuildSettingId == guild.GuildSettingId); + if (guildWithTextChannels.TextChannels == null || guildWithTextChannels.TextChannels.Count == 0) + { + this.Context.Remove(guild); + } + } + + private async Task RemoveTextChannelIfEmpty(TextChannelSetting textChannel) + { + if (textChannel.TeamMessageId != null || textChannel.VoiceChannelId != null) + { + return; + } + + this.Context.Remove(textChannel); + + // If we had to remove a text channel, then maybe we have to remove the guild, too. We will need to + // save the fact that we removed the text channel for the guild to realize that it has no text channels + // left. + GuildSetting guild = await this.AddOrGetGuildAsync(textChannel.GuildSettingId); + if (guild == null) + { + return; + } + + await this.Context.SaveChangesAsync(); + await this.RemoveGuildIfEmpty(guild); + } + } +} diff --git a/QuizBowlDiscordScoreTracker/Database/GuildSetting.cs b/QuizBowlDiscordScoreTracker/Database/GuildSetting.cs new file mode 100644 index 0000000..f7b1ae3 --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Database/GuildSetting.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace QuizBowlDiscordScoreTracker.Database +{ + public class GuildSetting + { + // This should match the Guild ID + public ulong GuildSettingId { get; set; } + public string TeamRolePrefix { get; set; } + +#pragma warning disable CA2227 // Collection properties should be read only. This is an EF Core model class; the collection must be settable + public ICollection TextChannels { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only + } +} diff --git a/QuizBowlDiscordScoreTracker/Database/IDatabaseActionFactory.cs b/QuizBowlDiscordScoreTracker/Database/IDatabaseActionFactory.cs new file mode 100644 index 0000000..90c2dfb --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Database/IDatabaseActionFactory.cs @@ -0,0 +1,7 @@ +namespace QuizBowlDiscordScoreTracker.Database +{ + public interface IDatabaseActionFactory + { + DatabaseAction Create(); + } +} diff --git a/QuizBowlDiscordScoreTracker/Database/SqliteDatabaseActionFactory.cs b/QuizBowlDiscordScoreTracker/Database/SqliteDatabaseActionFactory.cs new file mode 100644 index 0000000..dfb9793 --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Database/SqliteDatabaseActionFactory.cs @@ -0,0 +1,34 @@ +using System.IO; +using System.Reflection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace QuizBowlDiscordScoreTracker.Database +{ + // Approach taken from https://www.meziantou.net/testing-ef-core-in-memory-using-sqlite.htm + public class SqliteDatabaseActionFactory : IDatabaseActionFactory + { + public SqliteDatabaseActionFactory(string dataSource) + { + dataSource = dataSource ?? Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), + BotConfigurationContext.DefaultDatabase); + + SqliteConnectionStringBuilder builder = new SqliteConnectionStringBuilder + { + DataSource = dataSource + }; + this.ConnectionString = builder.ConnectionString; + } + + private string ConnectionString { get; set; } + + public DatabaseAction Create() + { + DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() + .UseSqlite(this.ConnectionString); + + return new DatabaseAction(new BotConfigurationContext(optionsBuilder.Options)); + } + } +} diff --git a/QuizBowlDiscordScoreTracker/Database/TextChannelSetting.cs b/QuizBowlDiscordScoreTracker/Database/TextChannelSetting.cs new file mode 100644 index 0000000..470f33c --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Database/TextChannelSetting.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace QuizBowlDiscordScoreTracker.Database +{ + public class TextChannelSetting + { + // This should match the TextChannel ID + public ulong TextChannelSettingId { get; set; } + public ulong? VoiceChannelId { get; set; } + + // This should match the channel's ID + public ulong? TeamMessageId { get; set; } + + [ForeignKey("GuildSetting")] + public ulong GuildSettingId { get; set; } + } +} diff --git a/QuizBowlDiscordScoreTracker/Migrations/20200829064343_InitialCreate.Designer.cs b/QuizBowlDiscordScoreTracker/Migrations/20200829064343_InitialCreate.Designer.cs new file mode 100644 index 0000000..50445cf --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Migrations/20200829064343_InitialCreate.Designer.cs @@ -0,0 +1,68 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using QuizBowlDiscordScoreTracker.Database; + +namespace QuizBowlDiscordScoreTracker.Migrations +{ + [DbContext(typeof(BotConfigurationContext))] + [Migration("20200829064343_InitialCreate")] + partial class InitialCreate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.7"); + + modelBuilder.Entity("QuizBowlDiscordScoreTracker.Database.GuildSetting", b => + { + b.Property("GuildSettingId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TeamRolePrefix") + .HasColumnType("TEXT"); + + b.HasKey("GuildSettingId"); + + b.ToTable("Guilds"); + }); + + modelBuilder.Entity("QuizBowlDiscordScoreTracker.Database.TextChannelSetting", b => + { + b.Property("TextChannelSettingId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GuildSettingId") + .HasColumnType("INTEGER"); + + b.Property("TeamMessageId") + .HasColumnType("INTEGER"); + + b.Property("VoiceChannelId") + .HasColumnType("INTEGER"); + + b.HasKey("TextChannelSettingId"); + + b.HasIndex("GuildSettingId"); + + b.ToTable("TextChannels"); + }); + + modelBuilder.Entity("QuizBowlDiscordScoreTracker.Database.TextChannelSetting", b => + { + b.HasOne("QuizBowlDiscordScoreTracker.Database.GuildSetting", null) + .WithMany("TextChannels") + .HasForeignKey("GuildSettingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/QuizBowlDiscordScoreTracker/Migrations/20200829064343_InitialCreate.cs b/QuizBowlDiscordScoreTracker/Migrations/20200829064343_InitialCreate.cs new file mode 100644 index 0000000..0198e52 --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Migrations/20200829064343_InitialCreate.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace QuizBowlDiscordScoreTracker.Migrations +{ + public partial class InitialCreate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + { + throw new ArgumentNullException(nameof(migrationBuilder)); + } + + migrationBuilder.CreateTable( + name: "Guilds", + columns: table => new + { + GuildSettingId = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TeamRolePrefix = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Guilds", x => x.GuildSettingId); + }); + + migrationBuilder.CreateTable( + name: "TextChannels", + columns: table => new + { + TextChannelSettingId = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + VoiceChannelId = table.Column(nullable: true), + TeamMessageId = table.Column(nullable: true), + GuildSettingId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TextChannels", x => x.TextChannelSettingId); + table.ForeignKey( + name: "FK_TextChannels_Guilds_GuildSettingId", + column: x => x.GuildSettingId, + principalTable: "Guilds", + principalColumn: "GuildSettingId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TextChannels_GuildSettingId", + table: "TextChannels", + column: "GuildSettingId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + { + throw new ArgumentNullException(nameof(migrationBuilder)); + } + + migrationBuilder.DropTable( + name: "TextChannels"); + + migrationBuilder.DropTable( + name: "Guilds"); + } + } +} diff --git a/QuizBowlDiscordScoreTracker/Migrations/BotConfigurationContextModelSnapshot.cs b/QuizBowlDiscordScoreTracker/Migrations/BotConfigurationContextModelSnapshot.cs new file mode 100644 index 0000000..6aac545 --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Migrations/BotConfigurationContextModelSnapshot.cs @@ -0,0 +1,66 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using QuizBowlDiscordScoreTracker.Database; + +namespace QuizBowlDiscordScoreTracker.Migrations +{ + [DbContext(typeof(BotConfigurationContext))] + partial class BotConfigurationContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.7"); + + modelBuilder.Entity("QuizBowlDiscordScoreTracker.Database.GuildSetting", b => + { + b.Property("GuildSettingId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("TeamRolePrefix") + .HasColumnType("TEXT"); + + b.HasKey("GuildSettingId"); + + b.ToTable("Guilds"); + }); + + modelBuilder.Entity("QuizBowlDiscordScoreTracker.Database.TextChannelSetting", b => + { + b.Property("TextChannelSettingId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GuildSettingId") + .HasColumnType("INTEGER"); + + b.Property("TeamMessageId") + .HasColumnType("INTEGER"); + + b.Property("VoiceChannelId") + .HasColumnType("INTEGER"); + + b.HasKey("TextChannelSettingId"); + + b.HasIndex("GuildSettingId"); + + b.ToTable("TextChannels"); + }); + + modelBuilder.Entity("QuizBowlDiscordScoreTracker.Database.TextChannelSetting", b => + { + b.HasOne("QuizBowlDiscordScoreTracker.Database.GuildSetting", null) + .WithMany("TextChannels") + .HasForeignKey("GuildSettingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/QuizBowlDiscordScoreTracker/QuizBowlDiscordScoreTracker.csproj b/QuizBowlDiscordScoreTracker/QuizBowlDiscordScoreTracker.csproj index f378ba6..65827a2 100644 --- a/QuizBowlDiscordScoreTracker/QuizBowlDiscordScoreTracker.csproj +++ b/QuizBowlDiscordScoreTracker/QuizBowlDiscordScoreTracker.csproj @@ -3,7 +3,7 @@ Exe netcoreapp3.1 - 2.2.0 + 3.0.0 Alejandro Lopez-Lago Quiz Bowl Discord Score Tracker @@ -12,13 +12,22 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/QuizBowlDiscordScoreTracker/Verify.cs b/QuizBowlDiscordScoreTracker/Verify.cs new file mode 100644 index 0000000..efa91d2 --- /dev/null +++ b/QuizBowlDiscordScoreTracker/Verify.cs @@ -0,0 +1,15 @@ +using System; + +namespace QuizBowlDiscordScoreTracker +{ + public static class Verify + { + public static void IsNotNull(object parameter, string parameterName) + { + if (parameter == null) + { + throw new ArgumentNullException(parameterName); + } + } + } +} diff --git a/QuizBowlDiscordScoreTracker/Web/Startup.cs b/QuizBowlDiscordScoreTracker/Web/Startup.cs index 9b3e021..54c3fce 100644 --- a/QuizBowlDiscordScoreTracker/Web/Startup.cs +++ b/QuizBowlDiscordScoreTracker/Web/Startup.cs @@ -25,6 +25,9 @@ public Startup(IConfiguration configuration) Justification = "Used by ASP.Net Core, and must be an instance method")] public void ConfigureServices(IServiceCollection services) { + // TODO: Initialize the BotConfigurationContext here with AddDbContext. You need a service scope to use it, + // and we'd need to move away from using Program.cs and making this a proper IHostedService. See + // https://stackoverflow.com/questions/51618406/cannot-consume-scoped-service-mydbcontext-from-singleton-microsoft-aspnetcore // TODO: See if there's a way we can avoid initializing SignalR if the url setting isn't set. I suspect // it's not possible, since we pass in the IHubContext to the Bot constructor, and it probably needs a // registered type for the interface diff --git a/QuizBowlDiscordScoreTrackerUnitTests/BotCommandHandlerTests.cs b/QuizBowlDiscordScoreTrackerUnitTests/BotCommandHandlerTests.cs index 1eee25d..9ccc4f1 100644 --- a/QuizBowlDiscordScoreTrackerUnitTests/BotCommandHandlerTests.cs +++ b/QuizBowlDiscordScoreTrackerUnitTests/BotCommandHandlerTests.cs @@ -5,27 +5,50 @@ using System.Threading.Tasks; using Discord; using Discord.Commands; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using QuizBowlDiscordScoreTracker; using QuizBowlDiscordScoreTracker.Commands; +using QuizBowlDiscordScoreTracker.Database; using Serilog; namespace QuizBowlDiscordScoreTrackerUnitTests { [TestClass] - public class BotCommandHandlerTests + public sealed class BotCommandHandlerTests : IDisposable { private const ulong DefaultReaderId = 1; private static readonly HashSet DefaultIds = new HashSet(new ulong[] { 1, 2, 3 }); private const ulong DefaultChannelId = 11; + private const ulong DefaultGuildId = 9; + + private InMemoryBotConfigurationContextFactory botConfigurationfactory; + + [TestInitialize] + public void InitializeTest() + { + this.botConfigurationfactory = new InMemoryBotConfigurationContextFactory(); + + // Make sure the database is initialized before running the test + using (BotConfigurationContext context = this.botConfigurationfactory.Create()) + { + context.Database.Migrate(); + } + } + + [TestCleanup] + public void Dispose() + { + this.botConfigurationfactory.Dispose(); + } [TestMethod] public async Task CanSetReaderToExistingUser() { - CreateHandler( + this.CreateHandler( DefaultIds, DefaultChannelId, DefaultReaderId, @@ -46,7 +69,7 @@ public async Task CannotSetReaderToNonexistentUser() { // This will fail, but in our use case this would be impossible. ulong readerId = GetNonexistentUserId(); - CreateHandler( + this.CreateHandler( DefaultIds, DefaultChannelId, readerId, @@ -64,7 +87,7 @@ public async Task SetReaderDoesNotReplaceExistingReader() const ulong existingReaderId = 1; const ulong newReaderId = 2; - CreateHandler( + this.CreateHandler( DefaultIds, DefaultChannelId, newReaderId, @@ -82,7 +105,7 @@ public async Task SetReaderDoesNotReplaceExistingReader() public async Task CanSetExistingUserAsNewReader() { ulong newReaderId = GetExistingNonReaderUserId(); - CreateHandler( + this.CreateHandler( DefaultIds, DefaultChannelId, DefaultReaderId, @@ -106,7 +129,7 @@ public async Task CanSetExistingUserAsNewReader() public async Task CannotSetNewReaderWhenReaderChoosesNonexistentUser() { ulong newReaderId = GetNonexistentUserId(); - CreateHandler( + this.CreateHandler( DefaultIds, DefaultChannelId, DefaultReaderId, @@ -131,7 +154,7 @@ public async Task CannotSetNewReaderWhenReaderChoosesNonexistentUser() public async Task ClearEmptiesQueue() { ulong buzzer = GetExistingNonReaderUserId(); - CreateHandler( + this.CreateHandler( DefaultIds, DefaultChannelId, DefaultReaderId, @@ -157,7 +180,12 @@ public async Task ClearAllRemovesGame() BotCommandHandler handler = new BotCommandHandler( - commandContext, manager, currentGame, Mock.Of(), CreateConfigurationOptionsMonitor()); + commandContext, + manager, + currentGame, + Mock.Of(), + CreateConfigurationOptionsMonitor(), + this.CreateDatabaseActionFactory()); await handler.ClearAll(); @@ -171,7 +199,7 @@ public async Task ClearAllRemovesGame() public async Task NextQuestionClears() { ulong buzzer = GetExistingNonReaderUserId(); - CreateHandler( + this.CreateHandler( DefaultIds, DefaultChannelId, DefaultReaderId, @@ -190,7 +218,7 @@ public async Task NextQuestionClears() public async Task CanUndoWithReader() { ulong buzzer = GetExistingNonReaderUserId(); - CreateHandler( + this.CreateHandler( DefaultIds, DefaultChannelId, DefaultReaderId, @@ -222,7 +250,7 @@ public async Task GetScoreContainsPlayers() // Unprivileged users should be able to get the score. ulong buzzer = GetExistingNonReaderUserId(); - CreateHandler( + this.CreateHandler( DefaultIds, DefaultChannelId, DefaultReaderId, @@ -263,7 +291,7 @@ public async Task GetScoreTitleShowsLimitWhenApplicable() existingIds.Add(i); } - CreateHandler( + this.CreateHandler( existingIds, DefaultChannelId, DefaultReaderId, @@ -320,11 +348,6 @@ public async Task GetScoreTitleShowsLimitWhenApplicable() [TestMethod] public async Task GetScoreShowsNoMoreThanLimit() { - GameState existingState = new GameState - { - ReaderId = 0 - }; - HashSet existingIds = new HashSet(); const ulong lastId = GameState.ScoresListLimit + 1; for (ulong i = 1; i <= lastId; i++) @@ -332,7 +355,7 @@ public async Task GetScoreShowsNoMoreThanLimit() existingIds.Add(i); } - CreateHandler( + this.CreateHandler( existingIds, DefaultChannelId, 0, @@ -363,7 +386,176 @@ public async Task GetScoreShowsNoMoreThanLimit() $"Number of scorers shown is not the same as the scoring limit."); } - private static void CreateHandler( + [TestMethod] + public async Task SetTeamRole() + { + const string prefix = "Team #"; + const string newPrefix = "New Team #"; + this.CreateHandler( + DefaultIds, + DefaultChannelId, + 0, + out BotCommandHandler handler, + out GameState _, + out MessageStore messageStore); + + await handler.SetTeamRolePrefix(prefix); + Assert.AreEqual( + 1, messageStore.ChannelMessages.Count, "Unexpected number of messages after setting the team role"); + string setMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + setMessage.Contains(prefix, StringComparison.InvariantCulture), + $"Prefix not in message \"{setMessage}\""); + + messageStore.Clear(); + + await handler.GetTeamRolePrefix(); + Assert.AreEqual( + 1, messageStore.ChannelMessages.Count, "Unexpected number of messages after getting the team role"); + string getMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + getMessage.Contains(prefix, StringComparison.InvariantCulture), + $"Prefix not in message \"{getMessage}\""); + Assert.AreNotEqual(setMessage, getMessage, "Get and set messages should be different"); + + messageStore.Clear(); + + await handler.SetTeamRolePrefix(newPrefix); + Assert.AreEqual( + 1, messageStore.ChannelMessages.Count, "Unexpected number of messages after updating the team role"); + setMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + setMessage.Contains(newPrefix, StringComparison.InvariantCulture), + $"Prefix not in message \"{setMessage}\" after update"); + + messageStore.Clear(); + + await handler.GetTeamRolePrefix(); + Assert.AreEqual( + 1, + messageStore.ChannelMessages.Count, + "Unexpected number of messages when getting the team role after the update"); + getMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + getMessage.Contains(prefix, StringComparison.InvariantCulture), + $"Prefix not in message \"{getMessage}\" after update"); + Assert.AreNotEqual(setMessage, getMessage, "Get and set messages should be different after update"); + } + + [TestMethod] + public async Task ClearTeamRole() + { + const string prefix = "Team #"; + this.CreateHandler( + DefaultIds, + DefaultChannelId, + 0, + out BotCommandHandler handler, + out GameState _, + out MessageStore messageStore); + + await handler.SetTeamRolePrefix(prefix); + Assert.AreEqual( + 1, messageStore.ChannelMessages.Count, "Unexpected number of messages after setting the team role"); + string setMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + setMessage.Contains(prefix, StringComparison.InvariantCulture), + $"Prefix not in message \"{setMessage}\""); + + messageStore.Clear(); + + await handler.ClearTeamRolePrefix(); + Assert.AreEqual( + 1, messageStore.ChannelMessages.Count, "Unexpected number of messages after updating the team role"); + string clearMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + clearMessage.Contains("unset", StringComparison.InvariantCulture), + @$"""unset"" not in message ""{clearMessage}"" after update"); + + messageStore.Clear(); + + await handler.GetTeamRolePrefix(); + Assert.AreEqual( + 1, + messageStore.ChannelMessages.Count, + "Unexpected number of messages when getting the team role after the update"); + string getMessage = messageStore.ChannelMessages[0]; + Assert.AreEqual("No team prefix used", getMessage, $"The team role prefix was not cleared"); + } + + [TestMethod] + public async Task PairChannels() + { + const string voiceChannelName = "Packet Voice"; + const ulong voiceChannelId = DefaultChannelId + 10; + this.CreateHandler( + DefaultChannelId, + voiceChannelId, + voiceChannelName, + out BotCommandHandler handler, + out MessageStore messageStore, + out IGuildTextChannel textChannel); + + await handler.PairChannels(textChannel, voiceChannelName); + + // TODO: Check the exact string once this issue is fixed: + // https://github.com/alopezlago/QuizBowlDiscordScoreTracker/issues/23 + Assert.AreEqual( + 1, messageStore.ChannelMessages.Count, "Unexpected number of messages after pairing channels"); + string setMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + setMessage.Contains("success", StringComparison.InvariantCulture), + @$"Pairing message doesn't mention ""success"". Message: {setMessage}"); + messageStore.Clear(); + + await handler.GetPairedChannel(textChannel); + + Assert.AreEqual( + 1, messageStore.ChannelMessages.Count, "Unexpected number of messages after pairing channels"); + string getMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + getMessage.Contains(voiceChannelName, StringComparison.InvariantCulture), + $"Voice channel name not found in get message. Message: {getMessage}"); + } + + [TestMethod] + public async Task UnpairChannel() + { + const string voiceChannelName = "Packet Voice"; + const ulong voiceChannelId = DefaultChannelId + 10; + this.CreateHandler( + DefaultChannelId, + voiceChannelId, + voiceChannelName, + out BotCommandHandler handler, + out MessageStore messageStore, + out IGuildTextChannel textChannel); + + await handler.PairChannels(textChannel, voiceChannelName); + + // TODO: Check the exact string once this issue is fixed: + // https://github.com/alopezlago/QuizBowlDiscordScoreTracker/issues/23 + Assert.AreEqual( + 1, messageStore.ChannelMessages.Count, "Unexpected number of messages after pairing channels"); + string setMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + setMessage.Contains("success", StringComparison.InvariantCultureIgnoreCase), + @$"Pairing message doesn't mention ""success"". Message: {setMessage}"); + messageStore.Clear(); + + await handler.UnpairChannel(textChannel); + + Assert.AreEqual( + 1, messageStore.ChannelMessages.Count, "Unexpected number of messages after pairing channels"); + string getMessage = messageStore.ChannelMessages[0]; + Assert.IsTrue( + getMessage.Contains("unpair", StringComparison.InvariantCultureIgnoreCase), + @$"Unpairing message doesn't mention ""unpaired"". Message: {getMessage}"); + } + + // TODO: Verify perf isn't bad for DBAction actions + + private void CreateHandler( HashSet existingUserIds, ulong channelId, ulong userId, @@ -377,7 +569,88 @@ private static void CreateHandler( ICommandContext commandContext = CreateCommandContext( messageStore, existingUserIds, channelId, userId); handler = new BotCommandHandler( - commandContext, manager, currentGame, Mock.Of(), CreateConfigurationOptionsMonitor()); + commandContext, + manager, + currentGame, + Mock.Of(), + CreateConfigurationOptionsMonitor(), + this.CreateDatabaseActionFactory()); + } + + private void CreateHandler( + ulong textChannelId, + ulong voiceChannelId, + string voiceChannelName, + out BotCommandHandler handler, + out MessageStore messageStore, + out IGuildTextChannel textChannel) + { + // We need a local copy for the mocked methods + MessageStore localMessageStore = new MessageStore(); + messageStore = localMessageStore; + + Mock mockCommandContext = new Mock(); + Mock mockGuild = new Mock(); + mockGuild.Setup(guild => guild.Id).Returns(777); + + Mock mockVoiceChannel = new Mock(); + mockVoiceChannel.Setup(voiceChannel => voiceChannel.Id).Returns(voiceChannelId); + mockVoiceChannel.Setup(voiceChannel => voiceChannel.Name).Returns(voiceChannelName); + mockGuild + .Setup(guild => guild.GetVoiceChannelAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(mockVoiceChannel.Object)); + + List voiceChannels = new List() + { + mockVoiceChannel.Object + }; + mockGuild + .Setup(guild => guild.GetVoiceChannelsAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult>(voiceChannels)); + + Mock mockUserMessage = new Mock(); + Mock mockMessageChannel = new Mock(); + mockMessageChannel + .Setup(channel => channel.Id) + .Returns(textChannelId); + mockMessageChannel + .Setup(channel => channel.SendMessageAsync(It.IsAny(), false, null, It.IsAny())) + .Returns((message, isTTS, embed, options) => + { + localMessageStore.ChannelMessages.Add(message); + return Task.FromResult(mockUserMessage.Object); + }); + mockMessageChannel + .Setup(channel => channel.SendMessageAsync(null, false, It.IsAny(), It.IsAny())) + .Returns((message, isTTS, embed, options) => + { + localMessageStore.ChannelEmbeds.Add(GetMockEmbedText(embed)); + return Task.FromResult(mockUserMessage.Object); + }); + mockMessageChannel + .Setup(channel => channel.Name) + .Returns("gameChannel"); + mockMessageChannel + .Setup(channel => channel.Guild) + .Returns(mockGuild.Object); + + textChannel = mockMessageChannel.Object; + + mockCommandContext + .Setup(context => context.Channel) + .Returns(mockMessageChannel.Object); + mockCommandContext + .Setup(context => context.Guild) + .Returns(mockGuild.Object); + + GameStateManager manager = new GameStateManager(); + handler = new BotCommandHandler( + mockCommandContext.Object, + manager, + null, + Mock.Of(), + CreateConfigurationOptionsMonitor(), + this.CreateDatabaseActionFactory()); } private static ICommandContext CreateCommandContext( @@ -385,7 +658,7 @@ private static ICommandContext CreateCommandContext( { Mock mockCommandContext = new Mock(); - Mock mockMessageChannel = new Mock(); + Mock mockMessageChannel = new Mock(); Mock mockUserMessage = new Mock(); mockMessageChannel .Setup(channel => channel.Id) @@ -404,6 +677,9 @@ private static ICommandContext CreateCommandContext( messageStore.ChannelEmbeds.Add(GetMockEmbedText(embed)); return Task.FromResult(mockUserMessage.Object); }); + mockMessageChannel + .Setup(channel => channel.Name) + .Returns("gameChannel"); Mock mockGuild = new Mock(); mockGuild @@ -417,6 +693,19 @@ private static ICommandContext CreateCommandContext( return Task.FromResult(null); }); + mockGuild.Setup(guild => guild.Id).Returns(DefaultGuildId); + + Mock mockBotUser = new Mock(); + mockBotUser + .Setup(user => user.GetPermissions(It.IsAny())) + .Returns(new ChannelPermissions(viewChannel: true, sendMessages: true, embedLinks: true)); + mockGuild + .Setup(guild => guild.GetCurrentUserAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(mockBotUser.Object)); + + mockMessageChannel + .Setup(channel => channel.Guild) + .Returns(mockGuild.Object); mockCommandContext .Setup(context => context.User) @@ -450,12 +739,29 @@ private static IOptionsMonitor CreateConfigurationOptionsMonit { Mock> mockOptionsMonitor = new Mock>(); Mock mockConfiguration = new Mock(); + mockConfiguration + .Setup(config => config.DatabaseDataSource) + .Returns("memory&cached=true"); // We can't set the WebURL directly without making it virtual or adding an interface for BotConfiguration mockOptionsMonitor.Setup(options => options.CurrentValue).Returns(mockConfiguration.Object); return mockOptionsMonitor.Object; } + private IDatabaseActionFactory CreateDatabaseActionFactory() + { + // TODO: See how we can dispose this correctly + + Mock mockDbActionFactory = new Mock(); + mockDbActionFactory + .Setup(dbActionFactory => dbActionFactory.Create()) + .Returns(() => + { + return new DatabaseAction(this.botConfigurationfactory.Create()); + }); + return mockDbActionFactory.Object; + } + private static ulong GetExistingNonReaderUserId(ulong readerId = DefaultReaderId) { return DefaultIds.Except(new ulong[] { readerId }).First(); diff --git a/QuizBowlDiscordScoreTrackerUnitTests/DatabaseActionTests.cs b/QuizBowlDiscordScoreTrackerUnitTests/DatabaseActionTests.cs new file mode 100644 index 0000000..d3bdecd --- /dev/null +++ b/QuizBowlDiscordScoreTrackerUnitTests/DatabaseActionTests.cs @@ -0,0 +1,117 @@ +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using QuizBowlDiscordScoreTracker.Database; + +namespace QuizBowlDiscordScoreTrackerUnitTests +{ + [TestClass] + public class DatabaseActionTests + { + private const ulong guildId = 1234; + + [TestMethod] + public async Task PairNewChannelAndUpdateChannel() + { + const ulong textChannelId = 12345; + const ulong voiceChannelId = 123456; + const ulong newVoiceChannelId = 123567; + + using (InMemoryBotConfigurationContextFactory factory = new InMemoryBotConfigurationContextFactory()) + using (BotConfigurationContext context = factory.Create()) + using (DatabaseAction action = new DatabaseAction(context)) + { + await action.MigrateAsync(); + await action.PairChannelsAsync(guildId, new (ulong, ulong)[] { (textChannelId, voiceChannelId) }); + + ulong? pairedVoiceChannelId = await action.GetPairedVoiceChannelIdOrNullAsync(textChannelId); + Assert.AreEqual(voiceChannelId, pairedVoiceChannelId, "Voice channel wasn't paired"); + + await action.PairChannelsAsync(guildId, new (ulong, ulong)[] { (textChannelId, newVoiceChannelId) }); + pairedVoiceChannelId = await action.GetPairedVoiceChannelIdOrNullAsync(textChannelId); + Assert.AreEqual(newVoiceChannelId, pairedVoiceChannelId, "Voice channel wasn't updated"); + } + } + + [TestMethod] + public async Task UnpairChannel() + { + const ulong textChannelId = 12345; + const ulong voiceChannelId = 123456; + + using (InMemoryBotConfigurationContextFactory factory = new InMemoryBotConfigurationContextFactory()) + using (BotConfigurationContext context = factory.Create()) + using (DatabaseAction action = new DatabaseAction(context)) + { + await action.MigrateAsync(); + ulong? pairedVoiceChannelId = await action.GetPairedVoiceChannelIdOrNullAsync(textChannelId); + Assert.IsNull(pairedVoiceChannelId, "Voice channel wasn't null initially"); + + await action.PairChannelsAsync(guildId, new (ulong, ulong)[] { (textChannelId, voiceChannelId) }); + pairedVoiceChannelId = await action.GetPairedVoiceChannelIdOrNullAsync(textChannelId); + Assert.AreEqual(voiceChannelId, pairedVoiceChannelId, "Voice channel wasn't paired."); + + await action.UnpairChannelAsync(textChannelId); + pairedVoiceChannelId = await action.GetPairedVoiceChannelIdOrNullAsync(textChannelId); + Assert.IsNull(pairedVoiceChannelId, "Voice channel should be unpaired"); + + bool anyTextChannels = await context.TextChannels.AnyAsync(); + Assert.IsFalse(anyTextChannels, "Text channel wasn't removed after the last setting was"); + + bool anyGuilds = await context.Guilds.AnyAsync(); + Assert.IsFalse(anyGuilds, "Guild wasn't removed after the last setting was"); + } + } + + [TestMethod] + public async Task SetTeamRole() + { + const string prefix = "Team "; + const string newPrefix = "New Team "; + + using (InMemoryBotConfigurationContextFactory factory = new InMemoryBotConfigurationContextFactory()) + using (BotConfigurationContext context = factory.Create()) + using (DatabaseAction action = new DatabaseAction(context)) + { + await action.MigrateAsync(); + string teamRolePrefix = await action.GetTeamRolePrefixAsync(guildId); + Assert.IsNull(teamRolePrefix, "Team role prefix should be uninitialized"); + + await action.SetTeamRolePrefixAsync(guildId, prefix); + teamRolePrefix = await action.GetTeamRolePrefixAsync(guildId); + Assert.AreEqual(prefix, teamRolePrefix, "Team role prefix was not set"); + + await action.SetTeamRolePrefixAsync(guildId, newPrefix); + teamRolePrefix = await action.GetTeamRolePrefixAsync(guildId); + Assert.AreEqual(newPrefix, teamRolePrefix, "Team role prefix was not updated"); + } + } + + [TestMethod] + public async Task ClearTeamRole() + { + const string prefix = "Team "; + + using (InMemoryBotConfigurationContextFactory factory = new InMemoryBotConfigurationContextFactory()) + using (BotConfigurationContext context = factory.Create()) + using (DatabaseAction action = new DatabaseAction(context)) + { + await action.MigrateAsync(); + string teamRolePrefix = await action.GetTeamRolePrefixAsync(guildId); + Assert.IsNull(teamRolePrefix, "Team role prefix should be uninitialized"); + + await action.SetTeamRolePrefixAsync(guildId, prefix); + teamRolePrefix = await action.GetTeamRolePrefixAsync(guildId); + Assert.AreEqual(prefix, teamRolePrefix, "Team role prefix was not set"); + + await action.ClearTeamRolePrefixAsync(guildId); + + bool anyGuilds = await context.Guilds.AnyAsync(); + Assert.IsFalse(anyGuilds, "Guild wasn't removed after the last setting was"); + + teamRolePrefix = await action.GetTeamRolePrefixAsync(guildId); + Assert.IsNull(teamRolePrefix, "Team role prefix should be cleared"); + } + } + } +} diff --git a/QuizBowlDiscordScoreTrackerUnitTests/IGuildTextChannel.cs b/QuizBowlDiscordScoreTrackerUnitTests/IGuildTextChannel.cs new file mode 100644 index 0000000..b8a8f3f --- /dev/null +++ b/QuizBowlDiscordScoreTrackerUnitTests/IGuildTextChannel.cs @@ -0,0 +1,9 @@ +using Discord; + +namespace QuizBowlDiscordScoreTrackerUnitTests +{ + // This is an interface for mocking guild text channels. It needs to be public for Moq to use it. + public interface IGuildTextChannel : IGuildChannel, IMessageChannel, ITextChannel + { + } +} diff --git a/QuizBowlDiscordScoreTrackerUnitTests/InMemoryBotConfigurationContextFactory.cs b/QuizBowlDiscordScoreTrackerUnitTests/InMemoryBotConfigurationContextFactory.cs new file mode 100644 index 0000000..d2ea642 --- /dev/null +++ b/QuizBowlDiscordScoreTrackerUnitTests/InMemoryBotConfigurationContextFactory.cs @@ -0,0 +1,57 @@ +using System; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using QuizBowlDiscordScoreTracker.Database; + +namespace QuizBowlDiscordScoreTrackerUnitTests +{ + // Tests need to access the Context, but we don't want to expose it from DatabaseAction in case we move away from + // Entity Framework. + // Approach taken from https://www.meziantou.net/testing-ef-core-in-memory-using-sqlite.htm + public class InMemoryBotConfigurationContextFactory : IDisposable + { + private DbConnection connection; + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Caller will dispose it through DatabaseAction's Dispose method")] + public BotConfigurationContext Create() + { + if (this.connection == null) + { + // Use Sqlite so we're closer to how the product implements it + this.connection = new SqliteConnection("DataSource=:memory:"); + this.connection.Open(); + } + + DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() + .UseSqlite(this.connection); + + return new BotConfigurationContext(optionsBuilder.Options); + } + + private bool IsDisposed { get; set; } + + protected virtual void Dispose(bool disposing) + { + if (!this.IsDisposed) + { + if (disposing) + { + this.connection?.Dispose(); + } + + this.IsDisposed = true; + } + } + + public void Dispose() + { + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/QuizBowlDiscordScoreTrackerUnitTests/QuizBowlDiscordScoreTrackerUnitTests.csproj b/QuizBowlDiscordScoreTrackerUnitTests/QuizBowlDiscordScoreTrackerUnitTests.csproj index 53ea039..23f7c52 100644 --- a/QuizBowlDiscordScoreTrackerUnitTests/QuizBowlDiscordScoreTrackerUnitTests.csproj +++ b/QuizBowlDiscordScoreTrackerUnitTests/QuizBowlDiscordScoreTrackerUnitTests.csproj @@ -7,14 +7,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + diff --git a/README.md b/README.md index 0dcbba4..6f5dce1 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,18 @@ This is a Discord bot which keeps track of who buzzed in, as well as each player - If using Visual Studio, you need Visual Studio 2017.5 - Libraries from Nuget: - Discord.Net + - Microsoft.EntityFrameworkCore (Design, Tools, and Sqlite) + - Microsoft.Extensions (Hosting, Configuration, Configuration.Json) + - Serilog - Moq - - These may be automatically downloaded. If not, you can get them by Managing your Nuget references in the - Visual Studio solution. + - These may be automatically downloaded. If not, you can get them by Managing your Nuget references in the Visual Studio solution. - Install [Libman](https://docs.microsoft.com/en-us/aspnet/core/client-side/libman/libman-cli), and run `libman restore` in the Web directory - You will need to create your own Discord bot at https://discordapp.com/developers. Follow the steps around creating your bot in Discord mentioned in the "Running the bot on your own machine" section. #### Runnig the bot on your own machine - Install [.Net Core Runtime 3.1](https://dotnet.microsoft.com/download/dotnet-core/3.1) - Unzip the release -- Create your own config file, with the name of your server and packet channels, and any custom emojis that can represent a buzz. Use the sampleConfig.txt file as an example. Your file should be called config.txt. +- Create your own config file. Use the sampleConfig.txt file as an example. Your file should be called config.txt. - Go to https://discordapp.com/developers to register your instance of the bot - Update token.txt with the client secret from your registered Discord bot - Visit this site (with your bot's client ID) to add the bot to your channel @@ -34,13 +36,14 @@ This is a Discord bot which keeps track of who buzzed in, as well as each player - Run the .exe file - Grant your bot the following permissions: - Read Text Channels & See Voice Messages + - Embed Links - Send Messages - Mute Members #### Running the bot on the author's machine -- Contact the author. Tell him the server name and the text and voice channel names used for packets - Visit this site to add the bot to your server: https://discordapp.com/oauth2/authorize?client_id=469025702885326849&scope=bot - Grant your bot the following permissions: - Read Text Channels & See Voice Messages + - Embed Links - Send Messages - Mute Members \ No newline at end of file From 9533e9c83d9328128d524b6946c8b47e95db5500 Mon Sep 17 00:00:00 2001 From: Alejandro Lopez-Lago Date: Wed, 9 Sep 2020 20:28:27 +0000 Subject: [PATCH 2/2] Merged PR 123: Log which user called commands that update the database Log which user called commands that update the database --- .../Commands/BotCommandHandler.cs | 9 +++++---- .../BotCommandHandlerTests.cs | 6 ++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/QuizBowlDiscordScoreTracker/Commands/BotCommandHandler.cs b/QuizBowlDiscordScoreTracker/Commands/BotCommandHandler.cs index b5ea26f..fa11af1 100644 --- a/QuizBowlDiscordScoreTracker/Commands/BotCommandHandler.cs +++ b/QuizBowlDiscordScoreTracker/Commands/BotCommandHandler.cs @@ -217,7 +217,8 @@ public async Task PairChannels(ITextChannel textChannel, string voiceChannelName await action.PairChannelsAsync(this.context.Guild.Id, textChannel.Id, voiceChannel.Id); } - this.logger.Information($"Channels {textChannel.Id} and {voiceChannel.Id} paired successfully"); + this.logger.Information( + $"Channels {textChannel.Id} and {voiceChannel.Id} paired successfully by user {this.context.User.Id}"); await this.context.Channel.SendMessageAsync("Text and voice channel paired successfully"); } @@ -234,7 +235,7 @@ public async Task UnpairChannel(ITextChannel textChannel) await action.UnpairChannelAsync(textChannel.Id); } - this.logger.Information($"Channel {textChannel.Id} unpaired successfully"); + this.logger.Information($"Channel {textChannel.Id} unpaired successfully by user {this.context.User.Id}"); await this.context.Channel.SendMessageAsync("Text and voice channel unpaired successfully"); } @@ -245,7 +246,7 @@ public async Task ClearTeamRolePrefix() await action.ClearTeamRolePrefixAsync(this.context.Guild.Id); } - this.logger.Information($"Team prefix cleared in guild {this.context.Guild.Id}"); + this.logger.Information($"Team prefix cleared in guild {this.context.Guild.Id} by user {this.context.User.Id}"); await this.context.Channel.SendMessageAsync("Prefix unset. Roles no longer determine who is on a team."); } @@ -268,7 +269,7 @@ public async Task SetTeamRolePrefix(string prefix) await action.SetTeamRolePrefixAsync(this.context.Guild.Id, prefix); } - this.logger.Information($"Team prefix set in guild {this.context.Guild.Id}"); + this.logger.Information($"Team prefix set in guild {this.context.Guild.Id} by user {this.context.User.Id}"); await this.context.Channel.SendMessageAsync( @$"Prefix set. Players who have the same role starting with ""{prefix}"" will be on the same team."); } diff --git a/QuizBowlDiscordScoreTrackerUnitTests/BotCommandHandlerTests.cs b/QuizBowlDiscordScoreTrackerUnitTests/BotCommandHandlerTests.cs index 9ccc4f1..84fae72 100644 --- a/QuizBowlDiscordScoreTrackerUnitTests/BotCommandHandlerTests.cs +++ b/QuizBowlDiscordScoreTrackerUnitTests/BotCommandHandlerTests.cs @@ -492,6 +492,7 @@ public async Task PairChannels() DefaultChannelId, voiceChannelId, voiceChannelName, + DefaultReaderId, out BotCommandHandler handler, out MessageStore messageStore, out IGuildTextChannel textChannel); @@ -527,6 +528,7 @@ public async Task UnpairChannel() DefaultChannelId, voiceChannelId, voiceChannelName, + DefaultReaderId, out BotCommandHandler handler, out MessageStore messageStore, out IGuildTextChannel textChannel); @@ -581,6 +583,7 @@ private void CreateHandler( ulong textChannelId, ulong voiceChannelId, string voiceChannelName, + ulong userId, out BotCommandHandler handler, out MessageStore messageStore, out IGuildTextChannel textChannel) @@ -636,6 +639,9 @@ private void CreateHandler( textChannel = mockMessageChannel.Object; + mockCommandContext + .Setup(context => context.User) + .Returns(CreateGuildUser(userId)); mockCommandContext .Setup(context => context.Channel) .Returns(mockMessageChannel.Object);