diff --git a/Data/GuildConfiguration.cs b/Data/GuildConfiguration.cs index 2daccfd..9d7d73a 100644 --- a/Data/GuildConfiguration.cs +++ b/Data/GuildConfiguration.cs @@ -1,10 +1,6 @@ -using Discord.WebSocket; -using Npgsql; +using Npgsql; using NpgsqlTypes; -using System; using System.Data.Common; -using System.Linq; -using System.Threading.Tasks; namespace BirthdayBot.Data; @@ -170,6 +166,7 @@ class GuildConfiguration { /// not exist in the database. /// public static async Task LoadAsync(ulong guildId, bool nullIfUnknown) { + // TODO nullable static analysis problem: how to indicate non-null return when nullIfUnknown parameter is true? using (var db = await Database.OpenConnectionAsync().ConfigureAwait(false)) { using (var c = db.CreateCommand()) { // Take note of ordinals for the constructor diff --git a/ShardInstance.cs b/ShardInstance.cs index f79d2cc..57ec47e 100644 --- a/ShardInstance.cs +++ b/ShardInstance.cs @@ -1,189 +1,162 @@ using BirthdayBot.BackgroundServices; using BirthdayBot.Data; -using Discord; using Discord.Net; -using Discord.WebSocket; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; using static BirthdayBot.UserInterface.CommandsCommon; -namespace BirthdayBot -{ +namespace BirthdayBot; + +/// +/// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord. +/// +class ShardInstance : IDisposable { + private readonly ShardManager _manager; + private readonly ShardBackgroundWorker _background; + private readonly Dictionary _dispatchCommands; + + public DiscordSocketClient DiscordClient { get; } + public int ShardId => DiscordClient.ShardId; /// - /// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord. + /// Returns a value showing the time in which the last background run successfully completed. /// - class ShardInstance : IDisposable - { - private readonly ShardManager _manager; - private readonly ShardBackgroundWorker _background; - private readonly Dictionary _dispatchCommands; + public DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun; + /// + /// Returns the name of the background service currently in execution. + /// + public string? CurrentExecutingService => _background.CurrentExecutingService; + public Configuration Config => _manager.Config; - public DiscordSocketClient DiscordClient { get; } - public int ShardId => DiscordClient.ShardId; - /// - /// Returns a value showing the time in which the last background run successfully completed. - /// - public DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun; - /// - /// Returns the name of the background service currently in execution. - /// - public string CurrentExecutingService => _background.CurrentExecutingService; - public Configuration Config => _manager.Config; + /// + /// Prepares and configures the shard instances, but does not yet start its connection. + /// + public ShardInstance(ShardManager manager, DiscordSocketClient client, Dictionary commands) { + _manager = manager; + _dispatchCommands = commands; - /// - /// Prepares and configures the shard instances, but does not yet start its connection. - /// - public ShardInstance(ShardManager manager, DiscordSocketClient client, Dictionary commands) - { - _manager = manager; - _dispatchCommands = commands; + DiscordClient = client; + DiscordClient.Log += Client_Log; + DiscordClient.Ready += Client_Ready; + DiscordClient.MessageReceived += Client_MessageReceived; - DiscordClient = client; - DiscordClient.Log += Client_Log; - DiscordClient.Ready += Client_Ready; - DiscordClient.MessageReceived += Client_MessageReceived; - - // Background task constructor begins background processing immediately. - _background = new ShardBackgroundWorker(this); - } - - /// - /// Starts up this shard's connection to Discord and background task handling associated with it. - /// - public async Task StartAsync() - { - await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false); - await DiscordClient.StartAsync().ConfigureAwait(false); - } - - /// - /// Does all necessary steps to stop this shard. This method may block for a few seconds as it waits - /// for the process to finish, but will force its disposal after at most 30 seconds. - /// - public void Dispose() - { - // Unsubscribe from own events - DiscordClient.Log -= Client_Log; - DiscordClient.Ready -= Client_Ready; - DiscordClient.MessageReceived -= Client_MessageReceived; - - _background.Dispose(); - try - { - if (!DiscordClient.LogoutAsync().Wait(15000)) - Log("Instance", "Warning: Client has not yet logged out. Continuing cleanup."); - } - catch (Exception ex) - { - Log("Instance", "Warning: Client threw an exception when logging out: " + ex.Message); - } - try - { - if (!DiscordClient.StopAsync().Wait(5000)) - Log("Instance", "Warning: Client has not yet stopped. Continuing cleanup."); - } - catch (Exception ex) - { - Log("Instance", "Warning: Client threw an exception when stopping: " + ex.Message); - } - - var clientDispose = Task.Run(DiscordClient.Dispose); - if (!clientDispose.Wait(10000)) Log("Instance", "Warning: Client is hanging on dispose. Will continue."); - else Log("Instance", "Shard instance disposed."); - } - - public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message); - - public void RequestDownloadUsers(ulong guildId) => _background.UserDownloader.RequestDownload(guildId); - - #region Event handling - private Task Client_Log(LogMessage arg) - { - // Suppress certain messages - if (arg.Message != null) - { - if (arg.Message.StartsWith("Unknown Dispatch ")) return Task.CompletedTask; - switch (arg.Message) // Connection status messages replaced by ShardManager's output - { - case "Connecting": - case "Connected": - case "Ready": - case "Failed to resume previous session": - case "Resumed previous session": - case "Disconnecting": - case "Disconnected": - case "WebSocket connection was closed": - case "Server requested a reconnect": - return Task.CompletedTask; - } - if (arg.Message == "Heartbeat Errored") - { - // Replace this with a custom message; do not show stack trace - Log("Discord.Net", $"{arg.Severity}: {arg.Message} - {arg.Exception.Message}"); - return Task.CompletedTask; - } - - Log("Discord.Net", $"{arg.Severity}: {arg.Message}"); - } - - if (arg.Exception != null) Log("Discord.Net", arg.Exception.ToString()); - - return Task.CompletedTask; - } - - /// - /// Sets the shard's status to display the help command. - /// - private async Task Client_Ready() => await DiscordClient.SetGameAsync(CommandPrefix + "help"); - - /// - /// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary. - /// - private async Task Client_MessageReceived(SocketMessage msg) - { - if (msg.Channel is not SocketTextChannel channel) return; - if (msg.Author.IsBot || msg.Author.IsWebhook) return; - if (((IMessage)msg).Type != MessageType.Default) return; - var author = (SocketGuildUser)msg.Author; - - // Limit 3: - // For all cases: base command, 2 parameters. - // Except this case: "bb.config", subcommand name, subcommand parameters in a single string - var csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries); - if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase)) - { - // Determine if it's something we're listening for. - if (!_dispatchCommands.TryGetValue(csplit[0][CommandPrefix.Length..], out CommandHandler command)) return; - - // Load guild information here - var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false); - - // Ban check - if (!gconf.IsBotModerator(author)) // skip check if user is a moderator - { - if (await gconf.IsUserBlockedAsync(author.Id)) return; // silently ignore - } - - // Execute the command - try - { - Log("Command", $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}"); - await command(this, gconf, csplit, channel, author); - } - catch (Exception ex) - { - if (ex is HttpException) return; - Log("Command", ex.ToString()); - try - { - channel.SendMessageAsync(":x: An unknown error occurred. It has been reported to the bot owner.").Wait(); - // TODO webhook report - } - catch (HttpException) { } // Fail silently - } - } - } - #endregion + // Background task constructor begins background processing immediately. + _background = new ShardBackgroundWorker(this); } + + /// + /// Starts up this shard's connection to Discord and background task handling associated with it. + /// + public async Task StartAsync() { + await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false); + await DiscordClient.StartAsync().ConfigureAwait(false); + } + + /// + /// Does all necessary steps to stop this shard. This method may block for a few seconds as it waits + /// for the process to finish, but will force its disposal after at most 30 seconds. + /// + public void Dispose() { + // Unsubscribe from own events + DiscordClient.Log -= Client_Log; + DiscordClient.Ready -= Client_Ready; + DiscordClient.MessageReceived -= Client_MessageReceived; + + _background.Dispose(); + try { + if (!DiscordClient.LogoutAsync().Wait(15000)) + Log("Instance", "Warning: Client has not yet logged out. Continuing cleanup."); + } catch (Exception ex) { + Log("Instance", "Warning: Client threw an exception when logging out: " + ex.Message); + } + try { + if (!DiscordClient.StopAsync().Wait(5000)) + Log("Instance", "Warning: Client has not yet stopped. Continuing cleanup."); + } catch (Exception ex) { + Log("Instance", "Warning: Client threw an exception when stopping: " + ex.Message); + } + + var clientDispose = Task.Run(DiscordClient.Dispose); + if (!clientDispose.Wait(10000)) Log("Instance", "Warning: Client is hanging on dispose. Will continue."); + else Log("Instance", "Shard instance disposed."); + } + + public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message); + + #region Event handling + private Task Client_Log(LogMessage arg) { + // TODO revise this some time, filters might need to be modified by now + // Suppress certain messages + if (arg.Message != null) { + if (arg.Message.StartsWith("Unknown Dispatch ") || arg.Message.StartsWith("Missing Channel")) return Task.CompletedTask; + switch (arg.Message) // Connection status messages replaced by ShardManager's output + { + case "Connecting": + case "Connected": + case "Ready": + case "Failed to resume previous session": + case "Resumed previous session": + case "Disconnecting": + case "Disconnected": + case "WebSocket connection was closed": + case "Server requested a reconnect": + return Task.CompletedTask; + } + //if (arg.Message == "Heartbeat Errored") { + // // Replace this with a custom message; do not show stack trace + // Log("Discord.Net", $"{arg.Severity}: {arg.Message} - {arg.Exception.Message}"); + // return Task.CompletedTask; + //} + + Log("Discord.Net", $"{arg.Severity}: {arg.Message}"); + } + + if (arg.Exception != null) Log("Discord.Net", arg.Exception.ToString()); + + return Task.CompletedTask; + } + + /// + /// Sets the shard's status to display the help command. + /// + private async Task Client_Ready() => await DiscordClient.SetGameAsync(CommandPrefix + "help"); + + /// + /// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary. + /// + private async Task Client_MessageReceived(SocketMessage msg) { + if (msg.Channel is not SocketTextChannel channel) return; + if (msg.Author.IsBot || msg.Author.IsWebhook) return; + if (((IMessage)msg).Type != MessageType.Default) return; + var author = (SocketGuildUser)msg.Author; + + // Limit 3: + // For all cases: base command, 2 parameters. + // Except this case: "bb.config", subcommand name, subcommand parameters in a single string + var csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries); + if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase)) { + // Determine if it's something we're listening for. + if (!_dispatchCommands.TryGetValue(csplit[0][CommandPrefix.Length..], out CommandHandler? command)) return; + + // Load guild information here + var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false); + + // Ban check + if (!gconf!.IsBotModerator(author)) // skip check if user is a moderator + { + if (await gconf.IsUserBlockedAsync(author.Id)) return; // silently ignore + } + + // Execute the command + try { + Log("Command", $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}"); + await command(this, gconf, csplit, channel, author); + } catch (Exception ex) { + if (ex is HttpException) return; + Log("Command", ex.ToString()); + try { + channel.SendMessageAsync(":x: An unknown error occurred. It has been reported to the bot owner.").Wait(); + // TODO webhook report + } catch (HttpException) { } // Fail silently + } + } + } + #endregion } diff --git a/ShardManager.cs b/ShardManager.cs index 8f85024..69a0bda 100644 --- a/ShardManager.cs +++ b/ShardManager.cs @@ -41,7 +41,7 @@ class ShardManager : IDisposable { /// a key's corresponding value will temporarily become null instead of the key/value /// pair being removed. /// - private readonly Dictionary _shards; + private readonly Dictionary _shards; private readonly Dictionary _dispatchCommands; private readonly UserCommands _cmdsUser; @@ -73,7 +73,7 @@ class ShardManager : IDisposable { foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2); // Allocate shards based on configuration - _shards = new Dictionary(); + _shards = new Dictionary(); for (int i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++) { _shards.Add(i, null); } @@ -203,7 +203,7 @@ class ShardManager : IDisposable { // Remove dead shards foreach (var dead in deadShards) { - _shards[dead].Dispose(); + _shards[dead]!.Dispose(); _shards[dead] = null; _destroyedShards++; }