From 63c86959459a6e6abcec755dcba406b7bcacfccc Mon Sep 17 00:00:00 2001 From: Ed Halley <1223980+hariedo@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:03:00 -0600 Subject: [PATCH 01/66] Update messages to use preferred usernames Use .GetUserPreferredName() instead of .Mention / .Username. Also, very minor grammar and formatting changes to messages. --- DiscordBot/Services/UserService.cs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index bec0d61b..81c052fb 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -271,7 +271,7 @@ private async Task LevelUp(SocketMessage messageParam, ulong userId) if (level <= 3) return; - await messageParam.Channel.SendMessageAsync($"**{messageParam.Author}** has leveled up !").DeleteAfterTime(60); + await messageParam.Channel.SendMessageAsync($"**{messageParam.Author.GetUserPreferredName()}** has leveled up!").DeleteAfterTime(60); //TODO Add level up card } @@ -411,7 +411,7 @@ public Embed WelcomeMessage(SocketGuildUser user) string icon = user.GetAvatarUrl(); icon = string.IsNullOrEmpty(icon) ? "https://cdn.discordapp.com/embed/avatars/0.png" : icon; - string welcomeString = $"Welcome to Unity Developer Community {user.GetPreferredAndUsername()}!"; + string welcomeString = $"Welcome to Unity Developer Community, {user.GetPreferredAndUsername()}!"; var builder = new EmbedBuilder() .WithDescription(welcomeString) .WithColor(_welcomeColour) @@ -460,10 +460,10 @@ public async Task Thanks(SocketMessage messageParam) if (_thanksCooldown.HasUser(userId)) { await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} you must wait " + + $"{messageParam.Author.GetUserPreferredName()}, you must wait " + $"{DateTime.Now - _thanksCooldown[userId]:ss} " + "seconds before giving another karma point." + Environment.NewLine + - "(In the future, if you are trying to thank multiple people, include all their names in the thanks message)") + "(In the future, if you are trying to thank multiple people, include all their names in the thanks message.)") .DeleteAfterTime(defaultDelTime); return; } @@ -478,7 +478,7 @@ await messageParam.Channel.SendMessageAsync( var mentionedSelf = false; var mentionedBot = false; var sb = new StringBuilder(); - sb.Append("**").Append(messageParam.Author.Username).Append("** gave karma to **"); + sb.Append("**").Append(messageParam.Author.GetUserPreferredName()).Append("** gave karma to **"); foreach (var user in mentions) { if (user.IsBot) @@ -494,18 +494,19 @@ await messageParam.Channel.SendMessageAsync( } await _databaseService.Query().IncrementKarma(user.Id.ToString()); - sb.Append(user.Username).Append(" , "); + sb.Append(user.GetUserPreferredName()).Append("**, **"); } // Even if a user gives multiple karma in one message, we only add one. var authorKarmaGiven = await _databaseService.Query().GetKarmaGiven(messageParam.Author.Id.ToString()); await _databaseService.Query().UpdateKarmaGiven(messageParam.Author.Id.ToString(), authorKarmaGiven + 1); - sb.Length -= 2; //Removes last instance of appended comma without convoluted tracking - sb.Append("**"); + sb.Length -= 4; //Removes last instance of appended comma/startbold without convoluted tracking + //sb.Append("**"); // Already appended an endbold + sb.Append("."); if (mentionedSelf) await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} you can't give karma to yourself.").DeleteAfterTime(defaultDelTime); + $"{messageParam.Author.GetUserPreferredName()}, you can't give karma to yourself.").DeleteAfterTime(defaultDelTime); _canEditThanks.Remove(messageParam.Id); @@ -550,7 +551,7 @@ public async Task CodeCheck(SocketMessage messageParam) { // A ``` codeblock was found, but no CS, let 'em know await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{CodeReminderFormattingExample}") + $"{messageParam.Author.GetUserPreferredName()}, when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{CodeReminderFormattingExample}") .DeleteAfterSeconds(seconds: 60); return; } @@ -569,7 +570,7 @@ await messageParam.Channel.SendMessageAsync( { //! CodeReminderCooldown.AddCooldown(userId, _codeReminderCooldownTime); await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} are you sharing c# scripts? Remember to use codeblocks to help readability!\n{CodeReminderFormattingExample}") + $"{messageParam.Author.GetUserPreferredName()}, are you sharing C# scripts? Remember to use codeblocks to help readability!\n{CodeReminderFormattingExample}") .DeleteAfterSeconds(seconds: 60); if (content.Length > _maxCodeBlockLengthWarning) { @@ -605,7 +606,8 @@ private async Task ScoldForAtEveryoneUsage(SocketMessage messageParam) DateTime.Now.AddSeconds(_settings.EveryoneScoldPeriodSeconds); await messageParam.Channel.SendMessageAsync( - $"Please don't try to alert **everyone** on the server {messageParam.Author.Mention}!\nIf you are asking a question, people will help you when they have time.") + $"Please don't try to alert **everyone** on the server, {messageParam.Author.GetUserPreferredName()}!\n" + + "If you are asking a question, people will help you when they have time.") .DeleteAfterTime(minutes: 2); } } From 8e81ab9808c307d963f282421f9d17e95374986e Mon Sep 17 00:00:00 2001 From: Ed Halley <1223980+hariedo@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:15:32 -0600 Subject: [PATCH 02/66] Revert some messages using Mention instead of GetUserPreferredName() Revert some messages to use @username (.Mention) again. --- DiscordBot/Services/UserService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index 81c052fb..3d513292 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -460,7 +460,7 @@ public async Task Thanks(SocketMessage messageParam) if (_thanksCooldown.HasUser(userId)) { await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.GetUserPreferredName()}, you must wait " + + $"{messageParam.Author.Mention} you must wait " + $"{DateTime.Now - _thanksCooldown[userId]:ss} " + "seconds before giving another karma point." + Environment.NewLine + "(In the future, if you are trying to thank multiple people, include all their names in the thanks message.)") @@ -506,7 +506,7 @@ await messageParam.Channel.SendMessageAsync( sb.Append("."); if (mentionedSelf) await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.GetUserPreferredName()}, you can't give karma to yourself.").DeleteAfterTime(defaultDelTime); + $"{messageParam.Author.Mention} you can't give karma to yourself.").DeleteAfterTime(defaultDelTime); _canEditThanks.Remove(messageParam.Id); @@ -551,7 +551,7 @@ public async Task CodeCheck(SocketMessage messageParam) { // A ``` codeblock was found, but no CS, let 'em know await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.GetUserPreferredName()}, when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{CodeReminderFormattingExample}") + $"{messageParam.Author.Mention} when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{CodeReminderFormattingExample}") .DeleteAfterSeconds(seconds: 60); return; } @@ -570,7 +570,7 @@ await messageParam.Channel.SendMessageAsync( { //! CodeReminderCooldown.AddCooldown(userId, _codeReminderCooldownTime); await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.GetUserPreferredName()}, are you sharing C# scripts? Remember to use codeblocks to help readability!\n{CodeReminderFormattingExample}") + $"{messageParam.Author.Mention} are you sharing C# scripts? Remember to use codeblocks to help readability!\n{CodeReminderFormattingExample}") .DeleteAfterSeconds(seconds: 60); if (content.Length > _maxCodeBlockLengthWarning) { @@ -606,7 +606,7 @@ private async Task ScoldForAtEveryoneUsage(SocketMessage messageParam) DateTime.Now.AddSeconds(_settings.EveryoneScoldPeriodSeconds); await messageParam.Channel.SendMessageAsync( - $"Please don't try to alert **everyone** on the server, {messageParam.Author.GetUserPreferredName()}!\n" + + $"Please don't try to alert **everyone** on the server, {messageParam.Author.Mention}!\n" + "If you are asking a question, people will help you when they have time.") .DeleteAfterTime(minutes: 2); } From 679cc644a6b51c619826d5fa8bc9b10c8615f06d Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 4 Dec 2023 20:11:43 +1000 Subject: [PATCH 03/66] Remove users after enumeration What a stupid mistake --- DiscordBot/Services/UserService.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index 3d513292..9b90425f 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -680,6 +680,7 @@ private async Task DelayedWelcomeService() await Task.Delay(10000); try { + List toRemove = new(); while (true) { var now = DateTime.Now; @@ -689,8 +690,22 @@ private async Task DelayedWelcomeService() { currentlyProcessedUserId = userData.id; await ProcessWelcomeUser(userData.id, null); + + toRemove.Add(userData.id); } + // Remove all the users we've welcomed from the list + if (toRemove.Count > 0) + { + _welcomeNoticeUsers.RemoveAll(u => toRemove.Contains(u.id)); + toRemove.Clear(); + // Prevent the list from growing too large, not that it really matters. + if (toRemove.Capacity > 20) + { + toRemove.Capacity = 20; + } + } + if (firstRun) firstRun = false; await Task.Delay(10000); @@ -723,9 +738,6 @@ private async Task ProcessWelcomeUser(ulong userID, IUser user = null) { if (_welcomeNoticeUsers.Exists(u => u.id == userID)) { - // Remove the user from the welcome list. - _welcomeNoticeUsers.RemoveAll(u => u.id == userID); - // If we didn't get the user passed in, we try grab it user ??= await _client.GetUserAsync(userID); // if they're null, they've likely left, so we just remove them from the list. From 6c2aa1ad103ac0bcb8b5639246610a34038b9caf Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 4 Dec 2023 20:21:57 +1000 Subject: [PATCH 04/66] Maybe fix `User Left -` event crashing out --- DiscordBot/Services/UserService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index 9b90425f..a06c214e 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -171,7 +171,7 @@ await _loggingService.LogAction( else { await _loggingService.LogAction( - $"User `{guildUser.GetPreferredAndUsername()}` - ID : `{user.Id}` - Left at {DateTime.Now}"); + $"User Left - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}` - Left at {DateTime.Now}"); } } From e56ee9da4194e31ba19f2310a4e8e2065401567c Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 4 Dec 2023 20:28:48 +1000 Subject: [PATCH 05/66] Ignore deleted messages if author is missing --- DiscordBot/Services/ModerationService.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index 1e6e30c6..a09b5de1 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -25,6 +25,10 @@ private async Task MessageDeleted(Cacheable message, Cacheable< { if (message.Value.Author.IsBot || channel.Id == _settings.BotAnnouncementChannel.Id) return; + // Check the author is even in the guild + var guildUser = message.Value.Author as SocketGuildUser; + if (guildUser == null) + return; var content = message.Value.Content; if (content.Length > MaxMessageLength) From de5019db64ed7d9f035fd0b6c1edfe3a287b50b7 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 4 Jan 2024 11:45:41 +1000 Subject: [PATCH 06/66] Additional extensions for Context to determine reply/mentions --- DiscordBot/Extensions/ContextExtension.cs | 36 +++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/DiscordBot/Extensions/ContextExtension.cs b/DiscordBot/Extensions/ContextExtension.cs index 0ca1ebcb..4f25d1c9 100644 --- a/DiscordBot/Extensions/ContextExtension.cs +++ b/DiscordBot/Extensions/ContextExtension.cs @@ -5,10 +5,42 @@ namespace DiscordBot.Extensions; public static class ContextExtension { /// - /// Returns true if the context includes a RoleID, UserID or Mentions Everyone (Should include @here, unsure) + /// Sanity test to confirm a Context doesn't contain role or everyone mentions. /// + /// Use `HasAnyPingableMention` to also include user mentions. + public static bool HasRoleOrEveryoneMention(this ICommandContext context) + { + return context.Message.MentionedRoleIds.Count != 0 || context.Message.MentionedEveryone; + } + + /// + /// True if the context includes a RoleID, UserID or Mentions Everyone (Should include @here, unsure) + /// + /// Use `HasRoleOrEveryoneMention` to check for ONLY RoleIDs or Everyone mentions. public static bool HasAnyPingableMention(this ICommandContext context) { - return context.Message.MentionedUserIds.Count > 0 || context.Message.MentionedRoleIds.Count > 0 || context.Message.MentionedEveryone; + return context.Message.MentionedUserIds.Count > 0 || context.HasRoleOrEveryoneMention(); + } + + /// + /// True if the Context contains a message that is a reply and only mentions the user that sent the message. + /// ie; the message is a reply to the user but doesn't contain any other mentions. + /// + public static bool IsOnlyReplyingToAuthor(this ICommandContext context) + { + if (!context.IsReply()) + return false; + if (context.Message.MentionedUserIds.Count != 1) + return false; + return context.Message.MentionedUserIds.First() == context.Message.ReferencedMessage.Author.Id; + } + + /// + /// Returns true if the Context has a reference to another message. + /// ie; the message is a reply to another message. + /// + public static bool IsReply(this ICommandContext context) + { + return context.Message.ReferencedMessage != null; } } \ No newline at end of file From 5ae4d7966678699359453005d54b2c4eab966a3e Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 4 Jan 2024 11:46:29 +1000 Subject: [PATCH 07/66] Let `Curr` work from response --- DiscordBot/Modules/UserModule.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index 64619d39..74584883 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -1146,15 +1146,22 @@ public async Task ConvertCurrency(string from, string to = "usd") { await ConvertCurrency(1, from, to); } - + [Command("Currency"), Priority(29)] [Summary("Converts a currency. Syntax : !currency amount fromCurrency toCurrency")] [Alias("curr")] public async Task ConvertCurrency(double amount, string from, string to = "usd") { if (Context.HasAnyPingableMention()) - return; - + { + // Only continue command if the user is replying to a message + if (!Context.IsReply()) + return; + // And that mention is only the author of the replied message + if (!Context.IsOnlyReplyingToAuthor()) + return; + } + from = from.ToLower(); to = to.ToLower(); From dfca2e6be85fdef5f0f75b6879f581cc1a78a1a0 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 4 Jan 2024 11:47:48 +1000 Subject: [PATCH 08/66] WebUtil to simplify basic fetch requests from url with deserializer --- DiscordBot/Utils/WebUtil.cs | 81 +++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 DiscordBot/Utils/WebUtil.cs diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebUtil.cs new file mode 100644 index 00000000..896ea608 --- /dev/null +++ b/DiscordBot/Utils/WebUtil.cs @@ -0,0 +1,81 @@ +using System.Net.Http; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; + +namespace DiscordBot.Utils; + +public static class WebUtil +{ + /// + /// Returns the content of a URL as a string, or an empty string if the request fails. + /// + public static async Task GetContent(string url) + { + using var client = new HttpClient(); + try + { + var response = await client.GetAsync(url); + return await response.Content.ReadAsStringAsync(); + } + catch (Exception e) + { + LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + return ""; + } + } + + /// + /// Returns the content of a url as a sanitized XML string, or an empty string if the request fails. + /// + public static async Task GetXMLContent(string url) + { + try + { + var content = await GetContent(url); + // We check if we're dealing with XML and sanitize it, otherwise we just return the content + if (content.StartsWith(" + /// Returns a deserialized object from a JSON string. If the string is empty or can't be deserialized, it returns the default value of the type. + /// + public static async Task GetObjectFromJson(string url) + { + try + { + var content = await GetContent(url); + return JsonConvert.DeserializeObject(content) ?? default; + } + catch (Exception e) + { + LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + return default; + } + } + + /// + /// Returns a deserialized object from a JSON string, or null if the string is empty or can't be deserialized. + /// + public static async Task<(bool success, T result)> TryGetObjectFromJson(string url) + { + try + { + var content = await GetContent(url); + var result = JsonConvert.DeserializeObject(content); + return (true, result); + } + catch (Exception e) + { + LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + return (false, default); + } + } +} \ No newline at end of file From 28098a1f0fc0f5ac3f2b71437f85ba38f04fa580 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 4 Jan 2024 11:49:59 +1000 Subject: [PATCH 09/66] Cleanup `Currency` and `Feed` service with WebUtil - Use WebUtil to fetch data - Log currency dictionary init (console) - Remove unrequired usings --- DiscordBot/Services/CurrencyService.cs | 41 ++++++++++---------------- DiscordBot/Services/FeedService.cs | 11 ++----- 2 files changed, 18 insertions(+), 34 deletions(-) diff --git a/DiscordBot/Services/CurrencyService.cs b/DiscordBot/Services/CurrencyService.cs index da47d95d..c27f33b4 100644 --- a/DiscordBot/Services/CurrencyService.cs +++ b/DiscordBot/Services/CurrencyService.cs @@ -1,12 +1,12 @@ -using System.IO; -using System.Net; -using System.Net.Http; +using DiscordBot.Utils; using Newtonsoft.Json.Linq; namespace DiscordBot.Services; public class CurrencyService { + private const string ServiceName = "CurrencyService"; + #region Configuration private const int ApiVersion = 1; @@ -21,7 +21,7 @@ private class Currency #endregion // Configuration - private readonly Dictionary _currencies = new Dictionary(); + private readonly Dictionary _currencies = new(); private static readonly string ApiUrl = $"https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@{ApiVersion}/latest/"; @@ -31,12 +31,17 @@ public async Task GetConversion(string toCurrency, string fromCurrency = fromCurrency = fromCurrency.ToLower(); var url = $"{ApiUrl}{ExchangeRatesEndpoint}/{fromCurrency.ToLower()}/{toCurrency.ToLower()}.min.json"; - var response = await GetResponse(url); - if (string.IsNullOrEmpty(response)) + + // Check if success + var (success, response) = await WebUtil.TryGetObjectFromJson(url); + if (!success) return -1; - var json = JObject.Parse(response); - return json[$"{toCurrency}"].Value(); + // Check currency exists in response + response.TryGetValue($"{toCurrency}", out var value); + if (value == null) + return -1; + return value.Value(); } #region Public Methods @@ -64,10 +69,7 @@ public async Task IsCurrency(string currency) private async Task BuildCurrencyList() { var url = ApiUrl + ValidCurrenciesEndpoint; - var client = new HttpClient(); - var response = await client.GetAsync(url); - var json = await response.Content.ReadAsStringAsync(); - var currencies = JObject.Parse(json); + var currencies = await WebUtil.GetObjectFromJson>(url); // Json is weird format of `Code: Name` each in dependant ie; {"1inch":"1inch Network","aave":"Aave"} foreach (var currency in currencies) @@ -78,21 +80,8 @@ private async Task BuildCurrencyList() Short = currency.Key }); } - } - - private async Task GetResponse(string url) - { - string jsonString = string.Empty; - - using var client = new HttpClient(); - var response = await client.GetAsync(url); - if (response.IsSuccessStatusCode) - { - jsonString = await response.Content.ReadAsStringAsync(); - } - - return jsonString; + LoggingService.LogToConsole($"[{ServiceName}] Built currency list with {_currencies.Count} currencies.", ExtendedLogSeverity.Positive); } #endregion // Private Methods diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index b316dffc..5048c9bc 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -1,5 +1,4 @@ using System.IO; -using System.Net.Http; using System.ServiceModel.Syndication; using System.Xml; using Discord.WebSocket; @@ -70,10 +69,8 @@ private async Task GetFeedData(string url) SyndicationFeed feed = null; try { - var client = new HttpClient(); - var response = await client.GetStringAsync(url); - response = Utils.Utils.SanitizeXml(response); - var reader = XmlReader.Create(new StringReader(response)); + var content = await Utils.WebUtil.GetXMLContent(url); + var reader = XmlReader.Create(new StringReader(content)); feed = SyndicationFeed.Load(reader); } catch (Exception e) @@ -82,9 +79,7 @@ private async Task GetFeedData(string url) } // Return the feed, empty feed if null to prevent additional checks for null on return - if (feed == null) - feed = new SyndicationFeed(); - return feed; + return feed ??= new SyndicationFeed(); } #region Feed Handlers From 0273334c7923becfa7d5c57caef42a64fa5b3221 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 4 Jan 2024 14:14:57 +1000 Subject: [PATCH 10/66] Bump MessageCacheSize to 200 to 1024 for better delete tracking Doesn't appear that this affects run-time memory use all that much, but may be worth watching. --- DiscordBot/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 283c78ae..f9b9cc78 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -37,7 +37,7 @@ private async Task MainAsync() { LogLevel = LogSeverity.Verbose, AlwaysDownloadUsers = true, - MessageCacheSize = 200, + MessageCacheSize = 1024, GatewayIntents = GatewayIntents.All, }); _client.Log += LoggingService.DiscordNetLogger; From 25429fbc872b8ead3035b653a7fb6df5f71b46d5 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 4 Jan 2024 14:18:11 +1000 Subject: [PATCH 11/66] Update classes to use new logging method Simplify some of the calls, replace some static logging use with loggingService --- DiscordBot/Modules/ModerationModule.cs | 18 ++-- DiscordBot/Modules/ReactionRoleModule.cs | 2 +- DiscordBot/Modules/UserModule.cs | 9 +- DiscordBot/Services/DatabaseService.cs | 35 ++++---- DiscordBot/Services/FeedService.cs | 9 +- DiscordBot/Services/LoggingService.cs | 106 +++++++++++++++++++---- DiscordBot/Services/ModerationService.cs | 7 +- DiscordBot/Services/ReactRoleService.cs | 5 +- DiscordBot/Services/ReminderService.cs | 8 +- DiscordBot/Services/UserService.cs | 24 ++--- 10 files changed, 145 insertions(+), 78 deletions(-) diff --git a/DiscordBot/Modules/ModerationModule.cs b/DiscordBot/Modules/ModerationModule.cs index 198c364b..59663444 100644 --- a/DiscordBot/Modules/ModerationModule.cs +++ b/DiscordBot/Modules/ModerationModule.cs @@ -52,7 +52,7 @@ public async Task MuteUser(IUser user, uint arg) await u.AddRoleAsync(Context.Guild.GetRole(Settings.MutedRoleId)); var reply = await ReplyAsync($"User {user} has been muted for {Utils.Utils.FormatTime(arg)} ({arg} seconds)."); - await LoggingService.LogAction( + await LoggingService.LogChannelAndFile( $"{Context.User.Username} has muted {u.Username} ({u.Id}) for {Utils.Utils.FormatTime(arg)} ({arg} seconds)."); UserService.MutedUsers.AddCooldown(u.Id, (int)arg, ignoreExisting: true); @@ -105,7 +105,7 @@ public async Task MuteUser(IUser user, uint seconds, params string[] messages) var reply = await ReplyAsync($"User {user} has been muted for {Utils.Utils.FormatTime(seconds)} ({seconds} seconds). Reason : {message}"); - await LoggingService.LogAction( + await LoggingService.LogChannelAndFile( $"{Context.User.Username} has muted {u.Username} ({u.Id}) for {Utils.Utils.FormatTime(seconds)} ({seconds} seconds). Reason : {message}"); var dm = await user.CreateDMChannelAsync(new RequestOptions()); @@ -117,7 +117,7 @@ await LoggingService.LogAction( await botCommandChannel.SendMessageAsync( $"I could not DM you {user.Mention}!\nYou have been muted from UDC for **{Utils.Utils.FormatTime(seconds)}** for the following reason : **{message}**. " + "This is not appealable and any tentative to avoid it will result in your permanent ban."); - await LoggingService.LogAction($"User {user.Username} has DM blocked and the mute reason couldn't be sent.", true, false); + await LoggingService.Log(LogBehaviour.Channel, $"User {user.Username} has DM blocked and the mute reason couldn't be sent."); } UserService.MutedUsers.AddCooldown(u.Id, (int)seconds, ignoreExisting: true); @@ -163,7 +163,7 @@ public async Task AddRole(IRole role, IUser user) var u = user as IGuildUser; await u.AddRoleAsync(role); await ReplyAsync("Role " + role + " has been added to " + user).DeleteAfterTime(minutes: 5); - await LoggingService.LogAction($"{contextUser.Username} has added role {role} to {u.Username}"); + await LoggingService.LogChannelAndFile($"{contextUser.Username} has added role {role} to {u.Username}"); return; } @@ -185,7 +185,7 @@ public async Task RemoveRole(IRole role, IUser user) await u.RemoveRoleAsync(role); await ReplyAsync("Role " + role + " has been removed from " + user).DeleteAfterTime(minutes: 5); - await LoggingService.LogAction($"{contextUser.Username} has removed role {role} from {u.Username}"); + await LoggingService.LogChannelAndFile($"{contextUser.Username} has removed role {role} from {u.Username}"); return; } @@ -204,7 +204,7 @@ public async Task ClearMessages(int count) await channel.DeleteMessagesAsync(messages); await ReplyAsync("Messages deleted.").DeleteAfterSeconds(seconds: 5); - await LoggingService.LogAction($"{Context.User.Username} has removed {count} messages from {Context.Channel.Name}"); + await LoggingService.LogChannelAndFile($"{Context.User.Username} has removed {count} messages from {Context.Channel.Name}"); } [Command("Clear")] @@ -220,7 +220,7 @@ public async Task ClearMessages(ulong messageId) await channel.DeleteMessagesAsync(enumerable); await ReplyAsync("Messages deleted.").DeleteAfterSeconds(seconds: 5); - await LoggingService.LogAction($"{Context.User.Username} has removed {enumerable.Count} messages from {Context.Channel.Name}"); + await LoggingService.LogChannelAndFile($"{Context.User.Username} has removed {enumerable.Count} messages from {Context.Channel.Name}"); } [Command("Kick")] @@ -233,7 +233,7 @@ internal async Task KickUser(IUser user) var u = user as IGuildUser; await u.KickAsync(); - await LoggingService.LogAction($"{Context.User.Username} has kicked {u.Username}"); + await LoggingService.LogChannelAndFile($"{Context.User.Username} has kicked {u.Username}"); } [Command("Ban")] @@ -245,7 +245,7 @@ public async Task BanUser(IUser user, params string[] reasons) var reason = string.Join(' ', reasons); await Context.Guild.AddBanAsync(user, 7, reason, RequestOptions.Default); - await LoggingService.LogAction($"{Context.User.Username} has banned {user.Username} with the reason \"{reasons}\""); + await LoggingService.LogChannelAndFile($"{Context.User.Username} has banned {user.Username} with the reason \"{reasons}\""); } [Command("Rules")] diff --git a/DiscordBot/Modules/ReactionRoleModule.cs b/DiscordBot/Modules/ReactionRoleModule.cs index 5cafe647..33fb26a7 100644 --- a/DiscordBot/Modules/ReactionRoleModule.cs +++ b/DiscordBot/Modules/ReactionRoleModule.cs @@ -226,7 +226,7 @@ public async Task DeleteReactionRoleConfig(uint messageId) ReactRoleService.ReactSettings.UserReactRoleList.Remove(foundMessage); await ReplyAsync( "Deleted the configuration for that ID, use \"!reactrole restart\" to enable these changes."); - await LoggingService.LogAction( + await LoggingService.LogChannelAndFile( $"{Context.User} deleted the reactionrole configuration for `{foundMessage}`."); } diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index 74584883..73547cf4 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -283,7 +283,7 @@ public async Task AddRoleUser(IRole role) await u.AddRoleAsync(role); await ReplyAsync($"{u.Username} you now have the `{role.Name}` role."); - await LoggingService.LogAction($"{Context.User.Username} has added {role} to themself."); + await LoggingService.LogChannelAndFile($"{Context.User.Username} has added {role} to themself."); } [Command("Remove")] @@ -301,7 +301,7 @@ public async Task RemoveRoleUser(IRole role) await u.RemoveRoleAsync(role); await ReplyAsync($"{u.Username} your `{role.Name}` role has been removed"); - await LoggingService.LogAction($"{Context.User.Username} has removed role {role} from themself."); + await LoggingService.LogChannelAndFile($"{Context.User.Username} has removed role {role} from themself."); } [Command("List")] @@ -505,7 +505,7 @@ private async Task GenerateRankEmbedFromList(List<(ulong userID, uint val } catch (Exception e) { - await LoggingService.LogAction($"Failed to generate top 10 embed.\n{e}", true, false); + await LoggingService.LogChannelAndFile($"Failed to generate top 10 embed.\n{e}", ExtendedLogSeverity.LowWarning); embedBuilder.Description = "Failed to generate top 10 embed."; } @@ -539,7 +539,8 @@ public async Task DisplayProfile(IUser user) } catch (Exception e) { - await LoggingService.LogAction($"Error while generating profile card for {user.Username}.\nEx:{e}", true, false); + await LoggingService.LogAction($"Error while generating profile card for {user.Username}.\nEx:{e}", + ExtendedLogSeverity.LowWarning); } } diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 5c10c87d..cdf99120 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -4,7 +4,6 @@ using Insight.Database; using MySql.Data.MySqlClient; - namespace DiscordBot.Services; public class DatabaseService @@ -41,13 +40,13 @@ public DatabaseService(ILoggingService logging, BotSettings settings) try { var userCount = await _connection.TestConnection(); - await _logging.LogAction($"{ServiceName}: Connected to database successfully. {userCount} users in database."); - LoggingService.LogToConsole($"{ServiceName}: Connected to database successfully. {userCount} users in database.", ExtendedLogSeverity.Positive); + await _logging.LogAction( + $"{ServiceName}: Connected to database successfully. {userCount} users in database.", + ExtendedLogSeverity.Positive); } catch { - LoggingService.LogToConsole( - "DatabaseService: Table 'users' does not exist, attempting to generate table.", + await _logging.LogAction("DatabaseService: Table 'users' does not exist, attempting to generate table.", ExtendedLogSeverity.LowWarning); try { @@ -60,13 +59,13 @@ public DatabaseService(ILoggingService logging, BotSettings settings) } catch (Exception e) { - LoggingService.LogToConsole( + await _logging.LogAction( $"SQL Exception: Failed to generate table 'users'.\nMessage: {e}", - LogSeverity.Critical); + ExtendedLogSeverity.Critical); c.Close(); return; } - LoggingService.LogToConsole("DatabaseService: Table 'users' generated without errors.", + await _logging.LogAction("DatabaseService: Table 'users' generated without errors.", ExtendedLogSeverity.Positive); c.Close(); } @@ -84,8 +83,8 @@ public DatabaseService(ILoggingService logging, BotSettings settings) } catch (Exception e) { - LoggingService.LogToConsole($"SQL Exception: Failed to generate leaderboard events.\nMessage: {e}", - LogSeverity.Warning); + await _logging.LogAction($"SQL Exception: Failed to generate leaderboard events.\nMessage: {e}", + ExtendedLogSeverity.Warning); } }); } @@ -129,7 +128,7 @@ await message.ModifyAsync(properties => }); } - await _logging.LogAction( + await _logging.LogChannelAndFile( $"Database Synchronized {counter.ToString()} Users Successfully.\n{newAdd.ToString()} missing users added."); } @@ -148,15 +147,14 @@ public async Task AddNewUser(SocketGuildUser socketUser) await Query().InsertUser(user); - await _logging.LogAction( - $"User {socketUser.GetPreferredAndUsername()} successfully added to the database.", - true, - false); + await _logging.Log(LogBehaviour.File, + $"User {socketUser.GetPreferredAndUsername()} successfully added to the database."); } catch (Exception e) { - await _logging.LogAction( - $"Error when trying to add user {socketUser.Id.ToString()} to the database : {e}", true, false); + // We don't print to channel as this could be spammy (Albeit rare) + await _logging.Log(LogBehaviour.Console | LogBehaviour.File, + $"Error when trying to add user {socketUser.Id.ToString()} to the database : {e}", ExtendedLogSeverity.Warning); } } @@ -170,7 +168,8 @@ public async Task DeleteUser(ulong id) } catch (Exception e) { - await _logging.LogAction($"Error when trying to delete user {id.ToString()} from the database : {e}", true, false); + await _logging.Log(LogBehaviour.Console | LogBehaviour.File, + $"Error when trying to delete user {id.ToString()} from the database : {e}", ExtendedLogSeverity.Warning); } } diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index 5048c9bc..ab3af744 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -89,11 +89,9 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c try { var feed = await GetFeedData(newsFeed.Url); - var channel = _client.GetChannel(channelId) as IForumChannel; - if (channel == null) + if (_client.GetChannel(channelId) is not IForumChannel channel) { - await _logging.LogAction($"[{ServiceName}] Error: Channel {channelId} not found", true, true); - LoggingService.LogToConsole($"[{ServiceName}] Error: Channel {channelId} not found", LogSeverity.Error); + await _logging.LogAction($"[{ServiceName}] Error: Channel {channelId} not found", ExtendedLogSeverity.Error); return; } foreach (var item in feed.Items.Take(MaximumCheck)) @@ -151,8 +149,7 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c } catch (Exception e) { - LoggingService.LogToConsole(e.ToString(), LogSeverity.Error); - await _logging.LogAction($"[{ServiceName}] Error: {e.ToString()}", true, true); + await _logging.LogAction($"[{ServiceName}] Error: {e}", ExtendedLogSeverity.Error); } } diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index 7ae29aa6..eda538b9 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -21,6 +21,26 @@ public enum ExtendedLogSeverity LowWarning = 11, // LowWarning(warning) is yellow in the console } +/// +/// An enum for specifying which logging behaviour to use. Can be combined with bitwise OR. +/// +/// +/// When adding new behaviours, ensure that the value is a power of 2 (1, 2, 4, 8, 16, etc). +/// Do not add a "ALL" value, as this could be dangerous for future additions depending on how it's used. +/// If adding behaviours, maybe rename `LogAction` to avoid confusion unless there is no chance of ambiguity or conflict. +/// +[Flags] +public enum LogBehaviour +{ + None = 0, + Console = 1, + Channel = 2, + File = 4, + // Common combinations + ChannelAndFile = Channel | File, + ConsoleChannelAndFile = Console | Channel | File, +} + public static class ExtendedLogSeverityExtensions { public static LogSeverity ToLogSeverity(this ExtendedLogSeverity severity) @@ -44,29 +64,51 @@ public static ExtendedLogSeverity ToExtended(this LogSeverity severity) public class LoggingService : ILoggingService { - private readonly DiscordSocketClient _client; - + private const string ServiceName = "LoggingService"; + private readonly BotSettings _settings; + private readonly ISocketMessageChannel _logChannel; public LoggingService(DiscordSocketClient client, BotSettings settings) { - _client = client; _settings = settings; - } - - public async Task LogAction(string action, bool logToFile = true, bool logToChannel = true, Embed embed = null) - { - if (logToChannel) + + // INIT + if (_settings.BotAnnouncementChannel == null) { - var channel = _client.GetChannel(_settings.BotAnnouncementChannel.Id) as ISocketMessageChannel; - await channel.SendMessageAsync(action, false, embed); + LogToConsole($"[{ServiceName}] Error: Logging Channel not set in settings.json", LogSeverity.Error); + return; + } + _logChannel = client.GetChannel(_settings.BotAnnouncementChannel.Id) as ISocketMessageChannel; + if (_logChannel == null) + { + LogToConsole($"[{ServiceName}] Error: Logging Channel {_settings.BotAnnouncementChannel.Id} not found", LogSeverity.Error); } - - if (logToFile) - await File.AppendAllTextAsync(_settings.ServerRootPath + @"/log.txt", - $"[{ConsistentDateTimeFormat()}] {action} {Environment.NewLine}"); } - + + public async Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) + { + if (behaviour.HasFlag(LogBehaviour.Console)) + LogToConsole(message, severity); + if (behaviour.HasFlag(LogBehaviour.Channel)) + await LogToChannel(message, severity, embed); + if (behaviour.HasFlag(LogBehaviour.File)) + await LogToFile(message, severity); + } + + public async Task LogToChannel(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) + { + if (_logChannel == null) + return; + await _logChannel.SendMessageAsync(message, false, embed); + } + + public async Task LogToFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) + { + await File.AppendAllTextAsync(_settings.ServerRootPath + @"/log.txt", + $"[{ConsistentDateTimeFormat()}] - [{severity}] - {message} {Environment.NewLine}"); + } + public void LogXp(string channel, string user, float baseXp, float bonusXp, float xpReduce, int totalXp) { File.AppendAllText(_settings.ServerRootPath + @"/logXP.txt", @@ -82,7 +124,7 @@ public static string ConsistentDateTimeFormat() // Logs DiscordNet specific messages, this shouldn't be used for normal logging public static Task DiscordNetLogger(LogMessage message) { - LoggingService.LogToConsole($"{message.Source} | {message.Message}", message.Severity.ToExtended()); + LogToConsole($"{message.Source} | {message.Message}", message.Severity.ToExtended()); return Task.CompletedTask; } #region Console Messages @@ -100,6 +142,7 @@ public static void LogToConsole(string message, ExtendedLogSeverity severity = E Console.ForegroundColor = restoreColour; } + public static void LogToConsole(string message, LogSeverity severity) => LogToConsole(message, severity.ToExtended()); public static void LogServiceDisabled(string service, string varName) @@ -154,8 +197,37 @@ private static void SetConsoleColour(ExtendedLogSeverity severity) #endregion } +/// +/// Interface for the LoggingService, this is only really required if you want to use DI. +/// Logging to console and file is still available without this through the static methods. +/// +/// +/// There is also DebugLog (LoggingService), which is only included in debug builds which is useful for more verbose logging during development. +/// public interface ILoggingService { - Task LogAction(string action, bool logToFile = true, bool logToChannel = true, Embed embed = null); void LogXp(string channel, string user, float baseXp, float bonusXp, float xpReduce, int totalXp); + + /// + /// Standard logging, this will log to console, channel and file depending on the behaviour. + /// + /// Where logs go, Console, Channel, File (Or some combination) + /// Message + /// Info, Error, Warn, etc (Included in File and Console logging) + /// Embed, only used by Channel Logging + Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null); + + /// + /// 'Short hand' for logging to all CURRENT supported behaviours, console, channel and file. + /// Same as calling `Log(LogBehaviour.ConsoleChannelAndFile, message, severity, embed);` + /// + Task LogAction(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => + Log(LogBehaviour.ConsoleChannelAndFile, message, severity, embed); + + /// + /// 'Short hand' for logging to channel and file. + /// Same as calling `Log(LogBehaviour.ChannelAndFile, message, severity, embed);` + /// + Task LogChannelAndFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => + Log(LogBehaviour.ChannelAndFile, message, severity, embed); } \ No newline at end of file diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index a09b5de1..83056924 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -54,10 +54,10 @@ private async Task MessageDeleted(Cacheable message, Cacheable< // TimeStamp for the Footer + await _loggingService.LogAction( $"User {user.GetPreferredAndUsername()} has " + - $"deleted the message\n{content}\n from channel #{(await channel.GetOrDownloadAsync()).Name}", true, false); - await _loggingService.LogAction(" ", false, true, embed); + $"deleted the message\n{content}\n from channel #{(await channel.GetOrDownloadAsync()).Name}", ExtendedLogSeverity.Info, embed); } private async Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) @@ -103,7 +103,6 @@ private async Task MessageUpdated(Cacheable before, SocketMessa await _loggingService.LogAction( $"User {user.GetPreferredAndUsername()} has " + - $"updated the message\n{content}\n in channel #{channel.Name}", true, false); - await _loggingService.LogAction(" ", false, true, embed); + $"updated the message\n{content}\n in channel #{channel.Name}", ExtendedLogSeverity.Info, embed); } } \ No newline at end of file diff --git a/DiscordBot/Services/ReactRoleService.cs b/DiscordBot/Services/ReactRoleService.cs index 06216351..38d129ba 100644 --- a/DiscordBot/Services/ReactRoleService.cs +++ b/DiscordBot/Services/ReactRoleService.cs @@ -108,8 +108,7 @@ private async Task StartService() var serverGuild = _client.GetGuild(_settings.GuildId); if (serverGuild == null) { - LoggingService.LogToConsole($"[{ServiceName}] ReactRoleService failed to start, could not return guild information.", LogSeverity.Warning); - await _loggingService.LogAction($"[{ServiceName}] ReactRoleService failed to start."); + await _loggingService.LogAction($"[{ServiceName}] ReactRoleService failed to start, could not return guild information.", ExtendedLogSeverity.Warning); return false; } @@ -206,7 +205,7 @@ private async Task UpdateUserRoles(IGuildUser user) } if (ReactSettings.LogUpdates) - await _loggingService.LogAction($"[{ServiceName}] {user.Username} Updated Roles.", false); + await _loggingService.Log(LogBehaviour.Channel, $"[{ServiceName}] {user.Username} Updated Roles.", ExtendedLogSeverity.Info); _pendingUserUpdate.Remove(user); } diff --git a/DiscordBot/Services/ReminderService.cs b/DiscordBot/Services/ReminderService.cs index f4655d93..994cb3a7 100644 --- a/DiscordBot/Services/ReminderService.cs +++ b/DiscordBot/Services/ReminderService.cs @@ -14,6 +14,8 @@ public class ReminderItem { public class ReminderService { + private const string ServiceName = "ReminderService"; + // Bot responds to reminder request, any users who also use this emoji on the message will be pinged when the reminder is triggered. public static readonly Emoji BotResponseEmoji = new("✅"); @@ -46,8 +48,7 @@ private void Initialize() LoadReminders(); if (_reminders == null) { - // Tell the user that we couldn't load the reminders - _loggingService.LogAction("ReminderService: Couldn't load reminders", false); + _loggingService.LogAction($"[{ServiceName}] Error: Could not load reminders from file.", ExtendedLogSeverity.Warning); } IsRunning = true; Task.Run(CheckReminders); @@ -175,8 +176,7 @@ await channel.SendMessageAsync( catch (Exception e) { // Catch and show exception - LoggingService.LogToConsole($"Reminder Service Exception during Reminder.\n{e.Message}"); - await _loggingService.LogAction($"Reminder Service has crashed.\nException Msg: {e.Message}.", false, true); + await _loggingService.LogChannelAndFile($"Reminder Service has crashed.\nException Msg: {e.Message}.", ExtendedLogSeverity.Warning); IsRunning = false; } } diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index a06c214e..575ce805 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -14,6 +14,8 @@ namespace DiscordBot.Services; public class UserService { + private const string ServiceName = "UserService"; + private readonly HashSet _canEditThanks; //Doesn't need to be saved private readonly DiscordSocketClient _client; public readonly string CodeFormattingExample; @@ -163,14 +165,14 @@ private async Task UserLeft(SocketGuild guild, SocketUser user) var joinDate = guildUser.JoinedAt.Value.Date; var timeStayed = DateTime.Now - joinDate; - await _loggingService.LogAction( + await _loggingService.LogChannelAndFile( $"User Left - After {(timeStayed.Days > 1 ? Math.Floor((double)timeStayed.Days) + " days" : " ")}" + $" {Math.Floor((double)timeStayed.Hours).ToString(CultureInfo.InvariantCulture)} hours {user.Mention} - `{guildUser.GetPreferredAndUsername()}` - ID : `{user.Id}`"); } // If bot is to slow to get user info, we just say they left at current time. else { - await _loggingService.LogAction( + await _loggingService.LogChannelAndFile( $"User Left - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}` - Left at {DateTime.Now}"); } } @@ -397,7 +399,7 @@ public async Task GenerateProfileCard(IUser user) } catch (Exception e) { - await _loggingService.LogAction($"Failed to generate profile card for {user.Username}.\nEx:{e.Message}"); + await _loggingService.LogChannelAndFile($"Failed to generate profile card for {user.Username}.\nEx:{e.Message}", ExtendedLogSeverity.LowWarning); } if (!string.IsNullOrEmpty(profileCardPath)) @@ -516,7 +518,7 @@ await messageParam.Channel.SendMessageAsync( return; _thanksCooldown.AddCooldown(userId, _thanksCooldownTime); await messageParam.Channel.SendMessageAsync(sb.ToString()); - await _loggingService.LogAction(sb + " in channel " + messageParam.Channel.Name); + await _loggingService.LogChannelAndFile(sb + " in channel " + messageParam.Channel.Name); } if (mentions.Count == 0 && _canEditThanks.Add(messageParam.Id)) @@ -652,7 +654,7 @@ private async Task UserJoined(SocketGuildUser user) if (MutedUsers.HasUser(user.Id)) { await user.AddRoleAsync(socketTextChannel?.Guild.GetRole(_settings.MutedRoleId)); - await _loggingService.LogAction( + await _loggingService.LogChannelAndFile( $"Currently muted user rejoined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); if (socketTextChannel != null) await socketTextChannel.SendMessageAsync( @@ -662,7 +664,7 @@ await socketTextChannel.SendMessageAsync( } } - await _loggingService.LogAction( + await _loggingService.LogChannelAndFile( $"User Joined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); // We check if they're already in the welcome list, if they are we don't add them again to avoid double posts @@ -714,20 +716,18 @@ private async Task DelayedWelcomeService() catch (Exception e) { // Catch and show exception - LoggingService.LogToConsole($"UserService Exception during welcome message `{currentlyProcessedUserId}`.\n{e.Message}", - LogSeverity.Error); - await _loggingService.LogAction($"UserService Exception during welcome message `{currentlyProcessedUserId}`.\n{e.Message}.", true, true); + await _loggingService.LogChannelAndFile($"{ServiceName} Exception during welcome message `{currentlyProcessedUserId}`.\n{e.Message}.", ExtendedLogSeverity.Warning); // Remove the offending user from the dictionary and run the service again. _welcomeNoticeUsers.RemoveAll(u => u.id == currentlyProcessedUserId); if (_welcomeNoticeUsers.Count > 200) { _welcomeNoticeUsers.Clear(); - await _loggingService.LogAction("UserService: Welcome list cleared due to size (+200), this should not happen.", true, true); + await _loggingService.LogAction($"{ServiceName}: Welcome list cleared due to size (+200), this should not happen.", ExtendedLogSeverity.Error); } if (firstRun) - await _loggingService.LogAction("UserService: Welcome service failed on first run!? This should not happen.", true, true); + await _loggingService.LogAction($"{ServiceName}: Welcome service failed on first run!? This should not happen.", ExtendedLogSeverity.Error); // Run the service again. Task.Run(DelayedWelcomeService); @@ -801,7 +801,7 @@ private async Task UserUpdated(Cacheable oldUserCached, var oldUser = await oldUserCached.GetOrDownloadAsync(); if (oldUser.Nickname != user.Nickname) { - await _loggingService.LogAction( + await _loggingService.LogChannelAndFile( $"User {oldUser.GetUserPreferredName()} changed his " + $"username to {user.GetUserPreferredName()}"); } From c42a53bb19f241eb80b209f5475136407bfd9f5c Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 4 Jan 2024 14:30:14 +1000 Subject: [PATCH 12/66] Update MessageDelete to state a message was deleted if not cached --- DiscordBot/Services/ModerationService.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index 83056924..07c16879 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -23,6 +23,12 @@ public ModerationService(DiscordSocketClient client, BotSettings settings, ILogg private async Task MessageDeleted(Cacheable message, Cacheable channel) { + if (message.HasValue == false) + { + await _loggingService.LogChannelAndFile($"An uncached Message snowflake:`{message.Id}` was deleted from channel <#{(await channel.GetOrDownloadAsync()).Id}>"); + return; + } + if (message.Value.Author.IsBot || channel.Id == _settings.BotAnnouncementChannel.Id) return; // Check the author is even in the guild From 2f9ec96f2882f6408b765f7437e2bed2e4ded062 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 4 Jan 2024 19:12:21 +1000 Subject: [PATCH 13/66] Changes to feed to provide summary for Beta/Release Still WIP --- DiscordBot/Services/FeedService.cs | 205 +++++++++++++++++++++++++---- 1 file changed, 182 insertions(+), 23 deletions(-) diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index ab3af744..cf59d3c0 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -3,6 +3,7 @@ using System.Xml; using Discord.WebSocket; using DiscordBot.Settings; +using HtmlAgilityPack; namespace DiscordBot.Services; @@ -16,6 +17,7 @@ public class FeedService #region Configurable Settings + private const int MaxFeedLengthBuffer = 400; #region News Feed Config private class ForumNewsFeed @@ -23,27 +25,29 @@ private class ForumNewsFeed public string TitleFormat { get; set; } public string Url { get; set; } public List IncludeTags { get; set; } - public bool IncludeSummary { get; set; } = false; + public bool IsRelease { get; set; } = false; } private readonly ForumNewsFeed _betaNews = new() { TitleFormat = "Beta Release - {0}", Url = "https://unity3d.com/unity/beta/latest.xml", - IncludeTags = new(){ "Beta Update" } + IncludeTags = new(){ "Beta Update" }, + IsRelease = true }; private readonly ForumNewsFeed _releaseNews = new() { TitleFormat = "New Release - {0}", Url = "https://unity3d.com/unity/releases.xml", - IncludeTags = new(){"New Release"} + IncludeTags = new(){"New Release"}, + IsRelease = true }; private readonly ForumNewsFeed _blogNews = new() { TitleFormat = "Blog - {0}", Url = "https://blogs.unity3d.com/feed/", IncludeTags = new() { "Unity Blog" }, - IncludeSummary = true + IsRelease = false }; #endregion // News Feed Config @@ -113,37 +117,37 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c _postedFeeds.RemoveAt(0); // Message - string newsContent = string.Empty; - if (newsFeed.IncludeSummary) + var newsContent = string.Empty; + List releaseNotes = new(); + if (!newsFeed.IsRelease) + newsContent = GetSummary(newsFeed, item); + else { - var summary = Utils.Utils.RemoveHtmlTags(item.Summary.Text); - newsContent = "**__Summary__**\n" + summary; - // Unlikely to be over, but we need space for extra local info - if (newsContent.Length > Constants.MaxLengthChannelMessage - 400) - newsContent = newsContent[..(Constants.MaxLengthChannelMessage - 400)] + "..."; + releaseNotes = GetReleaseNotes(item); + newsContent = releaseNotes[0]; } + // If a role is provided we add to end of title to ping the role var role = _client.GetGuild(_settings.GuildId).GetRole(roleId ?? 0); if (role != null) - newsContent = $"{(newsContent.Length > 0 ? $"{newsContent}\n" : "")}{role.Mention}"; + newsContent += $"\n{role.Mention}"; // Link to post if (item.Links.Count > 0) newsContent += $"\n\n**__Source__**\n{item.Links[0].Uri}"; // The Post var post = await channel.CreatePostAsync(newsTitle, ForumArchiveDuration, null, newsContent, null, null, AllowedMentions.All); - // If any tags, include them - if (newsFeed.IncludeTags is { Count: > 0 }) + await AddTagsToPost(channel, post, newsFeed.IncludeTags); + + if (releaseNotes.Count == 1) + continue; + + // post a new message for each release note after the first + for (int i = 1; i < releaseNotes.Count; i++) { - var includedTags = new List(); - foreach (var tag in newsFeed.IncludeTags) - { - var tagContainer = channel.Tags.FirstOrDefault(x => x.Name == tag); - if (tagContainer != null) - includedTags.Add(tagContainer.Id); - } - - await post.ModifyAsync(properties => { properties.AppliedTags = includedTags; }); + if (releaseNotes[i].Length == 0) + continue; + await post.SendMessageAsync(releaseNotes[i]); } } } @@ -152,6 +156,161 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c await _logging.LogAction($"[{ServiceName}] Error: {e}", ExtendedLogSeverity.Error); } } + + private async Task AddTagsToPost(IForumChannel channel, IThreadChannel post, List tags) + { + if (tags.Count <= 0) + return; + + var includedTags = new List(); + foreach (var tag in tags) + { + var tagContainer = channel.Tags.FirstOrDefault(x => x.Name == tag); + if (tagContainer != null) + includedTags.Add(tagContainer.Id); + } + + await post.ModifyAsync(properties => { properties.AppliedTags = includedTags; }); + } + + private string GetSummary(ForumNewsFeed feed, SyndicationItem item) + { + var summary = Utils.Utils.RemoveHtmlTags(item.Summary.Text); + + // If it is too long, we truncate it + var summaryLength = summary.Length; + if (summaryLength > Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) + summary = summary[..(Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer)] + "..."; + return summary; + } + + private List GetReleaseNotes(SyndicationItem item) + { + List releaseNotes = new(); + var summary = string.Empty; + + var htmlDoc = new HtmlDocument(); + var summaryText = item.Summary.Text; + + summaryText = summaryText.Replace("→", "->"); + // // TODO : (James) Likely other entities we need to replace + + htmlDoc.LoadHtml(summaryText); + + // Find "release-notes" + var summaryNode = htmlDoc.DocumentNode.SelectSingleNode("//div[@class='release-notes']"); + if (summaryNode == null) + return new List() { "No release notes found" }; + + // Find "Known Issues" + var knownIssueNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Known Issues"))?.NextSibling; + var entriesSinceNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Entries since")); + + // Find the features node which will be a h4 heading with content "Features" + var featuresNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Features")?.NextSibling; + var improvementsNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Improvements")?.NextSibling; + var apiChangesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "API Changes")?.NextSibling; + var changesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Changes")?.NextSibling; + var fixesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Fixes")?.NextSibling; + var packagesUpdatedNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText.ToLower().Contains("package changes"))?.NextSibling.NextSibling.NextSibling; + + // Need to construct the summary which is just a stats summary + summary += $"**Release notes summary**\n"; + var issueCount = knownIssueNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); + summary += $"Known Issues: {issueCount}\n"; + + if (entriesSinceNode != null) + summary += $"__{entriesSinceNode.InnerText}__\n\n"; + + if (featuresNode != null) + { + var featuresCount = featuresNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); + summary += $"Features: {featuresCount}\n"; + } + + if (improvementsNode != null) + { + var improvementsCount = improvementsNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); + summary += $"Improvements: {improvementsCount}\n"; + } + + if (apiChangesNode != null) + { + var apiChangesCount = apiChangesNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); + summary += $"API Changes: {apiChangesCount}\n"; + } + + if (changesNode != null) + { + var changesCount = changesNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); + summary += $"Changes: {changesCount}\n"; + } + + var fixesCount = fixesNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); + summary += $"Fixes: {fixesCount}\n"; + + var packagesUpdatedCount = packagesUpdatedNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); + summary += $"Packages updated: {packagesUpdatedCount}\n"; + + // Add Package Updates to Summary + releaseNotes.Add(BuildReleaseNote("Packages Updated", packagesUpdatedNode, summary)); + + // Features, Improvements + releaseNotes.Add(BuildReleaseNote("Features", featuresNode)); + releaseNotes.Add(BuildReleaseNote("Improvements", improvementsNode, "", 1000)); + // API Changes, Changes + Fixes + releaseNotes.Add(BuildReleaseNote("API Changes", apiChangesNode)); + releaseNotes.Add(BuildReleaseNote("Changes", changesNode)); + releaseNotes.Add(BuildReleaseNote("Fixes", fixesNode, "", 1400)); + + // Known Issues + releaseNotes.Add(BuildReleaseNote("Known Issues", knownIssueNode, "", 1000)); + + return releaseNotes; + } + + private string BuildReleaseNote(string title, HtmlNode node, string contents = "", int maxLength = Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) + { + if (node == null) + return string.Empty; + + var summary = $"**{node.PreviousSibling.InnerText}**\n"; + if (contents.Length > 0) + summary = $"{contents}\n{summary}"; + + foreach (var feature in node.NextSibling.ChildNodes.Where(x => x.Name == "li")) + { + var featureNode = feature; + var extraText = string.Empty; + if (title == "Fixes" || title == "Known Issues") + { + var nodeContents = featureNode.ChildNodes[0]; + // Remove \n if any + nodeContents.InnerHtml = nodeContents.InnerHtml.Replace("\n", " "); + + var linkNode = nodeContents.SelectSingleNode("a"); + if (linkNode != null) + { + nodeContents = nodeContents.RemoveChild(linkNode); + // Need to remove () + featureNode.InnerHtml = featureNode.InnerHtml.Replace("()", ""); + + // Add link to extraText, but use the InnerText as the text, and format so discord will use it as link + extraText = $" ([{linkNode.InnerText}](<{linkNode.Attributes["href"].Value}>))"; + } + } + + summary += $"- {featureNode.InnerText}{extraText}\n"; + if (summary.Length > maxLength) + { + // Trim down to the last full line, that is less than limits + var lastLine = summary[..maxLength].LastIndexOf('\n'); + summary = summary[..lastLine] + $"\n{title} truncated...\n"; + return summary; + } + } + return summary; + } #endregion // Feed Handlers From 22df7c0bf55724ac5e619d40a0be6cb1b4c08023 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Fri, 5 Jan 2024 10:40:36 +1000 Subject: [PATCH 14/66] Bit more cleanup for new ReleaseNotes --- DiscordBot/Services/FeedService.cs | 135 ++++++++++++++--------------- 1 file changed, 65 insertions(+), 70 deletions(-) diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index cf59d3c0..98e6a5ac 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -193,7 +193,7 @@ private List GetReleaseNotes(SyndicationItem item) var summaryText = item.Summary.Text; summaryText = summaryText.Replace("→", "->"); - // // TODO : (James) Likely other entities we need to replace + // TODO : (James) Likely other entities we need to replace htmlDoc.LoadHtml(summaryText); @@ -202,71 +202,57 @@ private List GetReleaseNotes(SyndicationItem item) if (summaryNode == null) return new List() { "No release notes found" }; - // Find "Known Issues" - var knownIssueNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Known Issues"))?.NextSibling; - var entriesSinceNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Entries since")); - - // Find the features node which will be a h4 heading with content "Features" - var featuresNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Features")?.NextSibling; - var improvementsNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Improvements")?.NextSibling; - var apiChangesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "API Changes")?.NextSibling; - var changesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Changes")?.NextSibling; - var fixesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Fixes")?.NextSibling; - var packagesUpdatedNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText.ToLower().Contains("package changes"))?.NextSibling.NextSibling.NextSibling; - - // Need to construct the summary which is just a stats summary - summary += $"**Release notes summary**\n"; - var issueCount = knownIssueNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); - summary += $"Known Issues: {issueCount}\n"; - - if (entriesSinceNode != null) - summary += $"__{entriesSinceNode.InnerText}__\n\n"; - - if (featuresNode != null) - { - var featuresCount = featuresNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); - summary += $"Features: {featuresCount}\n"; - } - - if (improvementsNode != null) - { - var improvementsCount = improvementsNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); - summary += $"Improvements: {improvementsCount}\n"; - } - - if (apiChangesNode != null) + try { - var apiChangesCount = apiChangesNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); - summary += $"API Changes: {apiChangesCount}\n"; + // Find "Known Issues" + var knownIssueNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Known Issues"))?.NextSibling; + var entriesSinceNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Entries since")); + + // Find the features node which will be a h4 heading with content "Features" + var featuresNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Features")?.NextSibling; + var improvementsNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Improvements")?.NextSibling; + var apiChangesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "API Changes")?.NextSibling; + var changesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Changes")?.NextSibling; + var fixesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Fixes")?.NextSibling; + var packagesUpdatedNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText.ToLower().Contains("package changes"))?.NextSibling.NextSibling.NextSibling; + + // Need to construct the summary which is just a stats summary + summary += $"**Summary**\n"; + summary += GetNodeLiCountString("Known Issues", knownIssueNode?.NextSibling); + + if (entriesSinceNode != null) + summary += $"__{entriesSinceNode.InnerText}__\n\n"; + + // Construct Stat Summary + summary += GetNodeLiCountString("Features", featuresNode?.NextSibling); + summary += GetNodeLiCountString("Improvements", improvementsNode?.NextSibling); + summary += GetNodeLiCountString("API Changes", apiChangesNode?.NextSibling); + summary += GetNodeLiCountString("Changes", changesNode?.NextSibling); + summary += GetNodeLiCountString("Fixes", fixesNode?.NextSibling); + summary += GetNodeLiCountString("Packages Updated", packagesUpdatedNode?.NextSibling); + + // Add Package Updates to Summary + releaseNotes.Add(BuildReleaseNote("Packages Updated", packagesUpdatedNode, summary)); + + // Features, Improvements + releaseNotes.Add(BuildReleaseNote("Features", featuresNode)); + releaseNotes.Add(BuildReleaseNote("Improvements", improvementsNode, "", 1000)); + // API Changes, Changes + Fixes + releaseNotes.Add(BuildReleaseNote("API Changes", apiChangesNode)); + releaseNotes.Add(BuildReleaseNote("Changes", changesNode)); + releaseNotes.Add(BuildReleaseNote("Fixes", fixesNode, "")); + + // Known Issues + releaseNotes.Add(BuildReleaseNote("Known Issues", knownIssueNode, "", 1200)); + + return releaseNotes; } - - if (changesNode != null) + catch (Exception e) { - var changesCount = changesNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); - summary += $"Changes: {changesCount}\n"; + _logging.LogChannelAndFile($"[{ServiceName}] Error generating release notes: {e}\nLikely updated format.", ExtendedLogSeverity.Warning); + // We ignore anything we've generated and return a "No release notes found" to maintain appearance + return new List() { "No release notes found" }; } - - var fixesCount = fixesNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); - summary += $"Fixes: {fixesCount}\n"; - - var packagesUpdatedCount = packagesUpdatedNode.NextSibling.ChildNodes.Count(x => x.Name == "li"); - summary += $"Packages updated: {packagesUpdatedCount}\n"; - - // Add Package Updates to Summary - releaseNotes.Add(BuildReleaseNote("Packages Updated", packagesUpdatedNode, summary)); - - // Features, Improvements - releaseNotes.Add(BuildReleaseNote("Features", featuresNode)); - releaseNotes.Add(BuildReleaseNote("Improvements", improvementsNode, "", 1000)); - // API Changes, Changes + Fixes - releaseNotes.Add(BuildReleaseNote("API Changes", apiChangesNode)); - releaseNotes.Add(BuildReleaseNote("Changes", changesNode)); - releaseNotes.Add(BuildReleaseNote("Fixes", fixesNode, "", 1400)); - - // Known Issues - releaseNotes.Add(BuildReleaseNote("Known Issues", knownIssueNode, "", 1000)); - - return releaseNotes; } private string BuildReleaseNote(string title, HtmlNode node, string contents = "", int maxLength = Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) @@ -274,17 +260,17 @@ private string BuildReleaseNote(string title, HtmlNode node, string contents = " if (node == null) return string.Empty; - var summary = $"**{node.PreviousSibling.InnerText}**\n"; - if (contents.Length > 0) - summary = $"{contents}\n{summary}"; + // If we pass in contents, we prepend it to the summary + var summary = $"{(contents.Length > 0 ? $"{contents}\n" : string.Empty)}**{node.PreviousSibling.InnerText}**\n"; + bool needsExtraProcessing = title is "Fixes" or "Known Issues" or "API Changes"; + foreach (var feature in node.NextSibling.ChildNodes.Where(x => x.Name == "li")) { - var featureNode = feature; var extraText = string.Empty; - if (title == "Fixes" || title == "Known Issues") + if (needsExtraProcessing) { - var nodeContents = featureNode.ChildNodes[0]; + var nodeContents = feature.ChildNodes[0]; // Remove \n if any nodeContents.InnerHtml = nodeContents.InnerHtml.Replace("\n", " "); @@ -293,14 +279,14 @@ private string BuildReleaseNote(string title, HtmlNode node, string contents = " { nodeContents = nodeContents.RemoveChild(linkNode); // Need to remove () - featureNode.InnerHtml = featureNode.InnerHtml.Replace("()", ""); + feature.InnerHtml = feature.InnerHtml.Replace("()", ""); // Add link to extraText, but use the InnerText as the text, and format so discord will use it as link extraText = $" ([{linkNode.InnerText}](<{linkNode.Attributes["href"].Value}>))"; } } - summary += $"- {featureNode.InnerText}{extraText}\n"; + summary += $"- {feature.InnerText}{extraText}\n"; if (summary.Length > maxLength) { // Trim down to the last full line, that is less than limits @@ -311,6 +297,15 @@ private string BuildReleaseNote(string title, HtmlNode node, string contents = " } return summary; } + + private string GetNodeLiCountString(string title, HtmlNode node) + { + if (node == null) + return string.Empty; + + var count = node.ChildNodes.Count(x => x.Name == "li"); + return $"{title}: {count}\n"; + } #endregion // Feed Handlers From 321e3fbe469c43ae70a930195c19f2be07eb1fae Mon Sep 17 00:00:00 2001 From: James Kellie Date: Fri, 5 Jan 2024 10:40:50 +1000 Subject: [PATCH 15/66] Bump DiscordNet to 3.13 --- DiscordBot/DiscordBot.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 2904e402..211aca47 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -5,7 +5,7 @@ 10 - + From e4f1c8edb3f9d38a7e2111b42409b2622fc87274 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 6 Jan 2024 12:23:52 +1000 Subject: [PATCH 16/66] Silence everyone/here mentions in feed (just in case) --- DiscordBot/Services/FeedService.cs | 5 ++++- DiscordBot/Utils/StringUtil.cs | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index 98e6a5ac..ebabf0be 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -3,6 +3,7 @@ using System.Xml; using Discord.WebSocket; using DiscordBot.Settings; +using DiscordBot.Utils; using HtmlAgilityPack; namespace DiscordBot.Services; @@ -134,6 +135,8 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c // Link to post if (item.Links.Count > 0) newsContent += $"\n\n**__Source__**\n{item.Links[0].Uri}"; + + newsContent = newsContent.SanitizeEveryoneHereMentions(); // The Post var post = await channel.CreatePostAsync(newsTitle, ForumArchiveDuration, null, newsContent, null, null, AllowedMentions.All); @@ -147,7 +150,7 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c { if (releaseNotes[i].Length == 0) continue; - await post.SendMessageAsync(releaseNotes[i]); + await post.SendMessageAsync(releaseNotes[i].SanitizeEveryoneHereMentions()); } } } diff --git a/DiscordBot/Utils/StringUtil.cs b/DiscordBot/Utils/StringUtil.cs index 2dd5ba33..1f1ba0b4 100644 --- a/DiscordBot/Utils/StringUtil.cs +++ b/DiscordBot/Utils/StringUtil.cs @@ -24,4 +24,13 @@ public static string MessageSelfDestructIn(int secondsFromNow) var time = DateTime.Now.ToUnixTimestamp() + secondsFromNow; return $"Self-delete: ****"; } + + /// + /// Sanitizes @everyone and @here mentions by adding a zero-width space after the @ symbol. + /// + public static string SanitizeEveryoneHereMentions(this string str) + { + return str.Replace("@everyone", "@\u200beveryone").Replace("@here", "@\u200bhere"); + } + } \ No newline at end of file From 97f970641c2126cd5e93e53a695ea89734fe1a5f Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 6 Jan 2024 12:28:52 +1000 Subject: [PATCH 17/66] Add GetHtmlDocument, GetHtmlNode, GetHtmlNodes, GetHtmlNodeInnerText --- DiscordBot/Utils/WebUtil.cs | 70 +++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebUtil.cs index 896ea608..7381a132 100644 --- a/DiscordBot/Utils/WebUtil.cs +++ b/DiscordBot/Utils/WebUtil.cs @@ -1,4 +1,6 @@ +using System.Net; using System.Net.Http; +using HtmlAgilityPack; using Newtonsoft.Json.Linq; using Newtonsoft.Json; @@ -24,6 +26,74 @@ public static async Task GetContent(string url) } } + /// + /// Returns the Html document of a url, or null if the request fails. + /// Internally calls GetContent and parses the result. + /// + public static async Task GetHtmlDocument(string url) + { + try + { + var html = await GetContent(url); + var doc = new HtmlDocument(); + doc.LoadHtml(html); + return doc; + } + catch (Exception e) + { + return null; + } + } + + /// + /// Returns the Html node of a url and xpath, or null if the request fails. + /// Internally calls GetHtmlDocument and parses the result with xpath. + /// + public static async Task GetHtmlNode(string url, string xpath) + { + try + { + var doc = await GetHtmlDocument(url); + return doc.DocumentNode.SelectSingleNode(xpath); + } + catch (Exception e) + { + return null; + } + } + + /// + /// Returns the Html nodes of a url and xpath, or null if the request fails. + /// + public static async Task GetHtmlNodes(string url, string xpath) + { + try + { + var doc = await GetHtmlDocument(url); + return doc.DocumentNode.SelectNodes(xpath); + } + catch (Exception e) + { + return null; + } + } + + /// + /// Returns the decoded inner text of a url and xpath, or an empty string if the request fails. + /// + public static async Task GetHtmlNodeInnerText(string url, string xpath) + { + try + { + var node = await GetHtmlNode(url, xpath); + return WebUtility.HtmlDecode(node?.InnerText); + } + catch (Exception e) + { + return string.Empty; + } + } + /// /// Returns the content of a url as a sanitized XML string, or an empty string if the request fails. /// From ac88d6090a99625a38025b556260a8c27108cfc4 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 6 Jan 2024 12:29:26 +1000 Subject: [PATCH 18/66] Add Props class to enforce names and avoid user error with SQL generation --- DiscordBot/Extensions/UserDBRepository.cs | 81 ++++++++++++++++------- DiscordBot/Services/DatabaseService.cs | 31 ++++++--- 2 files changed, 79 insertions(+), 33 deletions(-) diff --git a/DiscordBot/Extensions/UserDBRepository.cs b/DiscordBot/Extensions/UserDBRepository.cs index 6ec3a64a..94f49b5f 100644 --- a/DiscordBot/Extensions/UserDBRepository.cs +++ b/DiscordBot/Extensions/UserDBRepository.cs @@ -13,57 +13,90 @@ public class ServerUser public uint KarmaGiven { get; set; } public ulong Exp { get; set; } public uint Level { get; set; } + // DefaultCity - Optional Location for Weather, BDay, Temp, Time, etc. (Added - Jan 2024) + public string DefaultCity { get; set; } +} + +/// +/// Table Properties for ServerUser. Intended to be used with IServerUserRepo and enforce consistency and reduce errors. +/// +public static class UserProps +{ + public const string TableName = "users"; + + public const string UserID = nameof(ServerUser.UserID); + public const string Karma = nameof(ServerUser.Karma); + public const string KarmaWeekly = nameof(ServerUser.KarmaWeekly); + public const string KarmaMonthly = nameof(ServerUser.KarmaMonthly); + public const string KarmaYearly = nameof(ServerUser.KarmaYearly); + public const string KarmaGiven = nameof(ServerUser.KarmaGiven); + public const string Exp = nameof(ServerUser.Exp); + public const string Level = nameof(ServerUser.Level); + public const string DefaultCity = nameof(ServerUser.DefaultCity); } public interface IServerUserRepo { - [Sql("INSERT INTO users (UserID) VALUES (@UserID)")] + [Sql($"INSERT INTO {UserProps.TableName} ({UserProps.UserID}) VALUES (@{UserProps.UserID})")] Task InsertUser(ServerUser user); - [Sql("DELETE FROM users WHERE UserID = @userId")] + [Sql($"DELETE FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] Task RemoveUser(string userId); - [Sql("SELECT * FROM users WHERE UserID = @userId")] + [Sql($"SELECT * FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] Task GetUser(string userId); - // Rank Stuff - [Sql("SELECT UserID, Karma, Level, Exp FROM users ORDER BY Level DESC, RAND() LIMIT @n")] + #region Ranks + + [Sql($"SELECT {UserProps.UserID}, {UserProps.Karma}, {UserProps.Level}, {UserProps.Exp} FROM {UserProps.TableName} ORDER BY {UserProps.Level} DESC, RAND() LIMIT @n")] Task> GetTopLevel(int n); - [Sql("SELECT UserID, Karma, KarmaGiven FROM users ORDER BY Karma DESC, RAND() LIMIT @n")] + [Sql($"SELECT {UserProps.UserID}, {UserProps.Karma}, {UserProps.KarmaGiven} FROM {UserProps.TableName} ORDER BY {UserProps.Karma} DESC, RAND() LIMIT @n")] Task> GetTopKarma(int n); - [Sql("SELECT UserID, KarmaWeekly FROM users ORDER BY KarmaWeekly DESC, RAND() LIMIT @n")] + [Sql($"SELECT {UserProps.UserID}, {UserProps.KarmaWeekly} FROM {UserProps.TableName} ORDER BY {UserProps.KarmaWeekly} DESC, RAND() LIMIT @n")] Task> GetTopKarmaWeekly(int n); - [Sql("SELECT UserID, KarmaMonthly FROM users ORDER BY KarmaMonthly DESC, RAND() LIMIT @n")] + [Sql($"SELECT {UserProps.UserID}, {UserProps.KarmaMonthly} FROM {UserProps.TableName} ORDER BY {UserProps.KarmaMonthly} DESC, RAND() LIMIT @n")] Task> GetTopKarmaMonthly(int n); - [Sql("SELECT UserID, KarmaYearly FROM users ORDER BY KarmaYearly DESC, RAND() LIMIT @n")] + [Sql($"SELECT {UserProps.UserID}, {UserProps.KarmaYearly} FROM {UserProps.TableName} ORDER BY {UserProps.KarmaYearly} DESC, RAND() LIMIT @n")] Task> GetTopKarmaYearly(int n); - [Sql("SELECT COUNT(UserID)+1 FROM users WHERE Level > @level")] + [Sql($"SELECT COUNT({UserProps.UserID})+1 FROM {UserProps.TableName} WHERE {UserProps.Level} > @level")] Task GetLevelRank(string userId, uint level); - [Sql("SELECT COUNT(UserID)+1 FROM users WHERE Karma > @karma")] + [Sql($"SELECT COUNT({UserProps.UserID})+1 FROM {UserProps.TableName} WHERE {UserProps.Karma} > @karma")] Task GetKarmaRank(string userId, uint karma); + + #endregion // Ranks - // Update Values - [Sql("UPDATE users SET Karma = @karma WHERE UserID = @userId")] + #region Update Values + + [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Karma} = @karma WHERE {UserProps.UserID} = @userId")] Task UpdateKarma(string userId, uint karma); - [Sql("UPDATE users SET Karma = Karma + 1, KarmaWeekly = KarmaWeekly + 1, KarmaMonthly = KarmaMonthly + 1, KarmaYearly = KarmaYearly + 1 WHERE UserID = @userId")] + [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Karma} = {UserProps.Karma} + 1, {UserProps.KarmaWeekly} = {UserProps.KarmaWeekly} + 1, {UserProps.KarmaMonthly} = {UserProps.KarmaMonthly} + 1, {UserProps.KarmaYearly} = {UserProps.KarmaYearly} + 1 WHERE {UserProps.UserID} = @userId")] Task IncrementKarma(string userId); - [Sql("UPDATE users SET KarmaGiven = @karmaGiven WHERE UserID = @userId")] + [Sql($"UPDATE {UserProps.TableName} SET {UserProps.KarmaGiven} = @karmaGiven WHERE {UserProps.UserID} = @userId")] Task UpdateKarmaGiven(string userId, uint karmaGiven); - [Sql("UPDATE users SET Exp = @xp WHERE UserID = @userId")] + [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Exp} = @xp WHERE {UserProps.UserID} = @userId")] Task UpdateXp(string userId, ulong xp); - [Sql("UPDATE users SET Level = @level WHERE UserID = @userId")] + [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Level} = @level WHERE {UserProps.UserID} = @userId")] Task UpdateLevel(string userId, uint level); + [Sql($"UPDATE {UserProps.TableName} SET {UserProps.DefaultCity} = @city WHERE {UserProps.UserID} = @userId")] + Task UpdateDefaultCity(string userId, string city); + + #endregion // Update Values - // Get Single Values - [Sql("SELECT Karma FROM users WHERE UserID = @userId")] + #region Get Single Values + + [Sql($"SELECT {UserProps.Karma} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] Task GetKarma(string userId); - [Sql("SELECT KarmaGiven FROM users WHERE UserID = @userId")] + [Sql($"SELECT {UserProps.KarmaGiven} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] Task GetKarmaGiven(string userId); - [Sql("SELECT Exp FROM users WHERE UserID = @userId")] + [Sql($"SELECT {UserProps.Exp} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] Task GetXp(string userId); - [Sql("SELECT Level FROM users WHERE UserID = @userId")] + [Sql($"SELECT {UserProps.Level} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] Task GetLevel(string userId); + [Sql($"SELECT {UserProps.DefaultCity} FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] + Task GetDefaultCity(string userId); + + #endregion // Get Single Values - /// Returns a count of users in the Table, otherwise it fails. - [Sql("SELECT COUNT(*) FROM users")] + /// Returns a count of {Props.TableName} in the Table, otherwise it fails. + [Sql($"SELECT COUNT(*) FROM {UserProps.TableName}")] Task TestConnection(); } \ No newline at end of file diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index cdf99120..e1af390a 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -46,26 +46,38 @@ await _logging.LogAction( } catch { - await _logging.LogAction("DatabaseService: Table 'users' does not exist, attempting to generate table.", + await _logging.LogAction($"DatabaseService: Table '{UserProps.TableName}' does not exist, attempting to generate table.", ExtendedLogSeverity.LowWarning); try { c.ExecuteSql( - "CREATE TABLE `users` (`ID` int(11) UNSIGNED NOT NULL, `UserID` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, `Karma` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaWeekly` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaMonthly` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaYearly` int(11) UNSIGNED NOT NULL DEFAULT 0, `KarmaGiven` int(11) UNSIGNED NOT NULL DEFAULT 0, `Exp` bigint(11) UNSIGNED NOT NULL DEFAULT 0, `Level` int(11) UNSIGNED NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $"CREATE TABLE `{UserProps.TableName}` (`ID` int(11) UNSIGNED NOT NULL," + + $"`{UserProps.UserID}` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, " + + $"`{UserProps.Karma}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + + $"`{UserProps.KarmaWeekly}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + + $"`{UserProps.KarmaMonthly}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + + $"`{UserProps.KarmaYearly}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + + $"`{UserProps.KarmaGiven}` int(11) UNSIGNED NOT NULL DEFAULT 0, " + + $"`{UserProps.Exp}` bigint(11) UNSIGNED NOT NULL DEFAULT 0, " + + $"`{UserProps.Level}` int(11) UNSIGNED NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); c.ExecuteSql( - "ALTER TABLE `users` ADD PRIMARY KEY (`ID`,`UserID`), ADD UNIQUE KEY `UserID` (`UserID`)"); + $"ALTER TABLE `{UserProps.TableName}` ADD PRIMARY KEY (`ID`,`{UserProps.UserID}`), ADD UNIQUE KEY `{UserProps.UserID}` (`{UserProps.UserID}`)"); c.ExecuteSql( - "ALTER TABLE `users` MODIFY `ID` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1"); + $"ALTER TABLE `{UserProps.TableName}` MODIFY `ID` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1"); + + // "DefaultCity" Nullable - Weather, BDay, Temp, Time, etc. Optional for users to set their own city (Added - Jan 2024) + c.ExecuteSql( + $"ALTER TABLE `{UserProps.TableName}` ADD `{UserProps.DefaultCity}` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL AFTER `{UserProps.Level}`"); } catch (Exception e) { await _logging.LogAction( - $"SQL Exception: Failed to generate table 'users'.\nMessage: {e}", + $"SQL Exception: Failed to generate table '{UserProps.TableName}'.\nMessage: {e}", ExtendedLogSeverity.Critical); c.Close(); return; } - await _logging.LogAction("DatabaseService: Table 'users' generated without errors.", + await _logging.LogAction($"DatabaseService: Table '{UserProps.TableName}' generated without errors.", ExtendedLogSeverity.Positive); c.Close(); } @@ -74,11 +86,11 @@ await _logging.LogAction("DatabaseService: Table 'users' generated without error try { c.ExecuteSql( - $"CREATE EVENT IF NOT EXISTS `ResetWeeklyLeaderboards` ON SCHEDULE EVERY 1 WEEK STARTS '2021-08-02 00:00:00' ON COMPLETION NOT PRESERVE ENABLE DO UPDATE {c.Database}.users SET KarmaWeekly = 0"); + $"CREATE EVENT IF NOT EXISTS `ResetWeeklyLeaderboards` ON SCHEDULE EVERY 1 WEEK STARTS '2021-08-02 00:00:00' ON COMPLETION NOT PRESERVE ENABLE DO UPDATE {c.Database}.users SET {UserProps.KarmaWeekly} = 0"); c.ExecuteSql( - $"CREATE EVENT IF NOT EXISTS `ResetMonthlyLeaderboards` ON SCHEDULE EVERY 1 MONTH STARTS '2021-08-01 00:00:00' ON COMPLETION NOT PRESERVE ENABLE DO UPDATE {c.Database}.users SET KarmaMonthly = 0"); + $"CREATE EVENT IF NOT EXISTS `ResetMonthlyLeaderboards` ON SCHEDULE EVERY 1 MONTH STARTS '2021-08-01 00:00:00' ON COMPLETION NOT PRESERVE ENABLE DO UPDATE {c.Database}.users SET {UserProps.KarmaMonthly} = 0"); c.ExecuteSql( - $"CREATE EVENT IF NOT EXISTS `ResetYearlyLeaderboards` ON SCHEDULE EVERY 1 YEAR STARTS '2022-01-01 00:00:00' ON COMPLETION NOT PRESERVE ENABLE DO UPDATE {c.Database}.users SET KarmaYearly = 0"); + $"CREATE EVENT IF NOT EXISTS `ResetYearlyLeaderboards` ON SCHEDULE EVERY 1 YEAR STARTS '2022-01-01 00:00:00' ON COMPLETION NOT PRESERVE ENABLE DO UPDATE {c.Database}.users SET {UserProps.KarmaYearly} = 0"); c.Close(); } catch (Exception e) @@ -86,6 +98,7 @@ await _logging.LogAction("DatabaseService: Table 'users' generated without error await _logging.LogAction($"SQL Exception: Failed to generate leaderboard events.\nMessage: {e}", ExtendedLogSeverity.Warning); } + }); } From 253a65ad44d79599bbf82a79b76f06a96bc896b8 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 6 Jan 2024 17:25:53 +1000 Subject: [PATCH 19/66] DBConnectionExtension to check if a col exists, and add DefaultCity --- DiscordBot/Extensions/DBConnectionExtension.cs | 15 +++++++++++++++ DiscordBot/Services/DatabaseService.cs | 9 +++++++++ 2 files changed, 24 insertions(+) create mode 100644 DiscordBot/Extensions/DBConnectionExtension.cs diff --git a/DiscordBot/Extensions/DBConnectionExtension.cs b/DiscordBot/Extensions/DBConnectionExtension.cs new file mode 100644 index 00000000..bba946b9 --- /dev/null +++ b/DiscordBot/Extensions/DBConnectionExtension.cs @@ -0,0 +1,15 @@ +using System.Data.Common; +using Insight.Database; + +namespace DiscordBot.Extensions; + +public static class DBConnectionExtension +{ + public static async Task ColumnExists(this DbConnection connection, string tableName, string columnName) + { + // Execute the query `SHOW COLUMNS FROM `{tableName}` LIKE '{columnName}'` and check if any rows are returned + var query = $"SHOW COLUMNS FROM `{tableName}` LIKE '{columnName}'"; + var response = await connection.QuerySqlAsync(query); + return response.Count > 0; + } +} \ No newline at end of file diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index e1af390a..5ab09610 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -43,6 +43,15 @@ public DatabaseService(ILoggingService logging, BotSettings settings) await _logging.LogAction( $"{ServiceName}: Connected to database successfully. {userCount} users in database.", ExtendedLogSeverity.Positive); + + // Not sure on best practice for if column is missing, full blown migrations seem overkill + var defaultCityExists = await c.ColumnExists(UserProps.TableName, UserProps.DefaultCity); + if (!defaultCityExists) + { + c.ExecuteSql($"ALTER TABLE `{UserProps.TableName}` ADD `{UserProps.DefaultCity}` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL AFTER `{UserProps.Level}`"); + await _logging.LogAction($"DatabaseService: Added missing column '{UserProps.DefaultCity}' to table '{UserProps.TableName}'.", + ExtendedLogSeverity.Positive); + } } catch { From ece2f58ab66864d48d4aea6432702458816311a6 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 6 Jan 2024 19:51:45 +1000 Subject: [PATCH 20/66] Embed Cleanup --- .../Extensions/EmbedBuilderExtension.cs | 31 +++++++++++++++++++ DiscordBot/Modules/UserModule.cs | 16 ++-------- DiscordBot/Modules/UserSlashModule.cs | 8 ++--- DiscordBot/Services/ModerationService.cs | 24 +++----------- DiscordBot/Services/UserService.cs | 7 +---- 5 files changed, 41 insertions(+), 45 deletions(-) create mode 100644 DiscordBot/Extensions/EmbedBuilderExtension.cs diff --git a/DiscordBot/Extensions/EmbedBuilderExtension.cs b/DiscordBot/Extensions/EmbedBuilderExtension.cs new file mode 100644 index 00000000..88f86dd4 --- /dev/null +++ b/DiscordBot/Extensions/EmbedBuilderExtension.cs @@ -0,0 +1,31 @@ +namespace DiscordBot.Extensions; + +public static class EmbedBuilderExtension +{ + + public static EmbedBuilder FooterRequestedBy(this EmbedBuilder builder, IUser requestor) + { + builder.WithFooter( + $"Requested by {requestor.GetUserPreferredName()}", + requestor.GetAvatarUrl()); + return builder; + } + + public static EmbedBuilder FooterQuoteBy(this EmbedBuilder builder, IUser requestor, IChannel channel) + { + builder.WithFooter( + $"Quoted by {requestor.GetUserPreferredName()}, • From channel #{channel.Name}", + requestor.GetAvatarUrl()); + return builder; + } + + public static EmbedBuilder AddAuthor(this EmbedBuilder builder, IUser user, bool includeAvatar = true) + { + builder.WithAuthor( + user.GetUserPreferredName(), + includeAvatar ? user.GetAvatarUrl() : null); + return builder; + } + + +} \ No newline at end of file diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index 73547cf4..b742f7dd 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -128,18 +128,8 @@ public async Task QuoteMessage(ulong messageId, IMessageChannel channel = null) var builder = new EmbedBuilder() .WithColor(new Color(200, 128, 128)) .WithTimestamp(message.Timestamp) - .WithFooter(footer => - { - footer - .WithText($"Quoted by {Context.User.GetUserPreferredName()} • From channel {message.Channel.Name}") - .WithIconUrl(Context.User.GetAvatarUrl()); - }) - .WithAuthor(author => - { - author - .WithName(message.Author.Username) - .WithIconUrl(message.Author.GetAvatarUrl()); - }); + .FooterQuoteBy(Context.User, message.Channel) + .AddAuthor(message.Author); if (msgContent == string.Empty && msgAttachment != string.Empty) msgContent = "📸"; msgContent += $"\n\n***[Linkback]({messageLink})***"; @@ -1084,7 +1074,7 @@ await ReplyAsync( #endregion - #region temperatures + #region Temperatures [Command("FtoC"), Priority(28)] [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")] diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 35944b03..266256a5 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -173,12 +173,8 @@ public async Task ModalResponse(ulong id, ReportMessageModal modal) .WithText($"Reported by {Context.User.GetPreferredAndUsername()} • From channel {reportedMessage.Channel.Name}") .WithIconUrl(Context.User.GetAvatarUrl()); }) - .WithAuthor(author => - { - author - .WithName(reportedMessage.Author.Username) - .WithIconUrl(reportedMessage.Author.GetAvatarUrl()); - }); + .AddAuthor(reportedMessage.Author); + embed.Description += $"\n\n***[Linkback]({reportedMessage.GetJumpUrl()})***"; if (reportedMessage.Attachments.Count > 0) diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index 07c16879..eaf82150 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -44,16 +44,8 @@ private async Task MessageDeleted(Cacheable message, Cacheable< var builder = new EmbedBuilder() .WithColor(DeletedMessageColor) .WithTimestamp(message.Value.Timestamp) - .WithFooter(footer => - { - footer - .WithText($"In channel {message.Value.Channel.Name}"); - }) - .WithAuthor(author => - { - author - .WithName($"{user.GetPreferredAndUsername()} deleted a message"); - }) + .WithFooter($"In channel {message.Value.Channel.Name}") + .WithAuthor($"{user.GetPreferredAndUsername()} deleted a message") .AddField($"Deleted Message {(content.Length != message.Value.Content.Length ? "(truncated)" : "")}", content); var embed = builder.Build(); @@ -90,16 +82,8 @@ private async Task MessageUpdated(Cacheable before, SocketMessa var builder = new EmbedBuilder() .WithColor(EditedMessageColor) .WithTimestamp(after.Timestamp) - .WithFooter(footer => - { - footer - .WithText($"In channel {after.Channel.Name}"); - }) - .WithAuthor(author => - { - author - .WithName($"{user.GetPreferredAndUsername()} updated a message"); - }); + .WithFooter($"In channel {after.Channel.Name}") + .WithAuthor($"{user.GetPreferredAndUsername()} updated a message"); if (isCached) builder.AddField($"Previous message content {(isTruncated ? "(truncated)" : "")}", content); builder.WithDescription($"Message: [{after.Id}]({after.GetJumpUrl()})"); diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index 575ce805..d066a5e8 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -417,12 +417,7 @@ public Embed WelcomeMessage(SocketGuildUser user) var builder = new EmbedBuilder() .WithDescription(welcomeString) .WithColor(_welcomeColour) - .WithAuthor(author => - { - author - .WithName(user.GetUserPreferredName()) - .WithIconUrl(icon); - }); + .WithAuthor(user.GetUserPreferredName(), icon); var embed = builder.Build(); return embed; From 6aa1b57e105e7b90f47a701b8114bc6a981884da Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 6 Jan 2024 19:54:04 +1000 Subject: [PATCH 21/66] Some additional logging by CommandHandlingService during startup Spits out details about number of modules/commands loaded and how many of different interactivity. Kinda pointless, but might be useful for someone debugging if they're new. --- DiscordBot/Services/CommandHandlingService.cs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index b0ae9380..77206f63 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -13,6 +13,7 @@ namespace DiscordBot.Services; public class CommandHandlingService { + private const string ServiceName = "CommandHandlingService"; public bool IsInitialized { get; private set; } private readonly DiscordSocketClient _client; @@ -20,6 +21,7 @@ public class CommandHandlingService private readonly InteractionService _interactionService; private readonly IServiceProvider _services; private readonly BotSettings _settings; + private readonly ILoggingService _loggingService; // While not the most attractive solution, it works, and is fairly cheap compared to the last solution. // Tuple of string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true for a dictionary @@ -31,7 +33,8 @@ public CommandHandlingService( CommandService commandService, InteractionService interactionService, IServiceProvider services, - BotSettings settings + BotSettings settings, + ILoggingService loggingService ) { _client = client; @@ -39,6 +42,7 @@ BotSettings settings _interactionService = interactionService; _services = services; _settings = settings; + _loggingService = loggingService; // Events _client.MessageReceived += HandleCommand; @@ -50,9 +54,19 @@ BotSettings settings try { // Discover all of the commands in this assembly and load them. - await _commandService.AddModulesAsync(Assembly.GetEntryAssembly(), _services); - - await _interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + var addedEnumerable = await _commandService.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + var commandModulesAdded = addedEnumerable.ToList(); + await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: Loaded {commandModulesAdded.Count} 'Normal' modules. ({commandModulesAdded.Sum(x => x.Commands.Count)} commands)", ExtendedLogSeverity.Positive); + + var addedInteractivity = await _interactionService.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + var moduleInfos = addedInteractivity.ToList(); + await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: Loaded {moduleInfos.Count} 'Interactivity' modules.", ExtendedLogSeverity.Positive); + await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.SlashCommands.Count)} 'Slash' commands.", ExtendedLogSeverity.Positive); + await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.ContextCommands.Count)} 'Context' commands.", ExtendedLogSeverity.Positive); + await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.AutocompleteCommands.Count)} 'AutoComplete' commands.", ExtendedLogSeverity.Positive); + await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.ModalCommands.Count)} 'Modal' commands.", ExtendedLogSeverity.Positive); + await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.ComponentCommands.Count)} 'Component' commands.", ExtendedLogSeverity.Positive); + //TODO Consider global commands? Maybe an attribute? await _interactionService.RegisterCommandsToGuildAsync(_settings.GuildId); @@ -60,7 +74,7 @@ BotSettings settings } catch (Exception e) { - LoggingService.LogToConsole($"Failed to initialize the command service while adding modules.\nException: {e}", LogSeverity.Critical); + await _loggingService.Log(LogBehaviour.Console | LogBehaviour.File, $"[{ServiceName}] Failed to initialize service while adding modules.\nException: {e}", ExtendedLogSeverity.Critical); } }); } From 52bedb9b6705a4d964f13b9819140e93901e4072 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 6 Jan 2024 21:15:50 +1000 Subject: [PATCH 22/66] DefaultCIty Functionality and some Cleanup of Weather Added a UserExtendedService which manages a cache of CityNames to simplify lookup --- DiscordBot/Modules/AirportModule.cs | 5 +- DiscordBot/Modules/UserModule.cs | 35 ++ .../Modules/Weather/WeatherContainers.cs | 129 +++++++ .../Modules/{ => Weather}/WeatherModule.cs | 342 +++++++++--------- DiscordBot/Program.cs | 1 + DiscordBot/Services/UserExtendedService.cs | 59 +++ DiscordBot/Services/WeatherService.cs | 17 +- 7 files changed, 408 insertions(+), 180 deletions(-) create mode 100644 DiscordBot/Modules/Weather/WeatherContainers.cs rename DiscordBot/Modules/{ => Weather}/WeatherModule.cs (65%) create mode 100644 DiscordBot/Services/UserExtendedService.cs diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/AirportModule.cs index ab742077..5a35ae31 100644 --- a/DiscordBot/Modules/AirportModule.cs +++ b/DiscordBot/Modules/AirportModule.cs @@ -1,4 +1,5 @@ using Discord.Commands; +using DiscordBot.Modules.Weather; using DiscordBot.Services; using DiscordBot.Settings; @@ -123,7 +124,7 @@ public async Task FlyTo(string from, string to) #region Utility Methods - private async Task GetCity(string city, EmbedBuilder embed, IUserMessage msg) + private async Task GetCity(string city, EmbedBuilder embed, IUserMessage msg) { var cityResult = await WeatherService.GetWeather(city); if (cityResult == null) @@ -136,7 +137,7 @@ public async Task FlyTo(string from, string to) return cityResult; } - private async Task GetAirport(WeatherModule.WeatherContainer.Result weather, EmbedBuilder embed, IUserMessage msg) + private async Task GetAirport(WeatherContainer.Result weather, EmbedBuilder embed, IUserMessage msg) { var airportResult = await AirportService.GetClosestAirport(weather.coord.Lat, weather.coord.Lon); if (airportResult == null) diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index b742f7dd..f048e3d3 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -23,6 +23,8 @@ public class UserModule : ModuleBase public PublisherService PublisherService { get; set; } public UpdateService UpdateService { get; set; } public CommandHandlingService CommandHandlingService { get; set; } + public WeatherService WeatherService { get; set; } + public UserExtendedService UserExtendedService { get; set; } public BotSettings Settings { get; set; } public Rules Rules { get; set; } @@ -543,6 +545,39 @@ public async Task JoinDate() await ReplyAsync($"{Context.User.Mention} you joined **{joinDate:dddd dd/MM/yyy HH:mm:ss}**"); await Context.Message.DeleteAsync(); } + + [Command("SetCity"), Priority(100)] + [Alias("SetDefaultCity")] + [Summary("Set 'Default City' which can be used by various commands.")] + public async Task SetDefaultCity(params string[] city) + { + var fullCityName = string.Join(" ", city); + var (exists, result) = await WeatherService.CityExists(fullCityName); + if (!exists) + { + await ReplyAsync($"Sorry, {Context.User.Mention} but I couldn't find a city with that name.").DeleteAfterSeconds(30); + await Context.Message.DeleteAsync(); + return; + } + // Set default city + await UserExtendedService.SetUserDefaultCity(Context.User, result.name); + await ReplyAsync($"{Context.User.Mention} your default city has been set to {result.name}."); + } + + [Command("RemoveCity"), Priority(100)] + [Alias("RemoveDefaultCity")] + [Summary("Remove 'Default City' which can be used by various commands.")] + public async Task RemoveDefaultCity() + { + if (!await UserExtendedService.DoesUserHaveDefaultCity(Context.User)) + { + await ReplyAsync($"{Context.User.Mention} you don't have a default city set.").DeleteAfterSeconds(30); + await Context.Message.DeleteAsync(); + return; + } + await UserExtendedService.RemoveUserDefaultCity(Context.User); + await ReplyAsync($"{Context.User.Mention} your default city has been removed."); + } #endregion diff --git a/DiscordBot/Modules/Weather/WeatherContainers.cs b/DiscordBot/Modules/Weather/WeatherContainers.cs new file mode 100644 index 00000000..1f5d351c --- /dev/null +++ b/DiscordBot/Modules/Weather/WeatherContainers.cs @@ -0,0 +1,129 @@ +using Newtonsoft.Json; + +namespace DiscordBot.Modules.Weather; + + #region Weather Results + +#pragma warning disable 0649 + // ReSharper disable InconsistentNaming + public class WeatherContainer + { + public class Coord + { + public double Lon { get; set; } + public double Lat { get; set; } + } + + public class Weather + { + public int id { get; set; } + [JsonProperty("main")] public string Name { get; set; } + public string Description { get; set; } + public string Icon { get; set; } + } + + public class Main + { + public float Temp { get; set; } + [JsonProperty("feels_like")] public double Feels { get; set; } + [JsonProperty("temp_min")] public double Min { get; set; } + [JsonProperty("temp_max")] public double Max { get; set; } + public int Pressure { get; set; } + public int Humidity { get; set; } + } + + public class Wind + { + public double Speed { get; set; } + public int Deg { get; set; } + } + + public class Clouds + { + public int all { get; set; } + } + + public class Rain + { + [JsonProperty("1h")] public double Rain1h { get; set; } + [JsonProperty("3h")] public double Rain3h { get; set; } + } + + public class Snow + { + [JsonProperty("1h")] public double Snow1h { get; set; } + [JsonProperty("3h")] public double Snow3h { get; set; } + } + + public class Sys + { + public int type { get; set; } + public int id { get; set; } + public double message { get; set; } + public string country { get; set; } + public int sunrise { get; set; } + public int sunset { get; set; } + } + + public class Result + { + public Coord coord { get; set; } + public List weather { get; set; } + public string @base { get; set; } + public Main main { get; set; } + public int visibility { get; set; } + public Wind wind { get; set; } + public Clouds clouds { get; set; } + public Rain rain { get; set; } + public Snow snow { get; set; } + public int dt { get; set; } + public Sys sys { get; set; } + public int timezone { get; set; } + public int id { get; set; } + public string name { get; set; } + public int cod { get; set; } + } + } + + #endregion + #region Pollution Results + + public class PollutionContainer + { + public class Coord + { + public double lon { get; set; } + public double lat { get; set; } + } + public class Main + { + public int aqi { get; set; } + } + public class Components + { + [JsonProperty("co")] public double CarbonMonoxide { get; set; } + [JsonProperty("no")] public double NitrogenMonoxide { get; set; } + [JsonProperty("no2")] public double NitrogenDioxide { get; set; } + [JsonProperty("o3")] public double Ozone { get; set; } + [JsonProperty("so2")] public double SulphurDioxide { get; set; } + [JsonProperty("pm2_5")] public double FineParticles { get; set; } + [JsonProperty("pm10")] public double CoarseParticulate { get; set; } + [JsonProperty("nh3")] public double Ammonia { get; set; } + } + + public class List + { + public Main main { get; set; } + public Components components { get; set; } + public int dt { get; set; } + } + public class Result + { + public Coord coord { get; set; } + public List list { get; set; } + } + } + + // ReSharper restore InconsistentNaming +#pragma warning restore 0649 + #endregion \ No newline at end of file diff --git a/DiscordBot/Modules/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs similarity index 65% rename from DiscordBot/Modules/WeatherModule.cs rename to DiscordBot/Modules/Weather/WeatherModule.cs index 7d039615..82435454 100644 --- a/DiscordBot/Modules/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -1,4 +1,6 @@ using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Modules.Weather; using DiscordBot.Services; using Newtonsoft.Json; @@ -12,138 +14,13 @@ public class WeatherModule : ModuleBase #region Dependency Injection public WeatherService WeatherService { get; set; } + public UserExtendedService UserExtendedService { get; set; } #endregion - - #region Weather Results - -#pragma warning disable 0649 - // ReSharper disable InconsistentNaming - public class WeatherContainer - { - public class Coord - { - public double Lon { get; set; } - public double Lat { get; set; } - } - - public class Weather - { - public int id { get; set; } - [JsonProperty("main")] public string Name { get; set; } - public string Description { get; set; } - public string Icon { get; set; } - } - - public class Main - { - public float Temp { get; set; } - [JsonProperty("feels_like")] public double Feels { get; set; } - [JsonProperty("temp_min")] public double Min { get; set; } - [JsonProperty("temp_max")] public double Max { get; set; } - public int Pressure { get; set; } - public int Humidity { get; set; } - } - - public class Wind - { - public double Speed { get; set; } - public int Deg { get; set; } - } - - public class Clouds - { - public int all { get; set; } - } - - public class Rain - { - [JsonProperty("1h")] public double Rain1h { get; set; } - [JsonProperty("3h")] public double Rain3h { get; set; } - } - - public class Snow - { - [JsonProperty("1h")] public double Snow1h { get; set; } - [JsonProperty("3h")] public double Snow3h { get; set; } - } - - public class Sys - { - public int type { get; set; } - public int id { get; set; } - public double message { get; set; } - public string country { get; set; } - public int sunrise { get; set; } - public int sunset { get; set; } - } - - public class Result - { - public Coord coord { get; set; } - public List weather { get; set; } - public string @base { get; set; } - public Main main { get; set; } - public int visibility { get; set; } - public Wind wind { get; set; } - public Clouds clouds { get; set; } - public Rain rain { get; set; } - public Snow snow { get; set; } - public int dt { get; set; } - public Sys sys { get; set; } - public int timezone { get; set; } - public int id { get; set; } - public string name { get; set; } - public int cod { get; set; } - } - } - - #endregion - #region Pollution Results - - public class PollutionContainer - { - public class Coord - { - public double lon { get; set; } - public double lat { get; set; } - } - public class Main - { - public int aqi { get; set; } - } - public class Components - { - [JsonProperty("co")] public double CarbonMonoxide { get; set; } - [JsonProperty("no")] public double NitrogenMonoxide { get; set; } - [JsonProperty("no2")] public double NitrogenDioxide { get; set; } - [JsonProperty("o3")] public double Ozone { get; set; } - [JsonProperty("so2")] public double SulphurDioxide { get; set; } - [JsonProperty("pm2_5")] public double FineParticles { get; set; } - [JsonProperty("pm10")] public double CoarseParticulate { get; set; } - [JsonProperty("nh3")] public double Ammonia { get; set; } - } - - public class List - { - public Main main { get; set; } - public Components components { get; set; } - public int dt { get; set; } - } - public class Result - { - public Coord coord { get; set; } - public List list { get; set; } - } - } - + private List AQI_Index = new List() {"Invalid", "Good", "Fair", "Moderate", "Poor", "Very Poor"}; - // ReSharper restore InconsistentNaming -#pragma warning restore 0649 - #endregion - [Command("WeatherHelp")] [Summary("How to use the weather module.")] [Priority(100)] @@ -157,31 +34,60 @@ public async Task WeatherHelp() await ReplyAsync(embed: builder.Build()).DeleteAfterSeconds(seconds: 30); } - [Command("Temperature")] - [Summary("Attempts to provide the temperature of the city provided.")] - [Alias("temp"), Priority(20)] - public async Task Temperature(params string[] city) + #region Temperature + + private async Task TemperatureEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: string.Join(" ", city)); + WeatherContainer.Result res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) - return; + return null; EmbedBuilder builder = new EmbedBuilder() - .WithTitle($"{res.name} Temperature ({res.sys.country})") + .WithTitle($"{(replaceCityWith.Length == 0 ? res.name : replaceCityWith)} Temperature ({res.sys.country})") .WithDescription( $"Currently: **{Math.Round(res.main.Temp, 1)}°C** [Feels like **{Math.Round(res.main.Feels, 1)}°C**]") .WithColor(GetColour(res.main.Temp)); + return builder; + } + + [Command("Temperature"), HideFromHelp] + [Summary("Attempts to provide the temperature of the user provided.")] + [Alias("temp"), Priority(20)] + public async Task Temperature(IUser user) + { + if (!await DoesUserHaveDefaultCity(user)) + return; + + var city = await UserExtendedService.GetUserDefaultCity(user); + var builder = await TemperatureEmbed(city, user.GetUserPreferredName()); + if (builder == null) + return; + await ReplyAsync(embed: builder.Build()); } + + [Command("Temperature")] + [Summary("Attempts to provide the temperature of the city provided.")] + [Alias("temp"), Priority(20)] + public async Task Temperature(params string[] city) + { + var builder = await TemperatureEmbed(string.Join(" ", city)); + if (builder == null) + return; - [Command("Weather"), Priority(20)] - [Summary("Attempts to provide the weather of the city provided.")] - public async Task CurentWeather(params string[] city) + await ReplyAsync(embed: builder.Build()); + } + + #endregion // Temperature + + #region Weather + + private async Task WeatherEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: string.Join(" ", city)); + WeatherContainer.Result res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) - return; + return null; string extraInfo = string.Empty; @@ -195,8 +101,7 @@ public async Task CurentWeather(params string[] city) extraInfo += $"Sunrise **{sunrise:hh\\:mmtt}**, "; if (res.sys.sunrise > 0) extraInfo += $"Sunset **{sunset:hh\\:mmtt}**\n"; - - + if (res.main.Temp > 0 && res.rain != null) { if (res.rain.Rain3h > 0) @@ -211,8 +116,6 @@ public async Task CurentWeather(params string[] city) else if (res.snow.Snow1h > 0) extraInfo += $"**{Math.Round(res.snow.Snow1h, 1)}mm** *of snow in the last hour*\n"; } - // extraInfo += $"Local time: **{DateTime.UtcNow.AddSeconds(res.timezone):hh\\:mmtt}**"; - EmbedBuilder builder = new EmbedBuilder() .WithTitle($"{res.name} Weather ({res.sys.country}) [{DateTime.UtcNow.AddSeconds(res.timezone):hh\\:mmtt}]") @@ -223,38 +126,45 @@ public async Task CurentWeather(params string[] city) .WithFooter( $"{res.clouds.all}% cloud cover with {GetWindDirection((float)res.wind.Deg)} {Math.Round((res.wind.Speed * 60f * 60f) / 1000f, 2)} km/h winds & {res.main.Humidity}% humidity.") .WithColor(GetColour(res.main.Temp)); + + return builder; + } + + [Command("Weather"), HideFromHelp, Priority(20)] + [Summary("Attempts to provide the weather of the user provided.")] + public async Task CurentWeather(IUser user) + { + if (!await DoesUserHaveDefaultCity(user)) + return; + + var city = await UserExtendedService.GetUserDefaultCity(user); + var builder = await WeatherEmbed(city, user.GetUserPreferredName()); + if (builder == null) + return; await ReplyAsync(embed: builder.Build()); } - private string GetWindDirection(float windDeg) + [Command("Weather"), Priority(20)] + [Summary("Attempts to provide the weather of the city provided.")] + public async Task CurentWeather(params string[] city) { - if (windDeg < 22.5) - return "N"; - if (windDeg < 67.5) - return "NE"; - if (windDeg < 112.5) - return "E"; - if (windDeg < 157.5) - return "SE"; - if (windDeg < 202.5) - return "S"; - if (windDeg < 247.5) - return "SW"; - if (windDeg < 292.5) - return "W"; - if (windDeg < 337.5) - return "NW"; - return "N"; + var builder = await WeatherEmbed(string.Join(" ", city)); + if (builder == null) + return; + + await ReplyAsync(embed: builder.Build()); } + + #endregion // Weather - [Command("Pollution"), Priority(21)] - [Summary("Attempts to provide the pollution conditions of the city provided.")] - public async Task Pollution(params string[] city) + #region Pollution + + private async Task PollutionEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: string.Join(" ", city)); + WeatherContainer.Result res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) - return; + return null; // We can't really combine the call as having WeatherResults helps with other details PollutionContainer.Result polResult = @@ -292,28 +202,83 @@ public async Task Pollution(params string[] city) .WithTitle($"{res.name} Pollution ({res.sys.country})") .AddField($"Air Quality: **{AQI_Index[polResult.list[0].main.aqi]}** [Pollutants {combined:F2}μg/m3]\n", desc); + return builder; + } + + [Command("Pollution"), HideFromHelp, Priority(21)] + [Summary("Attempts to provide the pollution conditions of the user provided.")] + public async Task Pollution(IUser user) + { + if (!await DoesUserHaveDefaultCity(user)) + return; + + var city = await UserExtendedService.GetUserDefaultCity(user); + var builder = await PollutionEmbed(city, user.GetUserPreferredName()); + if (builder == null) + return; + await ReplyAsync(embed: builder.Build()); } - // Time - [Command("Time"), Alias("timezone"), Priority(22)] - [Summary("Attempts to provide the time of the city/location provided.")] - public async Task Time(params string[] city) + [Command("Pollution"), Priority(21)] + [Summary("Attempts to provide the pollution conditions of the city provided.")] + public async Task Pollution(params string[] city) { - WeatherContainer.Result res = await WeatherService.GetWeather(city: string.Join(" ", city)); - if (!await IsResultsValid(res)) + var builder = await PollutionEmbed(string.Join(" ", city)); + if (builder == null) return; + await ReplyAsync(embed: builder.Build()); + } + + #endregion // Pollution + + #region Time + + private async Task TimeEmbed(string city, string replaceCityWith = "") + { + WeatherContainer.Result res = await WeatherService.GetWeather(city: city); + if (!await IsResultsValid(res)) + return null; + var timezone = res.timezone / 3600; EmbedBuilder builder = new EmbedBuilder() - .WithTitle($"{res.name} Time ({res.sys.country})") + .WithTitle($"{(replaceCityWith.Length == 0 ? res.name : replaceCityWith)} Time ({res.sys.country})") // Timestamp is UTC, so we need to add the timezone offset to get the local time in format "Sunday, June 04, 2023 11:01:09" .WithDescription($"{DateTime.UtcNow.AddSeconds(res.timezone):dddd, MMMM dd, yyyy hh:mm:ss}") .AddField("Timezone", $"UTC {(timezone > 0 ? "+" : "")}{timezone}:00"); + return builder; + } + + [Command("Time"), HideFromHelp, Priority(22)] + [Summary("Attempts to provide the time of the user provided.")] + public async Task Time(IUser user) + { + if (!await DoesUserHaveDefaultCity(user)) + return; + + var city = await UserExtendedService.GetUserDefaultCity(user); + var builder = await TimeEmbed(city, user.GetUserPreferredName()); + if (builder == null) + return; + await ReplyAsync(embed: builder.Build()); } + [Command("Time"), Priority(22)] + [Summary("Attempts to provide the time of the city/location provided.")] + public async Task Time(params string[] city) + { + var builder = await TimeEmbed(string.Join(" ", city)); + if (builder == null) + return; + + await ReplyAsync(embed: builder.Build()); + } + + #endregion // Time + #region Utility Methods private async Task IsResultsValid(T res) @@ -341,5 +306,36 @@ private Color GetColour(float temp) }; } + private async Task DoesUserHaveDefaultCity(IUser user) + { + // If they do, return true + if (await UserExtendedService.DoesUserHaveDefaultCity(user)) return true; + + // Otherwise respond and return false + await ReplyAsync($"User {user.Username} does not have a default city set."); + return false; + } + + private static string GetWindDirection(float windDeg) + { + if (windDeg < 22.5) + return "N"; + if (windDeg < 67.5) + return "NE"; + if (windDeg < 112.5) + return "E"; + if (windDeg < 157.5) + return "SE"; + if (windDeg < 202.5) + return "S"; + if (windDeg < 247.5) + return "SW"; + if (windDeg < 292.5) + return "W"; + if (windDeg < 337.5) + return "NW"; + return "N"; + } + #endregion Utility Methods } diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index f9b9cc78..0296ab9a 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -99,6 +99,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .BuildServiceProvider(); private static void DeserializeSettings() diff --git a/DiscordBot/Services/UserExtendedService.cs b/DiscordBot/Services/UserExtendedService.cs new file mode 100644 index 00000000..0de911c9 --- /dev/null +++ b/DiscordBot/Services/UserExtendedService.cs @@ -0,0 +1,59 @@ +namespace DiscordBot.Services; + +/// +/// May be renamed later. +/// Current purpose is a cache for user data for "fun" commands which only includes DefaultCity behaviour. +/// +public class UserExtendedService +{ + private readonly DatabaseService _databaseService; + + // Cached Information + private Dictionary _cityCachedName = new(); + + public UserExtendedService(DatabaseService databaseService) + { + _databaseService = databaseService; + } + + public async Task SetUserDefaultCity(IUser user, string city) + { + // Update Database + await _databaseService.Query().UpdateDefaultCity(user.Id.ToString(), city); + // Update Cache + _cityCachedName[user.Id] = city; + return true; + } + + public async Task DoesUserHaveDefaultCity(IUser user) + { + // Quickest check if we have cached result + if (_cityCachedName.ContainsKey(user.Id)) + return true; + + // Check database + var res = await _databaseService.Query().GetDefaultCity(user.Id.ToString()); + if (string.IsNullOrEmpty(res)) + return false; + + // Cache result + _cityCachedName[user.Id] = res; + return true; + } + + public async Task GetUserDefaultCity(IUser user) + { + if (await DoesUserHaveDefaultCity(user)) + return _cityCachedName[user.Id]; + return ""; + } + + public async Task RemoveUserDefaultCity(IUser user) + { + // Update Database + await _databaseService.Query().UpdateDefaultCity(user.Id.ToString(), null); + // Update Cache + _cityCachedName.Remove(user.Id); + return true; + } +} \ No newline at end of file diff --git a/DiscordBot/Services/WeatherService.cs b/DiscordBot/Services/WeatherService.cs index d1aeb215..5f9665dc 100644 --- a/DiscordBot/Services/WeatherService.cs +++ b/DiscordBot/Services/WeatherService.cs @@ -1,7 +1,7 @@ using Discord.WebSocket; -using DiscordBot.Modules; using DiscordBot.Settings; using DiscordBot.Utils; +using DiscordBot.Modules.Weather; namespace DiscordBot.Services; @@ -20,16 +20,23 @@ public WeatherService(DiscordSocketClient client, ILoggingService loggingService } - public async Task GetWeather(string city, string unit = "metric") + public async Task GetWeather(string city, string unit = "metric") { var query = $"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={_weatherApiKey}&units={unit}"; - return await SerializeUtil.LoadUrlDeserializeResult(query); + return await SerializeUtil.LoadUrlDeserializeResult(query); } - public async Task GetPollution(double lon, double lat) + public async Task GetPollution(double lon, double lat) { var query = $"https://api.openweathermap.org/data/2.5/air_pollution?lat={lat}&lon={lon}&appid={_weatherApiKey}"; - return await SerializeUtil.LoadUrlDeserializeResult(query); + return await SerializeUtil.LoadUrlDeserializeResult(query); + } + + public async Task<(bool exists, WeatherContainer.Result result)> CityExists(string city) + { + var res = await GetWeather(city: city); + var exists = !object.Equals(res, default(WeatherContainer.Result)); + return (exists, res); } } \ No newline at end of file From 2ba092885804e429e5634f0a40b4de202311418a Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 6 Jan 2024 22:49:02 +1000 Subject: [PATCH 23/66] Update WeatherModule.cs --- DiscordBot/Modules/Weather/WeatherModule.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs index 82435454..f3bc4784 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -63,6 +63,7 @@ public async Task Temperature(IUser user) var builder = await TemperatureEmbed(city, user.GetUserPreferredName()); if (builder == null) return; + builder.FooterRequestedBy(Context.User); await ReplyAsync(embed: builder.Build()); } @@ -118,7 +119,7 @@ private async Task WeatherEmbed(string city, string replaceCityWit } EmbedBuilder builder = new EmbedBuilder() - .WithTitle($"{res.name} Weather ({res.sys.country}) [{DateTime.UtcNow.AddSeconds(res.timezone):hh\\:mmtt}]") + .WithTitle($"{(replaceCityWith.Length == 0 ? res.name : replaceCityWith)} Weather ({res.sys.country}) [{DateTime.UtcNow.AddSeconds(res.timezone):hh\\:mmtt}]") .AddField( $"Weather: **{Math.Round(res.main.Temp, 1)}°C** [Feels like **{Math.Round(res.main.Feels, 1)}°C**]", $"{extraInfo}\n") @@ -141,6 +142,7 @@ public async Task CurentWeather(IUser user) var builder = await WeatherEmbed(city, user.GetUserPreferredName()); if (builder == null) return; + builder.FooterRequestedBy(Context.User); await ReplyAsync(embed: builder.Build()); } @@ -199,7 +201,7 @@ private async Task PollutionEmbed(string city, string replaceCityW } EmbedBuilder builder = new EmbedBuilder() - .WithTitle($"{res.name} Pollution ({res.sys.country})") + .WithTitle($"{(replaceCityWith.Length == 0 ? res.name : replaceCityWith)} Pollution ({res.sys.country})") .AddField($"Air Quality: **{AQI_Index[polResult.list[0].main.aqi]}** [Pollutants {combined:F2}μg/m3]\n", desc); return builder; @@ -216,6 +218,7 @@ public async Task Pollution(IUser user) var builder = await PollutionEmbed(city, user.GetUserPreferredName()); if (builder == null) return; + builder.FooterRequestedBy(Context.User); await ReplyAsync(embed: builder.Build()); } @@ -262,6 +265,7 @@ public async Task Time(IUser user) var builder = await TimeEmbed(city, user.GetUserPreferredName()); if (builder == null) return; + builder.FooterRequestedBy(Context.User); await ReplyAsync(embed: builder.Build()); } From 1129f81dcf6c9beebc844f4bce0f17258a0a3e9c Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 7 Jan 2024 09:57:44 +1000 Subject: [PATCH 24/66] `AddUser` DatabaseService to return user once created --- DiscordBot/Services/DatabaseService.cs | 11 +++++++++-- DiscordBot/Services/UserService.cs | 7 ++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 5ab09610..95db56bc 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -154,13 +154,17 @@ await _logging.LogChannelAndFile( $"Database Synchronized {counter.ToString()} Users Successfully.\n{newAdd.ToString()} missing users added."); } - public async Task AddNewUser(SocketGuildUser socketUser) + /// + /// Adds a new user to the database if they don't already exist. + /// + /// Existing or newly created user. Null on database error. + public async Task AddNewUser(SocketGuildUser socketUser) { try { var user = await Query().GetUser(socketUser.Id.ToString()); if (user != null) - return; + return user; user = new ServerUser { @@ -168,15 +172,18 @@ public async Task AddNewUser(SocketGuildUser socketUser) }; await Query().InsertUser(user); + user = await Query().GetUser(socketUser.Id.ToString()); await _logging.Log(LogBehaviour.File, $"User {socketUser.GetPreferredAndUsername()} successfully added to the database."); + return user; } catch (Exception e) { // We don't print to channel as this could be spammy (Albeit rare) await _logging.Log(LogBehaviour.Console | LogBehaviour.File, $"Error when trying to add user {socketUser.Id.ToString()} to the database : {e}", ExtendedLogSeverity.Warning); + return null; } } diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index d066a5e8..e92a9d03 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -222,12 +222,9 @@ public async Task UpdateXp(SocketMessage messageParam) if (_xpCooldown.HasUser(userId)) return; - var user = await _databaseService.Query().GetUser(userId.ToString()); + var user = await _databaseService.AddNewUser((SocketGuildUser)messageParam.Author); if (user == null) - { - await _databaseService.AddNewUser((SocketGuildUser)messageParam.Author); - user = await _databaseService.Query().GetUser(userId.ToString()); - } + return; bonusXp += baseXp * (1f + user.Karma / 100f); From bec94475be08e55e70f3dff37e92f52ae55e8c4b Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 7 Jan 2024 10:46:33 +1000 Subject: [PATCH 25/66] Update DatabaseService to use Property --- DiscordBot/Modules/ModerationModule.cs | 2 +- DiscordBot/Modules/UserModule.cs | 14 ++++++------ DiscordBot/Services/DatabaseService.cs | 25 +++++++++++----------- DiscordBot/Services/UserExtendedService.cs | 6 +++--- DiscordBot/Services/UserService.cs | 20 ++++++++--------- 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/DiscordBot/Modules/ModerationModule.cs b/DiscordBot/Modules/ModerationModule.cs index 59663444..ebf3a8ea 100644 --- a/DiscordBot/Modules/ModerationModule.cs +++ b/DiscordBot/Modules/ModerationModule.cs @@ -404,7 +404,7 @@ await message.ModifyAsync(properties => [RequireUserPermission(GuildPermission.Administrator)] public async Task DbSync(IUser user) { - await DatabaseService.AddNewUser((SocketGuildUser)user); + await DatabaseService.GetOrAddUser((SocketGuildUser)user); } [Command("DBFullSync")] diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index f048e3d3..2e0031a0 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -250,8 +250,8 @@ public async Task UserCompleted(string message) } const uint xpGain = 5000; - var userXp = await DatabaseService.Query().GetXp(userId.ToString()); - await DatabaseService.Query().UpdateXp(userId.ToString(), userXp + xpGain); + var userXp = await DatabaseService.Query.GetXp(userId.ToString()); + await DatabaseService.Query.UpdateXp(userId.ToString(), userXp + xpGain); await Context.Message.DeleteAsync(); } @@ -409,7 +409,7 @@ public async Task KarmaDescription(int seconds = 60) [Alias("toplevel", "ranking")] public async Task TopLevel() { - var users = await DatabaseService.Query().GetTopLevel(10); + var users = await DatabaseService.Query.GetTopLevel(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.Level)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Level"); @@ -421,7 +421,7 @@ public async Task TopLevel() [Alias("karmarank", "rankingkarma", "topk")] public async Task TopKarma() { - var users = await DatabaseService.Query().GetTopKarma(10); + var users = await DatabaseService.Query.GetTopKarma(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.Karma)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Karma"); @@ -433,7 +433,7 @@ public async Task TopKarma() [Alias("karmarankweekly", "rankingkarmaweekly", "topkw")] public async Task TopKarmaWeekly() { - var users = await DatabaseService.Query().GetTopKarmaWeekly(10); + var users = await DatabaseService.Query.GetTopKarmaWeekly(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaWeekly)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Weekly Karma"); @@ -445,7 +445,7 @@ public async Task TopKarmaWeekly() [Alias("karmarankmonthly", "rankingkarmamonthly", "topkm")] public async Task TopKarmaMonthly() { - var users = await DatabaseService.Query().GetTopKarmaMonthly(10); + var users = await DatabaseService.Query.GetTopKarmaMonthly(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaMonthly)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Monthly Karma"); @@ -457,7 +457,7 @@ public async Task TopKarmaMonthly() [Alias("karmaranktearly", "rankingkarmayearly", "topky")] public async Task TopKarmaYearly() { - var users = await DatabaseService.Query().GetTopKarmaYearly(10); + var users = await DatabaseService.Query.GetTopKarmaYearly(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaYearly)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Yearly Karma"); diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 95db56bc..f87aaa07 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -13,8 +13,7 @@ public class DatabaseService private readonly ILoggingService _logging; private string ConnectionString { get; } - public IServerUserRepo Query() => _connection; - private readonly IServerUserRepo _connection; + public IServerUserRepo Query { get; } public DatabaseService(ILoggingService logging, BotSettings settings) { @@ -25,7 +24,7 @@ public DatabaseService(ILoggingService logging, BotSettings settings) try { c = new MySqlConnection(ConnectionString); - _connection = c.As(); + Query = c.As(); } catch (Exception e) { @@ -39,7 +38,7 @@ public DatabaseService(ILoggingService logging, BotSettings settings) // Test connection, if it fails we create the table and set keys try { - var userCount = await _connection.TestConnection(); + var userCount = await Query.TestConnection(); await _logging.LogAction( $"{ServiceName}: Connected to database successfully. {userCount} users in database.", ExtendedLogSeverity.Positive); @@ -129,10 +128,10 @@ await message.ModifyAsync(msg => if (!user.IsBot) { var userIdString = user.Id.ToString(); - var serverUser = await Query().GetUser(userIdString); + var serverUser = await Query.GetUser(userIdString); if (serverUser == null) { - await AddNewUser(user as SocketGuildUser); + await GetOrAddUser(user as SocketGuildUser); newAdd++; } } @@ -158,11 +157,11 @@ await _logging.LogChannelAndFile( /// Adds a new user to the database if they don't already exist. /// /// Existing or newly created user. Null on database error. - public async Task AddNewUser(SocketGuildUser socketUser) + public async Task GetOrAddUser(SocketGuildUser socketUser) { try { - var user = await Query().GetUser(socketUser.Id.ToString()); + var user = await Query.GetUser(socketUser.Id.ToString()); if (user != null) return user; @@ -171,8 +170,8 @@ public async Task AddNewUser(SocketGuildUser socketUser) UserID = socketUser.Id.ToString(), }; - await Query().InsertUser(user); - user = await Query().GetUser(socketUser.Id.ToString()); + await Query.InsertUser(user); + user = await Query.GetUser(socketUser.Id.ToString()); await _logging.Log(LogBehaviour.File, $"User {socketUser.GetPreferredAndUsername()} successfully added to the database."); @@ -191,9 +190,9 @@ public async Task DeleteUser(ulong id) { try { - var user = await Query().GetUser(id.ToString()); + var user = await Query.GetUser(id.ToString()); if (user != null) - await Query().RemoveUser(user.UserID); + await Query.RemoveUser(user.UserID); } catch (Exception e) { @@ -204,6 +203,6 @@ await _logging.Log(LogBehaviour.Console | LogBehaviour.File, public async Task UserExists(ulong id) { - return (await Query().GetUser(id.ToString()) != null); + return (await Query.GetUser(id.ToString()) != null); } } \ No newline at end of file diff --git a/DiscordBot/Services/UserExtendedService.cs b/DiscordBot/Services/UserExtendedService.cs index 0de911c9..7a3df56f 100644 --- a/DiscordBot/Services/UserExtendedService.cs +++ b/DiscordBot/Services/UserExtendedService.cs @@ -19,7 +19,7 @@ public UserExtendedService(DatabaseService databaseService) public async Task SetUserDefaultCity(IUser user, string city) { // Update Database - await _databaseService.Query().UpdateDefaultCity(user.Id.ToString(), city); + await _databaseService.Query.UpdateDefaultCity(user.Id.ToString(), city); // Update Cache _cityCachedName[user.Id] = city; return true; @@ -32,7 +32,7 @@ public async Task DoesUserHaveDefaultCity(IUser user) return true; // Check database - var res = await _databaseService.Query().GetDefaultCity(user.Id.ToString()); + var res = await _databaseService.Query.GetDefaultCity(user.Id.ToString()); if (string.IsNullOrEmpty(res)) return false; @@ -51,7 +51,7 @@ public async Task GetUserDefaultCity(IUser user) public async Task RemoveUserDefaultCity(IUser user) { // Update Database - await _databaseService.Query().UpdateDefaultCity(user.Id.ToString(), null); + await _databaseService.Query.UpdateDefaultCity(user.Id.ToString(), null); // Update Cache _cityCachedName.Remove(user.Id); return true; diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index e92a9d03..d4e6c521 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -222,7 +222,7 @@ public async Task UpdateXp(SocketMessage messageParam) if (_xpCooldown.HasUser(userId)) return; - var user = await _databaseService.AddNewUser((SocketGuildUser)messageParam.Author); + var user = await _databaseService.GetOrAddUser((SocketGuildUser)messageParam.Author); if (user == null) return; @@ -239,7 +239,7 @@ public async Task UpdateXp(SocketMessage messageParam) var xpGain = (int)Math.Round((baseXp + bonusXp) * reduceXp); _xpCooldown.AddCooldown(userId, waitTime); - await _databaseService.Query().UpdateXp(userId.ToString(), user.Exp + (uint)xpGain); + await _databaseService.Query.UpdateXp(userId.ToString(), user.Exp + (uint)xpGain); _loggingService.LogXp(messageParam.Channel.Name, messageParam.Author.Username, baseXp, bonusXp, reduceXp, xpGain); @@ -255,15 +255,15 @@ public async Task UpdateXp(SocketMessage messageParam) /// private async Task LevelUp(SocketMessage messageParam, ulong userId) { - var level = await _databaseService.Query().GetLevel(userId.ToString()); - var xp = await _databaseService.Query().GetXp(userId.ToString()); + var level = await _databaseService.Query.GetLevel(userId.ToString()); + var xp = await _databaseService.Query.GetXp(userId.ToString()); var xpHigh = GetXpHigh(level); if (xp < xpHigh) return; - await _databaseService.Query().UpdateLevel(userId.ToString(), level + 1); + await _databaseService.Query.UpdateLevel(userId.ToString(), level + 1); // First few levels are only a couple messages, // so we hide them to avoid scaring people away and give them slightly longer to naturally see these in the server. @@ -293,7 +293,7 @@ public async Task GenerateProfileCard(IUser user) try { - var dbRepo = _databaseService.Query(); + var dbRepo = _databaseService.Query; if (dbRepo == null) return profileCardPath; @@ -487,13 +487,13 @@ await messageParam.Channel.SendMessageAsync( continue; } - await _databaseService.Query().IncrementKarma(user.Id.ToString()); + await _databaseService.Query.IncrementKarma(user.Id.ToString()); sb.Append(user.GetUserPreferredName()).Append("**, **"); } // Even if a user gives multiple karma in one message, we only add one. - var authorKarmaGiven = await _databaseService.Query().GetKarmaGiven(messageParam.Author.Id.ToString()); - await _databaseService.Query().UpdateKarmaGiven(messageParam.Author.Id.ToString(), authorKarmaGiven + 1); + var authorKarmaGiven = await _databaseService.Query.GetKarmaGiven(messageParam.Author.Id.ToString()); + await _databaseService.Query.UpdateKarmaGiven(messageParam.Author.Id.ToString(), authorKarmaGiven + 1); sb.Length -= 4; //Removes last instance of appended comma/startbold without convoluted tracking //sb.Append("**"); // Already appended an endbold @@ -638,7 +638,7 @@ private async Task UserJoined(SocketGuildUser user) await DMFormattedWelcome(user); var socketTextChannel = _client.GetChannel(_settings.GeneralChannel.Id) as SocketTextChannel; - await _databaseService.AddNewUser(user); + await _databaseService.GetOrAddUser(user); // Check if moderator commands are enabled, and if so we check if they were previously muted. if (_settings.ModeratorCommandsEnabled) From 9a6cbfd101c4aabfda1fae795850ee469fe2e4d9 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 20 Jan 2024 13:05:16 +1000 Subject: [PATCH 26/66] Attribute: Ignores Bots who try to use the command --- DiscordBot/Attributes/IgnoreBotsAttribute.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 DiscordBot/Attributes/IgnoreBotsAttribute.cs diff --git a/DiscordBot/Attributes/IgnoreBotsAttribute.cs b/DiscordBot/Attributes/IgnoreBotsAttribute.cs new file mode 100644 index 00000000..0c5b1c3c --- /dev/null +++ b/DiscordBot/Attributes/IgnoreBotsAttribute.cs @@ -0,0 +1,20 @@ +using Discord.Commands; + +namespace DiscordBot.Attributes; + +/// +/// Simple attribute, if the command is used by a bot, it escapes early and doesn't run the command. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class IgnoreBotsAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + if (context.Message.Author.IsBot) + { + return Task.FromResult(PreconditionResult.FromError(string.Empty)); + } + + return Task.FromResult(PreconditionResult.FromSuccess()); + } +} \ No newline at end of file From 7e76ea96a21b4aa1ee0104c80342b21d74bfd399 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 20 Jan 2024 13:10:21 +1000 Subject: [PATCH 27/66] Split `preconditions` and move to `Attributes` --- .../Attributes/BotCommandChannelAttribute.cs | 22 ++++++++++ DiscordBot/Attributes/RoleAttributes.cs | 31 +++++++++++++ .../ThreadAttributes.cs} | 44 +------------------ DiscordBot/Modules/EmbedModule.cs | 1 + DiscordBot/Modules/TicketModule.cs | 1 + DiscordBot/Modules/UserModule.cs | 18 +++----- 6 files changed, 63 insertions(+), 54 deletions(-) create mode 100644 DiscordBot/Attributes/BotCommandChannelAttribute.cs create mode 100644 DiscordBot/Attributes/RoleAttributes.cs rename DiscordBot/{Preconditions.cs => Attributes/ThreadAttributes.cs} (68%) diff --git a/DiscordBot/Attributes/BotCommandChannelAttribute.cs b/DiscordBot/Attributes/BotCommandChannelAttribute.cs new file mode 100644 index 00000000..d8eada5d --- /dev/null +++ b/DiscordBot/Attributes/BotCommandChannelAttribute.cs @@ -0,0 +1,22 @@ +using Discord.Commands; +using DiscordBot.Settings; +using Microsoft.Extensions.DependencyInjection; + +namespace DiscordBot.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class BotCommandChannelAttribute : PreconditionAttribute +{ + public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + var settings = services.GetRequiredService(); + + if (context.Channel.Id == settings.BotCommandsChannel.Id) + { + return await Task.FromResult(PreconditionResult.FromSuccess()); + } + + Task task = context.Message.DeleteAfterSeconds(seconds: 10); + return await Task.FromResult(PreconditionResult.FromError($"This command can only be used in <#{settings.BotCommandsChannel.Id.ToString()}>.")); + } +} \ No newline at end of file diff --git a/DiscordBot/Attributes/RoleAttributes.cs b/DiscordBot/Attributes/RoleAttributes.cs new file mode 100644 index 00000000..d4e337ce --- /dev/null +++ b/DiscordBot/Attributes/RoleAttributes.cs @@ -0,0 +1,31 @@ +using Discord.Commands; +using Discord.WebSocket; +using DiscordBot.Settings; +using Microsoft.Extensions.DependencyInjection; + +namespace DiscordBot.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class RequireAdminAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + var user = (SocketGuildUser)context.Message.Author; + + if (user.Roles.Any(x => x.Permissions.Administrator)) return Task.FromResult(PreconditionResult.FromSuccess()); + return Task.FromResult(PreconditionResult.FromError(user + " attempted to use admin only command!")); + } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class RequireModeratorAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + var user = (SocketGuildUser)context.Message.Author; + var settings = services.GetRequiredService(); + + if (user.Roles.Any(x => x.Id == settings.ModeratorRoleId)) return Task.FromResult(PreconditionResult.FromSuccess()); + return Task.FromResult(PreconditionResult.FromError(user + " attempted to use a moderator command!")); + } +} \ No newline at end of file diff --git a/DiscordBot/Preconditions.cs b/DiscordBot/Attributes/ThreadAttributes.cs similarity index 68% rename from DiscordBot/Preconditions.cs rename to DiscordBot/Attributes/ThreadAttributes.cs index 938df8dc..8cd6583b 100644 --- a/DiscordBot/Preconditions.cs +++ b/DiscordBot/Attributes/ThreadAttributes.cs @@ -3,49 +3,7 @@ using DiscordBot.Settings; using Microsoft.Extensions.DependencyInjection; -namespace DiscordBot; - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public class RequireAdminAttribute : PreconditionAttribute -{ - public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) - { - var user = (SocketGuildUser)context.Message.Author; - - if (user.Roles.Any(x => x.Permissions.Administrator)) return Task.FromResult(PreconditionResult.FromSuccess()); - return Task.FromResult(PreconditionResult.FromError(user + " attempted to use admin only command!")); - } -} - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public class RequireModeratorAttribute : PreconditionAttribute -{ - public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) - { - var user = (SocketGuildUser)context.Message.Author; - var settings = services.GetRequiredService(); - - if (user.Roles.Any(x => x.Id == settings.ModeratorRoleId)) return Task.FromResult(PreconditionResult.FromSuccess()); - return Task.FromResult(PreconditionResult.FromError(user + " attempted to use a moderator command!")); - } -} - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public class BotChannelOnlyAttribute : PreconditionAttribute -{ - public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) - { - var settings = services.GetRequiredService(); - - if (context.Channel.Id == settings.BotCommandsChannel.Id) - { - return await Task.FromResult(PreconditionResult.FromSuccess()); - } - - Task task = context.Message.DeleteAfterSeconds(seconds: 10); - return await Task.FromResult(PreconditionResult.FromError($"This command can only be used in <#{settings.BotCommandsChannel.Id.ToString()}>.")); - } -} +namespace DiscordBot.Attributes; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class RequireThreadAttribute : PreconditionAttribute diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index bc59f999..f46bd30a 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using Discord.Commands; +using DiscordBot.Attributes; using Newtonsoft.Json; // ReSharper disable all UnusedMember.Local diff --git a/DiscordBot/Modules/TicketModule.cs b/DiscordBot/Modules/TicketModule.cs index bdd07d55..67fb8f71 100644 --- a/DiscordBot/Modules/TicketModule.cs +++ b/DiscordBot/Modules/TicketModule.cs @@ -1,4 +1,5 @@ using Discord.Commands; +using DiscordBot.Attributes; using DiscordBot.Services; using DiscordBot.Settings; diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index 2e0031a0..ebb72b57 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -255,7 +255,7 @@ public async Task UserCompleted(string message) await Context.Message.DeleteAsync(); } - [Group("Role"), BotChannelOnly] + [Group("Role"), BotCommandChannel] public class RoleModule : ModuleBase { public BotSettings Settings { get; set; } @@ -640,7 +640,7 @@ public async Task CoinFlip() #region Publisher - [Command("PInfo"), BotChannelOnly, Priority(11)] + [Command("PInfo"), BotCommandChannel, Priority(11)] [Summary("Information on how to get publisher role.")] [Alias("publisherinfo")] public async Task PublisherInfo() @@ -656,7 +656,7 @@ public async Task PublisherInfo() await Context.Message.DeleteAfterSeconds(seconds: 2); } - [Command("Publisher"), BotChannelOnly, HideFromHelp] + [Command("Publisher"), BotCommandChannel, HideFromHelp] [Summary("Get the Asset-Publisher role by verifying who you are. Syntax: !publisher publisherID")] public async Task Publisher(uint publisherId) { @@ -676,7 +676,7 @@ public async Task Publisher(uint publisherId) await Context.Message.DeleteAfterSeconds(seconds: 1); } - [Command("Verify"), BotChannelOnly, HideFromHelp] + [Command("Verify"), BotCommandChannel, HideFromHelp] [Summary("Verify a publisher with the code received by email. Syntax : !verify publisherId code")] public async Task VerifyPackage(uint packageId, string code) { @@ -1014,13 +1014,9 @@ private int ParseNumber(string s) public async Task Birthday() { // URL to cell C15/"Next birthday" cell from Corn's google sheet - var nextBirthday = - "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; - var doc = new HtmlWeb().Load(nextBirthday); - - // XPath to the table row - var row = doc.DocumentNode.SelectSingleNode("/html/body/table/tr[2]/td"); - var tableText = WebUtility.HtmlDecode(row.InnerText); + const string nextBirthday = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; + + var tableText = await WebUtil.GetHtmlNodeInnerText(nextBirthday, "/html/body/table/tr[2]/td"); var message = $"**{tableText}**"; await ReplyAsync(message).DeleteAfterTime(minutes: 3); From a041648863f5e58a78b7a253593536ff434cd3f3 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 20 Jan 2024 13:10:45 +1000 Subject: [PATCH 28/66] Don't send a message if a pre-condition is empty --- DiscordBot/Services/CommandHandlingService.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index 77206f63..8b0264aa 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -209,6 +209,10 @@ private async Task HandleCommand(SocketMessage messageParam) if (result is PreconditionGroupResult groupResult) { resultString = groupResult.PreconditionResults.First().ErrorReason; + + // Pre-condition doesn't have a reason, we don't respond. + if (resultString == string.Empty) + return; } await context.Channel.SendMessageAsync(resultString).DeleteAfterSeconds(10); } From 8736a806f574b6c74d16d54fb14fa5fd68b44341 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 20 Jan 2024 13:11:41 +1000 Subject: [PATCH 29/66] Attempt to avoid a race condition by updating database later Not even sure this helps at all, I don't think so. Problem seems to be somewhere with database opening and never closing before something else tries to open another query but I can't find it. --- DiscordBot/Services/UserService.cs | 36 +++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index d4e6c521..b4bee628 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -222,29 +222,33 @@ public async Task UpdateXp(SocketMessage messageParam) if (_xpCooldown.HasUser(userId)) return; - var user = await _databaseService.GetOrAddUser((SocketGuildUser)messageParam.Author); - if (user == null) - return; + // Add Delay and delay action by 200ms to avoid some weird database collision? + _xpCooldown.AddCooldown(userId, waitTime); + Task.Run(async () => + { + var user = await _databaseService.GetOrAddUser((SocketGuildUser)messageParam.Author); + if (user == null) + return; - bonusXp += baseXp * (1f + user.Karma / 100f); + bonusXp += baseXp * (1f + user.Karma / 100f); - //Reduce XP for members with no role - if (((IGuildUser)messageParam.Author).RoleIds.Count < 2) - baseXp *= .9f; + //Reduce XP for members with no role + if (((IGuildUser)messageParam.Author).RoleIds.Count < 2) + baseXp *= .9f; - //Lower xp for difference between level and karma - var reduceXp = 1f; - if (user.Karma < user.Level) reduceXp = 1 - Math.Min(.9f, (user.Level - user.Karma) * .05f); + //Lower xp for difference between level and karma + var reduceXp = 1f; + if (user.Karma < user.Level) reduceXp = 1 - Math.Min(.9f, (user.Level - user.Karma) * .05f); - var xpGain = (int)Math.Round((baseXp + bonusXp) * reduceXp); - _xpCooldown.AddCooldown(userId, waitTime); + var xpGain = (int)Math.Round((baseXp + bonusXp) * reduceXp); - await _databaseService.Query.UpdateXp(userId.ToString(), user.Exp + (uint)xpGain); + await _databaseService.Query.UpdateXp(userId.ToString(), user.Exp + (uint)xpGain); - _loggingService.LogXp(messageParam.Channel.Name, messageParam.Author.Username, baseXp, bonusXp, reduceXp, - xpGain); + _loggingService.LogXp(messageParam.Channel.Name, messageParam.Author.Username, baseXp, bonusXp, reduceXp, + xpGain); - await LevelUp(messageParam, userId); + await LevelUp(messageParam, userId); + }); } /// From a56c947b9f9205e78e7f6d15f1d128ed37ed5a3f Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 20 Jan 2024 13:14:40 +1000 Subject: [PATCH 30/66] Update bday so order of op is more clear --- DiscordBot/Modules/UserModule.cs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index ebb72b57..cad0ce5e 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -1030,29 +1030,29 @@ public async Task Birthday(IUser user) { var searchName = user.Username; // URL to columns B to D of Corn's google sheet - var birthdayTable = - "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; - var doc = new HtmlWeb().Load(birthdayTable); + const string birthdayTable = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; + var relevantNodes = await WebUtil.GetHtmlNodes(birthdayTable, "/html/body/table/tr"); + var birthdate = default(DateTime); HtmlNode matchedNode = null; var matchedLength = int.MaxValue; // XPath to each table row - foreach (var row in doc.DocumentNode.SelectNodes("/html/body/table/tr")) + foreach (var row in relevantNodes) { // XPath to the name column (C) var nameNode = row.SelectSingleNode("td[2]"); var name = nameNode.InnerText; - if (name.ToLower().Contains(searchName.ToLower())) - // Check for a "Closer" match - if (name.Length < matchedLength) - { - matchedNode = row; - matchedLength = name.Length; - // Nothing will match "Better" so we may as well break out - if (name.Length == searchName.Length) break; - } + + if (!name.ToLower().Contains(searchName.ToLower()) || name.Length >= matchedLength) + continue; + + // Check for a "Closer" match + matchedNode = row; + matchedLength = name.Length; + // Nothing will match "Better" so we may as well break out + if (name.Length == searchName.Length) break; } if (matchedNode != null) From bc0ed9ee14dcd39a57e34dd6611384b674f89620 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 20 Jan 2024 13:53:44 +1000 Subject: [PATCH 31/66] Update Weather to work without a user input (Default = Context.Author) --- DiscordBot/Modules/Weather/WeatherModule.cs | 12 ++++++++---- DiscordBot/Services/ModerationService.cs | 8 ++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs index f3bc4784..f00ccbd5 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -54,8 +54,9 @@ private async Task TemperatureEmbed(string city, string replaceCit [Command("Temperature"), HideFromHelp] [Summary("Attempts to provide the temperature of the user provided.")] [Alias("temp"), Priority(20)] - public async Task Temperature(IUser user) + public async Task Temperature(IUser user = null) { + user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; @@ -133,8 +134,9 @@ private async Task WeatherEmbed(string city, string replaceCityWit [Command("Weather"), HideFromHelp, Priority(20)] [Summary("Attempts to provide the weather of the user provided.")] - public async Task CurentWeather(IUser user) + public async Task CurentWeather(IUser user = null) { + user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; @@ -209,8 +211,9 @@ private async Task PollutionEmbed(string city, string replaceCityW [Command("Pollution"), HideFromHelp, Priority(21)] [Summary("Attempts to provide the pollution conditions of the user provided.")] - public async Task Pollution(IUser user) + public async Task Pollution(IUser user = null) { + user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; @@ -256,8 +259,9 @@ private async Task TimeEmbed(string city, string replaceCityWith = [Command("Time"), HideFromHelp, Priority(22)] [Summary("Attempts to provide the time of the user provided.")] - public async Task Time(IUser user) + public async Task Time(IUser user = null) { + user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index eaf82150..d7dd5118 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -53,9 +53,7 @@ private async Task MessageDeleted(Cacheable message, Cacheable< // TimeStamp for the Footer - await _loggingService.LogAction( - $"User {user.GetPreferredAndUsername()} has " + - $"deleted the message\n{content}\n from channel #{(await channel.GetOrDownloadAsync()).Name}", ExtendedLogSeverity.Info, embed); + await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); } private async Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) @@ -91,8 +89,6 @@ private async Task MessageUpdated(Cacheable before, SocketMessa // TimeStamp for the Footer - await _loggingService.LogAction( - $"User {user.GetPreferredAndUsername()} has " + - $"updated the message\n{content}\n in channel #{channel.Name}", ExtendedLogSeverity.Info, embed); + await _loggingService.Log(LogBehaviour.Channel,string.Empty, ExtendedLogSeverity.Info, embed); } } \ No newline at end of file From 6171d5949dad481d8f88ade596be47e016055ee1 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 20 Jan 2024 13:56:09 +1000 Subject: [PATCH 32/66] Add some more embed overrides --- DiscordBot/Extensions/EmbedBuilderExtension.cs | 14 ++++++++++++++ DiscordBot/Services/ModerationService.cs | 11 ++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/DiscordBot/Extensions/EmbedBuilderExtension.cs b/DiscordBot/Extensions/EmbedBuilderExtension.cs index 88f86dd4..25296cc2 100644 --- a/DiscordBot/Extensions/EmbedBuilderExtension.cs +++ b/DiscordBot/Extensions/EmbedBuilderExtension.cs @@ -19,6 +19,13 @@ public static EmbedBuilder FooterQuoteBy(this EmbedBuilder builder, IUser reques return builder; } + public static EmbedBuilder FooterInChannel(this EmbedBuilder builder, IChannel channel) + { + builder.WithFooter( + $"In channel #{channel.Name}", null); + return builder; + } + public static EmbedBuilder AddAuthor(this EmbedBuilder builder, IUser user, bool includeAvatar = true) { builder.WithAuthor( @@ -27,5 +34,12 @@ public static EmbedBuilder AddAuthor(this EmbedBuilder builder, IUser user, bool return builder; } + public static EmbedBuilder AddAuthorWithAction(this EmbedBuilder builder, IUser user, string action, bool includeAvatar = true) + { + builder.WithAuthor( + $"{user.GetUserPreferredName()} - {action}", + includeAvatar ? user.GetAvatarUrl() : null); + return builder; + } } \ No newline at end of file diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index d7dd5118..97cd5105 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -44,14 +44,11 @@ private async Task MessageDeleted(Cacheable message, Cacheable< var builder = new EmbedBuilder() .WithColor(DeletedMessageColor) .WithTimestamp(message.Value.Timestamp) - .WithFooter($"In channel {message.Value.Channel.Name}") - .WithAuthor($"{user.GetPreferredAndUsername()} deleted a message") + .FooterInChannel(message.Value.Channel) + .AddAuthorWithAction(user, "Deleted a message", true) .AddField($"Deleted Message {(content.Length != message.Value.Content.Length ? "(truncated)" : "")}", content); var embed = builder.Build(); - - // TimeStamp for the Footer - await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); } @@ -80,8 +77,8 @@ private async Task MessageUpdated(Cacheable before, SocketMessa var builder = new EmbedBuilder() .WithColor(EditedMessageColor) .WithTimestamp(after.Timestamp) - .WithFooter($"In channel {after.Channel.Name}") - .WithAuthor($"{user.GetPreferredAndUsername()} updated a message"); + .FooterInChannel(after.Channel) + .AddAuthorWithAction(user, "Updated a message", true); if (isCached) builder.AddField($"Previous message content {(isTruncated ? "(truncated)" : "")}", content); builder.WithDescription($"Message: [{after.Id}]({after.GetJumpUrl()})"); From ca3fdb893d5957b68905d8bed00021ee147b474b Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 21 Jan 2024 17:49:03 +1000 Subject: [PATCH 33/66] Use LoggingService properly, attempt to continue feed even on failure --- DiscordBot/Services/UpdateService.cs | 42 +++++++++++++++++----------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index 1470298a..62be1ea4 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -48,6 +48,8 @@ public FeedData() //TODO Download all avatars to cache them public class UpdateService { + private const string ServiceName = "UpdateService"; + private readonly ILoggingService _loggingService; private readonly FeedService _feedService; private readonly BotSettings _settings; private readonly CancellationToken _token; @@ -62,10 +64,11 @@ public class UpdateService private UserData _userData; public UpdateService(DiscordSocketClient client, - DatabaseService databaseService, BotSettings settings, FeedService feedService) + DatabaseService databaseService, BotSettings settings, FeedService feedService, ILoggingService loggingService) { _client = client; _feedService = feedService; + _loggingService = loggingService as LoggingService; _settings = settings; _token = new CancellationToken(); @@ -183,9 +186,9 @@ private async Task DownloadDocDatabase() _apiDatabase = ConvertJsToArray(apiInput, false); if (!SerializeUtil.SerializeFile($"{_settings.ServerRootPath}/unitymanual.json", _manualDatabase)) - LoggingService.LogToConsole("Failed to save unitymanual.json", LogSeverity.Warning); + await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile,$"{ServiceName}: Failed to save unitymanual.json", ExtendedLogSeverity.Warning); if (!SerializeUtil.SerializeFile($"{_settings.ServerRootPath}/unityapi.json", _apiDatabase)) - LoggingService.LogToConsole("Failed to save unityapi.json", LogSeverity.Warning); + await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile,$"{ServiceName}: Failed to save unityapi.json", ExtendedLogSeverity.Warning); string[][] ConvertJsToArray(string data, bool isManual) { @@ -214,7 +217,7 @@ string[][] ConvertJsToArray(string data, bool isManual) } catch (Exception e) { - LoggingService.LogToConsole($"Failed to download manual/api file\nEx:{e.ToString()}", LogSeverity.Error); + await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile,$"{ServiceName}: Failed to download manual/api file\nEx:{e.ToString()}", ExtendedLogSeverity.Warning); } } @@ -235,23 +238,30 @@ private async Task UpdateRssFeeds() await Task.Delay(TimeSpan.FromSeconds(30d), _token); while (true) { - if (_feedData != null) + try { - if (_feedData.LastUnityReleaseCheck < DateTime.Now - TimeSpan.FromMinutes(5)) + if (_feedData != null) { - _feedData.LastUnityReleaseCheck = DateTime.Now; + if (_feedData.LastUnityReleaseCheck < DateTime.Now - TimeSpan.FromMinutes(5)) + { + _feedData.LastUnityReleaseCheck = DateTime.Now; - await _feedService.CheckUnityBetasAsync(_feedData); - await _feedService.CheckUnityReleasesAsync(_feedData); - } + await _feedService.CheckUnityBetasAsync(_feedData); + await _feedService.CheckUnityReleasesAsync(_feedData); + } - if (_feedData.LastUnityBlogCheck < DateTime.Now - TimeSpan.FromMinutes(10)) - { - _feedData.LastUnityBlogCheck = DateTime.Now; + if (_feedData.LastUnityBlogCheck < DateTime.Now - TimeSpan.FromMinutes(10)) + { + _feedData.LastUnityBlogCheck = DateTime.Now; - await _feedService.CheckUnityBlogAsync(_feedData); + await _feedService.CheckUnityBlogAsync(_feedData); + } } } + catch (Exception e) + { + await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile,$"{ServiceName}: Failed to update RSS feeds, attempting to continue.", ExtendedLogSeverity.Error); + } await Task.Delay(TimeSpan.FromSeconds(30d), _token); } @@ -270,7 +280,7 @@ private async Task UpdateRssFeeds() } catch { - LoggingService.LogToConsole($"Wikipedia method failed loading URL: {wikiSearchUri}", LogSeverity.Warning); + await _loggingService.LogChannelAndFile($"{ServiceName}: Wikipedia method failed loading URL: {wikiSearchUri}", ExtendedLogSeverity.Warning); return (null, null, null); } @@ -313,7 +323,7 @@ private async Task UpdateRssFeeds() } catch (Exception e) { - LoggingService.LogToConsole($"Wikipedia method likely failed to parse JSON response from: {wikiSearchUri}.\nEx:{e.ToString()}"); + await _loggingService.LogChannelAndFile($"{ServiceName}: Wikipedia method likely failed to parse JSON response from: {wikiSearchUri}.\nEx:{e.ToString()}", ExtendedLogSeverity.Warning); } return (null, null, null); From 37302414d7831fa5d676de566468e8b1b3bc6ceb Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 21 Jan 2024 17:49:32 +1000 Subject: [PATCH 34/66] Sanity method for mods to check if Help service is failing to keep up --- DiscordBot/Modules/UnityHelp/UnityHelpModule.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs index ece8fbc1..8d3a146d 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs @@ -1,5 +1,6 @@ using Discord.Commands; using Discord.WebSocket; +using DiscordBot.Attributes; using DiscordBot.Services; using DiscordBot.Settings; @@ -25,6 +26,20 @@ public async Task ResolveAsync() await Context.Message.DeleteAsync(); await HelpService.OnUserRequestChannelClose(Context.User, Context.Channel as SocketThreadChannel); } + + [Command("pending-questions")] + [Summary("Moderation only command, announces the number of pending questions in the help channel.")] + [RequireModerator, HideFromHelp, IgnoreBots] + public async Task PendingQuestionsAsync() + { + if (!BotSettings.UnityHelpBabySitterEnabled) + { + await ReplyAsync("UnityHelp Service currently disabled.").DeleteAfterSeconds(15); + return; + } + var pendingQuestions = HelpService.GetPendingQuestions(); + await ReplyAsync($"There are {pendingQuestions} pending questions in the help channel."); + } #region Utility From 736da8ae337e635ce8ee45d3fdf7eb723dc659cd Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 21 Jan 2024 20:22:44 +1000 Subject: [PATCH 35/66] Tracked question count --- DiscordBot/Modules/UnityHelp/UnityHelpModule.cs | 4 ++-- DiscordBot/Services/UnityHelp/UnityHelpService.cs | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs index 8d3a146d..8e64f22a 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs @@ -37,8 +37,8 @@ public async Task PendingQuestionsAsync() await ReplyAsync("UnityHelp Service currently disabled.").DeleteAfterSeconds(15); return; } - var pendingQuestions = HelpService.GetPendingQuestions(); - await ReplyAsync($"There are {pendingQuestions} pending questions in the help channel."); + var trackedQuestionCount = HelpService.GetTrackedQuestionCount(); + await ReplyAsync($"There are {trackedQuestionCount} pending questions in the help channel."); } #region Utility diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 7a4ccfc8..41fc9dfa 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -739,6 +739,11 @@ private bool IsValidAuthorUser(SocketGuildUser user, ulong authorId) return false; } + + public int GetTrackedQuestionCount() + { + return _activeThreads.Count; + } #endregion // Utility Methods From 66c882e504634eecc34970e9664f1086cd3a75d6 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 21 Jan 2024 20:27:18 +1000 Subject: [PATCH 36/66] Simplify Settings significantly by removing the excessive Channel types --- DiscordBot/Services/ReminderService.cs | 2 +- DiscordBot/Settings/Deserialized/Settings.cs | 61 ++++---------------- 2 files changed, 11 insertions(+), 52 deletions(-) diff --git a/DiscordBot/Services/ReminderService.cs b/DiscordBot/Services/ReminderService.cs index 994cb3a7..4256f1af 100644 --- a/DiscordBot/Services/ReminderService.cs +++ b/DiscordBot/Services/ReminderService.cs @@ -27,7 +27,7 @@ public class ReminderService private readonly ILoggingService _loggingService; private List _reminders = new List(); - private readonly BotCommandsChannel _botCommandsChannel; + private readonly ChannelInfo _botCommandsChannel; private bool _hasChangedSinceLastSave = false; private const int _maxUserReminders = 10; diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 8fae9f1b..3f9390c3 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -54,19 +54,19 @@ public class BotSettings #region Channels - public GeneralChannel GeneralChannel { get; set; } - public GenericHelpChannel GenericHelpChannel { get; set; } + public ChannelInfo GeneralChannel { get; set; } + public ChannelInfo GenericHelpChannel { get; set; } - public BotAnnouncementChannel BotAnnouncementChannel { get; set; } - public AnnouncementsChannel AnnouncementsChannel { get; set; } - public BotCommandsChannel BotCommandsChannel { get; set; } - public UnityNewsChannel UnityNewsChannel { get; set; } - public UnityReleasesChannel UnityReleasesChannel { get; set; } - public RulesChannel RulesChannel { get; set; } + public ChannelInfo BotAnnouncementChannel { get; set; } + public ChannelInfo AnnouncementsChannel { get; set; } + public ChannelInfo BotCommandsChannel { get; set; } + public ChannelInfo UnityNewsChannel { get; set; } + public ChannelInfo UnityReleasesChannel { get; set; } + public ChannelInfo RulesChannel { get; set; } // Recruitment Channels - public RecruitmentChannel RecruitmentChannel { get; set; } + public ChannelInfo RecruitmentChannel { get; set; } public ChannelInfo ReportedMessageChannel { get; set; } @@ -90,7 +90,7 @@ public class BotSettings #region User Roles - public UserAssignableRoles UserAssignableRoles { get; set; } + public RoleGroup UserAssignableRoles { get; set; } public ulong MutedRoleId { get; set; } public ulong SubsReleasesRoleId { get; set; } public ulong SubsNewsRoleId { get; set; } @@ -145,10 +145,6 @@ public class RoleGroup public List Roles { get; set; } } -public class UserAssignableRoles : RoleGroup -{ -} - #endregion #region Channel Information @@ -191,41 +187,4 @@ public string GenerateFirstMessage(IUser author) } } -public class GeneralChannel : ChannelInfo -{ -} - -public class GenericHelpChannel : ChannelInfo -{ - -} - -public class BotAnnouncementChannel : ChannelInfo -{ -} - -public class AnnouncementsChannel : ChannelInfo -{ -} - -public class BotCommandsChannel : ChannelInfo -{ -} - -public class UnityNewsChannel : ChannelInfo -{ -} - -public class UnityReleasesChannel : ChannelInfo -{ -} - -public class RecruitmentChannel : ChannelInfo -{ -} - -public class RulesChannel : ChannelInfo -{ -} - #endregion \ No newline at end of file From 1833e9ea22777b4eecbc693c53b06f1efc8ea6ff Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 21 Jan 2024 21:17:56 +1000 Subject: [PATCH 37/66] Add a basic IntroductionWatcher, just deletes messages if same user posts twice Not a huge improvement, but we do seem to have a couple people who post once a day, or once a week. Introductions is a pretty slow channel so this won't really add any additional cost of running. Maybe slightly more memory, but we only hold user id of past 1000 messages, which is tiny. --- DiscordBot/Extensions/UserExtensions.cs | 8 +++ DiscordBot/Program.cs | 4 ++ .../Moderation/IntroductionWatcherService.cs | 64 +++++++++++++++++++ DiscordBot/Settings/Deserialized/Settings.cs | 4 +- DiscordBot/Settings/Settings.example.json | 4 ++ 5 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 DiscordBot/Services/Moderation/IntroductionWatcherService.cs diff --git a/DiscordBot/Extensions/UserExtensions.cs b/DiscordBot/Extensions/UserExtensions.cs index e4ec119d..38ebc737 100644 --- a/DiscordBot/Extensions/UserExtensions.cs +++ b/DiscordBot/Extensions/UserExtensions.cs @@ -37,4 +37,12 @@ public static string GetPreferredAndUsername(this IUser user) return guildUser.DisplayName; return $"{guildUser.DisplayName} (aka {user.Username})"; } + + public static string GetUserLoggingString(this IUser user) + { + var guildUser = user as SocketGuildUser; + if (guildUser == null) + return $"{user.Username} `{user.Id}`"; + return $"{guildUser.GetPreferredAndUsername()} `{guildUser.Id}`"; + } } \ No newline at end of file diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 0296ab9a..9884e18e 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Discord.Commands; using Discord.Interactions; using Discord.WebSocket; @@ -70,6 +71,8 @@ private async Task MainAsync() _unityHelpService = _services.GetRequiredService(); _recruitService = _services.GetRequiredService(); + _services.GetRequiredService(); + return Task.CompletedTask; }; @@ -88,6 +91,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/DiscordBot/Services/Moderation/IntroductionWatcherService.cs b/DiscordBot/Services/Moderation/IntroductionWatcherService.cs new file mode 100644 index 00000000..58b98387 --- /dev/null +++ b/DiscordBot/Services/Moderation/IntroductionWatcherService.cs @@ -0,0 +1,64 @@ +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services; + +// Small service to watch users posting new messages in introductions, keeping track of the last 500 messages and deleting any from the same user +public class IntroductionWatcherService +{ + private const string ServiceName = "IntroductionWatcherService"; + + private readonly DiscordSocketClient _client; + private readonly ILoggingService _loggingService; + private readonly SocketChannel _introductionChannel; + + private readonly HashSet _uniqueUsers = new HashSet(MaxMessagesToTrack + 1); + private readonly Queue _orderedUsers = new Queue(MaxMessagesToTrack + 1); + + private const int MaxMessagesToTrack = 1000; + + public IntroductionWatcherService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) + { + _client = client; + _loggingService = loggingService; + + if (!settings.IntroductionWatcherServiceEnabled) + { + LoggingService.LogServiceDisabled(ServiceName, nameof(settings.IntroductionWatcherServiceEnabled)); + return; + } + + _introductionChannel = client.GetChannel(settings.IntroductionChannel.Id); + if (_introductionChannel == null) + { + _loggingService.LogAction($"[{ServiceName}] Error: Could not find introduction channel.", ExtendedLogSeverity.Warning); + return; + } + + _client.MessageReceived += MessageReceived; + } + + private async Task MessageReceived(SocketMessage message) + { + // We only watch the introduction channel + if (_introductionChannel == null || message.Channel.Id != _introductionChannel.Id) + return; + + if (_uniqueUsers.Contains(message.Author.Id)) + { + await message.DeleteAsync(); + await _loggingService.LogChannelAndFile( + $"[{ServiceName}]: Duplicate introduction from {message.Author.GetUserLoggingString()} [Message deleted]"); + } + + _uniqueUsers.Add(message.Author.Id); + _orderedUsers.Enqueue(message.Author.Id); + if (_orderedUsers.Count > MaxMessagesToTrack) + { + var oldestUser = _orderedUsers.Dequeue(); + _uniqueUsers.Remove(oldestUser); + } + + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 3f9390c3..e25a1832 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -31,10 +31,9 @@ public class BotSettings // Used for enabling/disabling services in the bot public bool RecruitmentServiceEnabled { get; set; } = false; - public bool UnityHelpBabySitterEnabled { get; set; } = false; - public bool ReactRoleServiceEnabled { get; set; } = false; + public bool IntroductionWatcherServiceEnabled { get; set; } = false; #endregion // Service Enabling @@ -54,6 +53,7 @@ public class BotSettings #region Channels + public ChannelInfo IntroductionChannel { get; set; } public ChannelInfo GeneralChannel { get; set; } public ChannelInfo GenericHelpChannel { get; set; } diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index cb1b355f..6d5da3ec 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -40,6 +40,10 @@ "desc": "General-Chat Channel", "id": "0" }, + "IntroductionChannel": { // Introductions + "desc": "Introductions Channel", + "id": "0" + }, "botAnnouncementChannel": { // Most bot logs will go here "desc": "Bot-Announcement Channel", "id": "0" From 721e3b41534daf787e5044b6cd218b4d899ca32e Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sun, 21 Jan 2024 22:32:20 +1000 Subject: [PATCH 38/66] Fix issue where welcome would be spammed while user typed --- DiscordBot/Services/UserService.cs | 35 ++++++++++++++++++------------ 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index b4bee628..86da65db 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -622,7 +622,11 @@ private async Task UserIsTyping(Cacheable user, Cacheable u.id == user.Id)) + { + _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); + await ProcessWelcomeUser(user.Id, user.Value); + } } private async Task CheckForWelcomeMessage(SocketMessage messageParam) @@ -633,7 +637,12 @@ private async Task CheckForWelcomeMessage(SocketMessage messageParam) var user = messageParam.Author; if (user.IsBot) return; - await ProcessWelcomeUser(user.Id, user); + + if (_welcomeNoticeUsers.Exists(u => u.id == user.Id)) + { + _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); + await ProcessWelcomeUser(user.Id, user); + } } private async Task UserJoined(SocketGuildUser user) @@ -733,20 +742,18 @@ private async Task DelayedWelcomeService() private async Task ProcessWelcomeUser(ulong userID, IUser user = null) { if (_welcomeNoticeUsers.Exists(u => u.id == userID)) - { // If we didn't get the user passed in, we try grab it user ??= await _client.GetUserAsync(userID); - // if they're null, they've likely left, so we just remove them from the list. - if (user == null) - return; - - var offTopic = await _client.GetChannelAsync(_settings.GeneralChannel.Id) as SocketTextChannel; - if (user is not SocketGuildUser guildUser) - return; - var em = WelcomeMessage(guildUser); - if (offTopic != null && em != null) - await offTopic.SendMessageAsync(string.Empty, false, em); - } + // if they're null, they've likely left, so we just remove them from the list. + if (user == null) + return; + + var offTopic = await _client.GetChannelAsync(_settings.GeneralChannel.Id) as SocketTextChannel; + if (user is not SocketGuildUser guildUser) + return; + var em = WelcomeMessage(guildUser); + if (offTopic != null && em != null) + await offTopic.SendMessageAsync(string.Empty, false, em); } From ac6febf97fc29783360d69d58c9a6310e4c3a564 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Tue, 23 Jan 2024 08:45:35 +1000 Subject: [PATCH 39/66] Prevent thread creation happening when bot joins thread late --- DiscordBot/Services/UnityHelp/UnityHelpService.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 41fc9dfa..097d139f 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -214,9 +214,12 @@ private Task GatewayOnThreadCreated(SocketThreadChannel thread) return Task.CompletedTask; if (thread.Owner.HasRoleGroup(ModeratorRole)) return Task.CompletedTask; - // Gateway is called twice for forums, not sure why + // Gateway is called twice for forums/threads (When Bot joins channel) if (_activeThreads.ContainsKey(thread.Id)) return Task.CompletedTask; + // Ignore new thread if age is over, 5 mins? + if (thread.CreatedAt < DateTime.Now.AddMinutes(-5)) + return Task.CompletedTask; LoggingService.DebugLog($"[{ServiceName}] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); Task.Run(() => OnThreadCreated(thread)); From 0caff2fdfac8aef19232da4a00b2669b9a4b01ed Mon Sep 17 00:00:00 2001 From: James Kellie Date: Tue, 23 Jan 2024 08:45:48 +1000 Subject: [PATCH 40/66] More friendly request to add tags --- DiscordBot/Services/UnityHelp/UnityHelpService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 097d139f..890082e6 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -49,7 +49,7 @@ public class UnityHelpService private readonly Embed _noAppliedTagsEmbed = new EmbedBuilder() .WithTitle("Warning: No Tags Applied") - .WithDescription($"Apply tags to your thread to help others find it!\n" + + .WithDescription($"Consider adding tags to your question to help others find it!\n" + $"Right click on the thread title and select 'Edit Tags'!\n") .WithFooter($"Relevant tags help experienced users find you!") .WithColor(Color.LightOrange) From 4698ccfb1f519d564c421f7a9b2ea324104ead5b Mon Sep 17 00:00:00 2001 From: James Kellie Date: Tue, 23 Jan 2024 08:48:00 +1000 Subject: [PATCH 41/66] Bump numbers up a little to avoid spamming users --- DiscordBot/Services/UnityHelp/UnityHelpService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 890082e6..7821ee21 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -31,13 +31,13 @@ public class UnityHelpService .WithColor(Color.Green) .Build(); private static readonly Emoji CloseEmoji = new Emoji(":lock:"); - private const int HasResponseIdleTimeSelfUser = 60 * 8; + private const int HasResponseIdleTimeSelfUser = 60 * 14; private static readonly string HasResponseIdleTimeSelfUserMessage = $"Hello {{0}}! This forum has been inactive for {HasResponseIdleTimeSelfUser / 60} hours. If the question has been appropriately answered, click the {CloseEmoji} emoji to close this thread."; - private const int HasResponseIdleTimeOtherUser = 60 * 12; + private const int HasResponseIdleTimeOtherUser = 60 * 20; private static readonly string HasResponseMessageRequestClose = $"Hello {{0}}! This forum has been inactive for {HasResponseIdleTimeOtherUser / 60} hours without your input. If the question has been appropriately answered, click the {CloseEmoji} emoji to close this thread."; private const string HasResponseExtraMessage = $"If you still need help, perhaps include additional details!"; - private const int NoResponseNotResolvedIdleTime = 60 * 24 * 2; + private const int NoResponseNotResolvedIdleTime = 60 * 24 * 3; private readonly Embed _stealthDeleteEmbed = new EmbedBuilder() .WithTitle("Warning: No Activity") .WithDescription($"This question has been idle for {NoResponseNotResolvedIdleTime / 60} hours and has no response.\n" + From c0d8c78eb30fff7ea25a334b4f80209b0de9e5e3 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 24 Jan 2024 20:20:47 +1000 Subject: [PATCH 42/66] Better report edited message (no empty message, or "edit" for posting a gif or emote) --- DiscordBot/Services/ModerationService.cs | 28 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index 97cd5105..3e1545d6 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -66,6 +66,12 @@ private async Task MessageUpdated(Cacheable before, SocketMessa else content = beforeMessage.Content; + // Check the message aren't the same + if (content == after.Content) + return; + if (content.Length == 0 && beforeMessage.Attachments.Count == 0) + return; + bool isTruncated = false; if (content.Length > MaxMessageLength) { @@ -80,12 +86,26 @@ private async Task MessageUpdated(Cacheable before, SocketMessa .FooterInChannel(after.Channel) .AddAuthorWithAction(user, "Updated a message", true); if (isCached) + { builder.AddField($"Previous message content {(isTruncated ? "(truncated)" : "")}", content); + // if any attachments that after does not, add a link to them and a count + if (beforeMessage.Attachments.Count > 0) + { + var attachments = beforeMessage.Attachments.Where(x => after.Attachments.All(y => y.Url != x.Url)); + var removedAttachments = attachments.ToList(); + if (removedAttachments.Any()) + { + var attachmentString = string.Join("\n", removedAttachments.Select(x => $"[{x.Filename}]({x.Url})")); + builder.AddField($"Previous attachments ({removedAttachments.Count()})", attachmentString); + } + } + } + builder.WithDescription($"Message: [{after.Id}]({after.GetJumpUrl()})"); - var embed = builder.Build(); - + var embed = builder.Build(); + // TimeStamp for the Footer - - await _loggingService.Log(LogBehaviour.Channel,string.Empty, ExtendedLogSeverity.Info, embed); + + await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); } } \ No newline at end of file From fde7128933a9c2c6e1eea8d71a84c18648a68552 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 24 Jan 2024 20:34:42 +1000 Subject: [PATCH 43/66] Update to use lock unicode This used to work, I'm unsure why using :lock: is incorrect now. --- DiscordBot/Services/UnityHelp/UnityHelpService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 7821ee21..62c77b6b 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -30,7 +30,7 @@ public class UnityHelpService .WithFooter($"Remember to Right click a message and select 'Apps->Correct Answer'") .WithColor(Color.Green) .Build(); - private static readonly Emoji CloseEmoji = new Emoji(":lock:"); + private static readonly Emoji CloseEmoji = new Emoji("\ud83d\udd12"); private const int HasResponseIdleTimeSelfUser = 60 * 14; private static readonly string HasResponseIdleTimeSelfUserMessage = $"Hello {{0}}! This forum has been inactive for {HasResponseIdleTimeSelfUser / 60} hours. If the question has been appropriately answered, click the {CloseEmoji} emoji to close this thread."; private const int HasResponseIdleTimeOtherUser = 60 * 20; From f38e55ef3a54e36658da43b22dd9f5b614c501c2 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 8 Feb 2024 18:51:24 +1000 Subject: [PATCH 44/66] Improve Logs, create backups once reach 2mb instead of tens of MB log file sizes --- DiscordBot/Services/LoggingService.cs | 49 +++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index eda538b9..53c31fed 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -68,11 +68,31 @@ public class LoggingService : ILoggingService private readonly BotSettings _settings; private readonly ISocketMessageChannel _logChannel; + + // Configuration + private readonly long MAX_LOG_SIZE = 1024 * 1024 * 2; // 2MB + private readonly long FILE_CHECK_INTERVAL = 1000 * 60 * 60 * 1; // 1 Hour + private readonly string BACKUP_LOG_FILE_PATH; + private readonly string LOG_FILE_PATH; + private readonly string LOG_XP_FILE_PATH; + + private DateTime _lastFileCheck; public LoggingService(DiscordSocketClient client, BotSettings settings) { _settings = settings; + // Paths + BACKUP_LOG_FILE_PATH = _settings.ServerRootPath + @"/log_backups/"; + LOG_FILE_PATH = _settings.ServerRootPath + @"/log.txt"; + LOG_XP_FILE_PATH = _settings.ServerRootPath + @"/logXP.txt"; + + if (!Directory.Exists(BACKUP_LOG_FILE_PATH)) + { + Directory.CreateDirectory(BACKUP_LOG_FILE_PATH); + LogToConsole($"[{ServiceName}] Created backup log directory", ExtendedLogSeverity.Info); + } + // INIT if (_settings.BotAnnouncementChannel == null) { @@ -105,13 +125,15 @@ public async Task LogToChannel(string message, ExtendedLogSeverity severity = Ex public async Task LogToFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) { - await File.AppendAllTextAsync(_settings.ServerRootPath + @"/log.txt", + PrepareLogFile(LOG_FILE_PATH); + await File.AppendAllTextAsync(LOG_FILE_PATH, $"[{ConsistentDateTimeFormat()}] - [{severity}] - {message} {Environment.NewLine}"); } public void LogXp(string channel, string user, float baseXp, float bonusXp, float xpReduce, int totalXp) { - File.AppendAllText(_settings.ServerRootPath + @"/logXP.txt", + PrepareLogFile(LOG_XP_FILE_PATH); + File.AppendAllText(LOG_XP_FILE_PATH, $"[{ConsistentDateTimeFormat()}] - {user} gained {totalXp}xp (base: {baseXp}, bonus : {bonusXp}, reduce : {xpReduce}) in channel {channel} {Environment.NewLine}"); } @@ -127,6 +149,29 @@ public static Task DiscordNetLogger(LogMessage message) LogToConsole($"{message.Source} | {message.Message}", message.Severity.ToExtended()); return Task.CompletedTask; } + + private void PrepareLogFile(string path) + { + if (DateTime.Now - _lastFileCheck < TimeSpan.FromMilliseconds(FILE_CHECK_INTERVAL)) + return; + + _lastFileCheck = DateTime.Now; + if (new FileInfo(path).Length > MAX_LOG_SIZE) + { + // Rename the file, add the year, month and day it was created, and the year month and day it was backed up (SHORT year + var backupPath = $"{BACKUP_LOG_FILE_PATH}log_F{File.GetCreationTime(path):yyMMdd}_T{DateTime.Now:yyMMdd}.txt"; + File.Move(path, backupPath); + LogToConsole($"[{ServiceName}] Log file was backed up to {backupPath}", ExtendedLogSeverity.Info); + } + + if (!File.Exists(path)) + { + File.Create(path).Dispose(); + File.AppendAllText(path, $"[{ConsistentDateTimeFormat()}] - Log file was started. {Environment.NewLine}"); + LogToConsole($"[{ServiceName}] Log file was started", ExtendedLogSeverity.Info); + } + } + #region Console Messages // Logs message to console without changing the colour public static void LogConsole(string message) { From fd3d9b2e36a3e4f2774441c924a25ab37994d1cb Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 8 Feb 2024 20:46:56 +1000 Subject: [PATCH 45/66] Hide mod/admin commands from !help even if no 'hide' attribute included --- DiscordBot/Services/CommandHandlingService.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index 8b0264aa..15cc234d 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -164,13 +164,26 @@ private string GetArguments(bool getArgs, IReadOnlyList arguments } private IEnumerable GetOrganizedCommandInfo( - (string moduleName, bool orderByName, bool includeArgs, bool includeModuleName) input, string search = "") + (string moduleName, bool orderByName, bool includeArgs, bool includeModuleName) input, string search = "", bool onlyNormalUsers = true) { + // Prepare attributes before linq + var hideFromHelp = new HideFromHelpAttribute(); + var requireModerator = new RequireModeratorAttribute(); + var requireAdmin = new RequireAdminAttribute(); + // Generates a list of commands that doesn't include any that have the ``HideFromHelp`` attribute. // Adds commands that use the same Module, and contains the search query if given. - var commands = _commandService.Commands.Where(x => - x.Module.Name == input.moduleName && !x.Attributes.Contains(new HideFromHelpAttribute()) && - (search == string.Empty || x.Name.Contains(search, StringComparison.CurrentCultureIgnoreCase))); + var commands = + _commandService.Commands.Where(x => + x.Module.Name == input.moduleName && + !x.Attributes.Contains(hideFromHelp) && + (search == string.Empty || x.Name.Contains(search, StringComparison.CurrentCultureIgnoreCase)) + ); + // We try to hide commands that have moderator or admin requirements if onlyNormalUsers is true. + commands = onlyNormalUsers + ? commands.Where(x => !x.Preconditions.Any(y => y.TypeId == requireModerator.TypeId || y.TypeId == requireAdmin.TypeId)) + : commands; + // Orders the list either by name or by priority, if no priority is given we push it to the end. commands = input.orderByName ? commands.OrderBy(c => c.Name) From 9840838c6b2415663065fb50a25aa0d0e7ce240f Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 8 Feb 2024 20:47:22 +1000 Subject: [PATCH 46/66] Handful of basic canned responses that users can use --- .../Modules/UnityHelp/CannedResponseModule.cs | 98 +++++++++++++++ .../UnityHelp/CannedResponseService.cs | 114 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 DiscordBot/Modules/UnityHelp/CannedResponseModule.cs create mode 100644 DiscordBot/Services/UnityHelp/CannedResponseService.cs diff --git a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs new file mode 100644 index 00000000..893f9018 --- /dev/null +++ b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs @@ -0,0 +1,98 @@ +using Discord.Commands; +using DiscordBot.Service; +using DiscordBot.Services; +using DiscordBot.Settings; +using static DiscordBot.Service.CannedResponseService; + +namespace DiscordBot.Modules; + +public class CannedResponseModule : ModuleBase +{ + #region Dependency Injection + + public UserService UserService { get; set; } + public BotSettings BotSettings { get; set; } + public CannedResponseService CannedResponseService { get; set; } + + #endregion // Dependency Injection + + [Command("ask"), Alias("dontasktoask", "nohello")] + [Summary("When someone asks to ask a question, respond with a link to the 'How to Ask' page.")] + public async Task RespondWithHowToAsk() + { + if (Context.User.IsUserBotOrWebhook()) + return; + + var embed = CannedResponseService.GetCannedResponse(CannedResponseType.HowToAsk, Context.User); + await Context.Message.DeleteAsync(); + + await ReplyAsync(string.Empty, false, embed.Build()); + } + + [Command("paste")] + [Summary("When someone asks how to paste code, respond with a link to the 'How to Paste Code' page.")] + public async Task RespondWithHowToPaste() + { + if (Context.User.IsUserBotOrWebhook()) + return; + + var embed = CannedResponseService.GetCannedResponse(CannedResponseType.Paste, Context.User); + await Context.Message.DeleteAsync(); + + await ReplyAsync(string.Empty, false, embed.Build()); + } + + [Command("nocode")] + [Summary("When someone asks for help with code, but doesn't provide any, respond with a link to the 'No Code Provided' page.")] + public async Task RespondWithNoCode() + { + if (Context.User.IsUserBotOrWebhook()) + return; + + var embed = CannedResponseService.GetCannedResponse(CannedResponseType.NoCode, Context.User); + await Context.Message.DeleteAsync(); + + await ReplyAsync(string.Empty, false, embed.Build()); + } + + [Command("xy")] + [Summary("When someone is asking about their attempted solution rather than their actual problem, respond with a link to the 'XY Problem' page.")] + public async Task RespondWithXYProblem() + { + if (Context.User.IsUserBotOrWebhook()) + return; + + var embed = CannedResponseService.GetCannedResponse(CannedResponseType.XYProblem, Context.User); + await Context.Message.DeleteAsync(); + + await ReplyAsync(string.Empty, false, embed.Build()); + } + + [Command("biggame"), Alias("scope", "bigscope", "scopecreep")] + [Summary("When someone is asking for help with a large project, respond with a link to the 'Game Too Big' page.")] + public async Task RespondWithGameToBig() + { + if (Context.User.IsUserBotOrWebhook()) + return; + + var embed = CannedResponseService.GetCannedResponse(CannedResponseType.GameToBig, Context.User); + await Context.Message.DeleteAsync(); + + await ReplyAsync(string.Empty, false, embed.Build()); + } + + [Command("google"), Alias("search", "howtosearch")] + [Summary("When someone asks a question that could have been answered by a quick search, respond with a link to the 'How to Google' page.")] + public async Task RespondWithHowToGoogle() + { + if (Context.User.IsUserBotOrWebhook()) + return; + + var embed = CannedResponseService.GetCannedResponse(CannedResponseType.HowToGoogle, Context.User); + await Context.Message.DeleteAsync(); + + await ReplyAsync(string.Empty, false, embed.Build()); + } + + +} \ No newline at end of file diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/UnityHelp/CannedResponseService.cs new file mode 100644 index 00000000..c1bc55b9 --- /dev/null +++ b/DiscordBot/Services/UnityHelp/CannedResponseService.cs @@ -0,0 +1,114 @@ +namespace DiscordBot.Service; + +public class CannedResponseService +{ + private const string ServiceName = "CannedResponseService"; + + #region Configuration + + public enum CannedResponseType + { + HowToAsk, + Paste, + NoCode, + XYProblem, + // Passive Aggressive + GameToBig, + HowToGoogle, + } + + private readonly Color _defaultEmbedColor = new Color(0x00, 0x80, 0xFF); + + private readonly EmbedBuilder _howToAskEmbed = new EmbedBuilder + { + Title = "How to Ask", + Description = "When you have a question, just ask it directly and wait patiently for an answer. " + + "Providing more information upfront can improve the quality and speed of the responses you receive. " + + "There’s no need to ask for permission to ask or to check if someone is present before asking.\n" + + "See: [How to Ask](https://stackoverflow.com/help/how-to-ask)", + Url = "https://stackoverflow.com/help/how-to-ask", + }; + + private readonly EmbedBuilder _pasteEmbed = new EmbedBuilder + { + Title = "How to Paste Code", + // A unity based example + Description = "When sharing code on Discord, it’s best to use code blocks. You can create a code block by wrapping your code in backticks (\\`\\`\\`). For example:\n" + + "```csharp\n" + + "public void Start()\n" + + "{\n" + + " Debug.Log(\"Hello, world!\");\n" + + " GameObject cube = GameObject.Instantiate(prefab);\n" + + " // Set the position of the cube to the origin\n" + + " cube.transform.position = new Vector3(0, 0, 0);\n" + + "}\n" + + "```\n" + + "This will make your code easier to read and copy. If your code is too long, consider using a service like [GitHub Gist](https://gist.github.com/) or [Pastebin](https://pastebin.com/).", + Url = "https://pastebin.com/", + }; + + private readonly EmbedBuilder _noCodeEmbed = new EmbedBuilder + { + Title = "No Code Provided", + Description = "***Where the code at?*** Your question is code focused, but you haven't provided much if any of the code involved." + + "Someone who wants to help you won't be able to do so without seeing the code you're working with." + }; + + private readonly EmbedBuilder _xyProblemEmbed = new EmbedBuilder + { + Title = "XY Problem", + Description = "Don't ask about your attempted solution, ask about the actual problem.\n" + + "This leads to a lot of wasted time and energy, both on the part of people asking for help, and on the part of those providing help.\n" + + "See: [XY Problem](https://xyproblem.info/)\n" + + "- Always include information about the broader problem.\n" + + "- If you've tried something, tell us what you tried", + Url = "https://xyproblem.info/", + }; + + private readonly EmbedBuilder _gameToBigEmbed = new EmbedBuilder + { + Title = "Game Too Big", + Description = "Managing project scope is important. It's important to start small and build up from there. " + + "If you're new to game development, it's best to start with a small project to learn the basics. " + + "Once you have a good understanding of the basics, you can start working on larger projects.\n" + + "See: [Project Scope](https://clintbellanger.net/toobig/advice.html)", + }; + + private readonly EmbedBuilder _howToGoogleEmbed = new EmbedBuilder + { + Title = "How to Google", + Description = "Someone thinks this question could be answered by a quick search!\n" + + "Quick searches often answer questions.\nAs developers, self-reliance in finding answers saves time.\n" + + "See: [How to Google](https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html)", + Url = "https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html", + }; + + #endregion // Configuration + + public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor) + { + var embed = GetUnbuiltCannedResponse(type); + if (embed == null) + return null; + + embed.FooterRequestedBy(requestor); + embed.WithColor(_defaultEmbedColor); + + return embed; + } + + public EmbedBuilder GetUnbuiltCannedResponse(CannedResponseType type) + { + return type switch + { + CannedResponseType.HowToAsk => _howToAskEmbed, + CannedResponseType.Paste => _pasteEmbed, + CannedResponseType.NoCode => _noCodeEmbed, + CannedResponseType.XYProblem => _xyProblemEmbed, + // Passive Aggressive + CannedResponseType.GameToBig => _gameToBigEmbed, + CannedResponseType.HowToGoogle => _howToGoogleEmbed, + _ => null + }; + } +} \ No newline at end of file From 8364383d4c693ac407957bc114bdc9ce3db3c487 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 8 Feb 2024 21:07:26 +1000 Subject: [PATCH 47/66] Log new user error to channel as well since it is more common than expected --- DiscordBot/Services/DatabaseService.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index f87aaa07..95670a13 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -179,8 +179,7 @@ await _logging.Log(LogBehaviour.File, } catch (Exception e) { - // We don't print to channel as this could be spammy (Albeit rare) - await _logging.Log(LogBehaviour.Console | LogBehaviour.File, + await _logging.Log(LogBehaviour.ConsoleChannelAndFile, $"Error when trying to add user {socketUser.Id.ToString()} to the database : {e}", ExtendedLogSeverity.Warning); return null; } From f9ecbf88dd1aa0e2906c5d77fa672b8dcbfe6496 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 8 Feb 2024 21:07:39 +1000 Subject: [PATCH 48/66] DefaultCity as empty string --- DiscordBot/Extensions/UserDBRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DiscordBot/Extensions/UserDBRepository.cs b/DiscordBot/Extensions/UserDBRepository.cs index 94f49b5f..3824347b 100644 --- a/DiscordBot/Extensions/UserDBRepository.cs +++ b/DiscordBot/Extensions/UserDBRepository.cs @@ -14,7 +14,7 @@ public class ServerUser public ulong Exp { get; set; } public uint Level { get; set; } // DefaultCity - Optional Location for Weather, BDay, Temp, Time, etc. (Added - Jan 2024) - public string DefaultCity { get; set; } + public string DefaultCity { get; set; } = string.Empty; } /// From 5bd50544bd1ad3f8be1d92d27194a97348e4eacf Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 8 Feb 2024 22:01:38 +1000 Subject: [PATCH 49/66] Update IgnoreBotsAttribute.cs --- DiscordBot/Attributes/IgnoreBotsAttribute.cs | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/DiscordBot/Attributes/IgnoreBotsAttribute.cs b/DiscordBot/Attributes/IgnoreBotsAttribute.cs index 0c5b1c3c..396983d0 100644 --- a/DiscordBot/Attributes/IgnoreBotsAttribute.cs +++ b/DiscordBot/Attributes/IgnoreBotsAttribute.cs @@ -15,6 +15,34 @@ public override Task CheckPermissionsAsync(ICommandContext c return Task.FromResult(PreconditionResult.FromError(string.Empty)); } + return Task.FromResult(PreconditionResult.FromSuccess()); + } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class IgnoreBotsAndWebhooksAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + if (context.Message.Author.IsBot || context.Message.Author.IsWebhook) + { + return Task.FromResult(PreconditionResult.FromError(string.Empty)); + } + + return Task.FromResult(PreconditionResult.FromSuccess()); + } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class IgnoreWebhooksAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + if (context.Message.Author.IsWebhook) + { + return Task.FromResult(PreconditionResult.FromError(string.Empty)); + } + return Task.FromResult(PreconditionResult.FromSuccess()); } } \ No newline at end of file From 39c860f7ed83532e11b0f2052b33e833b2a23ff3 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 8 Feb 2024 22:02:09 +1000 Subject: [PATCH 50/66] Add some canned resources, simplify the Module command --- .../Modules/UnityHelp/CannedResponseModule.cs | 95 +++++++++++-------- .../UnityHelp/CannedResponseService.cs | 82 +++++++++++++++- 2 files changed, 137 insertions(+), 40 deletions(-) diff --git a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs index 893f9018..9400462e 100644 --- a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs @@ -1,4 +1,5 @@ using Discord.Commands; +using DiscordBot.Attributes; using DiscordBot.Service; using DiscordBot.Services; using DiscordBot.Settings; @@ -16,83 +17,99 @@ public class CannedResponseModule : ModuleBase #endregion // Dependency Injection - [Command("ask"), Alias("dontasktoask", "nohello")] - [Summary("When someone asks to ask a question, respond with a link to the 'How to Ask' page.")] - public async Task RespondWithHowToAsk() + // The core command for the canned response module + public async Task RespondWithCannedResponse(CannedResponseType type) { if (Context.User.IsUserBotOrWebhook()) return; - var embed = CannedResponseService.GetCannedResponse(CannedResponseType.HowToAsk, Context.User); + var embed = CannedResponseService.GetCannedResponse(type, Context.User); await Context.Message.DeleteAsync(); await ReplyAsync(string.Empty, false, embed.Build()); } + [Command("ask"), Alias("dontasktoask", "nohello")] + [Summary("When someone asks to ask a question, respond with a link to the 'How to Ask' page.")] + public async Task RespondWithHowToAsk() + { + await RespondWithCannedResponse(CannedResponseType.HowToAsk); + } + [Command("paste")] [Summary("When someone asks how to paste code, respond with a link to the 'How to Paste Code' page.")] public async Task RespondWithHowToPaste() { - if (Context.User.IsUserBotOrWebhook()) - return; - - var embed = CannedResponseService.GetCannedResponse(CannedResponseType.Paste, Context.User); - await Context.Message.DeleteAsync(); - - await ReplyAsync(string.Empty, false, embed.Build()); + await RespondWithCannedResponse(CannedResponseType.Paste); } [Command("nocode")] [Summary("When someone asks for help with code, but doesn't provide any, respond with a link to the 'No Code Provided' page.")] public async Task RespondWithNoCode() { - if (Context.User.IsUserBotOrWebhook()) - return; - - var embed = CannedResponseService.GetCannedResponse(CannedResponseType.NoCode, Context.User); - await Context.Message.DeleteAsync(); - - await ReplyAsync(string.Empty, false, embed.Build()); + await RespondWithCannedResponse(CannedResponseType.NoCode); } [Command("xy")] [Summary("When someone is asking about their attempted solution rather than their actual problem, respond with a link to the 'XY Problem' page.")] public async Task RespondWithXYProblem() { - if (Context.User.IsUserBotOrWebhook()) - return; - - var embed = CannedResponseService.GetCannedResponse(CannedResponseType.XYProblem, Context.User); - await Context.Message.DeleteAsync(); - - await ReplyAsync(string.Empty, false, embed.Build()); + await RespondWithCannedResponse(CannedResponseType.XYProblem); } [Command("biggame"), Alias("scope", "bigscope", "scopecreep")] [Summary("When someone is asking for help with a large project, respond with a link to the 'Game Too Big' page.")] public async Task RespondWithGameToBig() { - if (Context.User.IsUserBotOrWebhook()) - return; - - var embed = CannedResponseService.GetCannedResponse(CannedResponseType.GameToBig, Context.User); - await Context.Message.DeleteAsync(); - - await ReplyAsync(string.Empty, false, embed.Build()); + await RespondWithCannedResponse(CannedResponseType.GameToBig); } [Command("google"), Alias("search", "howtosearch")] [Summary("When someone asks a question that could have been answered by a quick search, respond with a link to the 'How to Google' page.")] public async Task RespondWithHowToGoogle() { - if (Context.User.IsUserBotOrWebhook()) - return; - - var embed = CannedResponseService.GetCannedResponse(CannedResponseType.HowToGoogle, Context.User); - await Context.Message.DeleteAsync(); - - await ReplyAsync(string.Empty, false, embed.Build()); + await RespondWithCannedResponse(CannedResponseType.HowToGoogle); } + [Command("programming")] + [Summary("When someone asks for programming resources, respond with a link to the 'Programming Resources' page.")] + public async Task RespondWithProgrammingResources() + { + await RespondWithCannedResponse(CannedResponseType.Programming); + } + [Command("art")] + [Summary("When someone asks for art resources, respond with a link to the 'Art Resources' page.")] + public async Task RespondWithArtResources() + { + await RespondWithCannedResponse(CannedResponseType.Art); + } + + [Command("3d"), Alias("3dmodeling", "3dassets")] + [Summary("When someone asks for 3D modeling resources, respond with a link to the '3D Modeling Resources' page.")] + public async Task RespondWith3DModelingResources() + { + await RespondWithCannedResponse(CannedResponseType.ThreeD); + } + + [Command("2d"), Alias("2dmodeling", "2dassets")] + [Summary("When someone asks for 2D modeling resources, respond with a link to the '2D Modeling Resources' page.")] + public async Task RespondWith2DModelingResources() + { + await RespondWithCannedResponse(CannedResponseType.TwoD); + } + + [Command("audio"), Alias("sound", "music")] + [Summary("When someone asks for audio resources, respond with a link to the 'Audio Resources' page.")] + public async Task RespondWithAudioResources() + { + await RespondWithCannedResponse(CannedResponseType.Audio); + } + + [Command("design"), Alias("ui", "ux")] + [Summary("When someone asks for design resources, respond with a link to the 'Design Resources' page.")] + public async Task RespondWithDesignResources() + { + await RespondWithCannedResponse(CannedResponseType.Design); + } } \ No newline at end of file diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/UnityHelp/CannedResponseService.cs index c1bc55b9..4b055da4 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/UnityHelp/CannedResponseService.cs @@ -15,6 +15,13 @@ public enum CannedResponseType // Passive Aggressive GameToBig, HowToGoogle, + // General Help + Programming, + Art, + ThreeD, + TwoD, + Audio, + Design, } private readonly Color _defaultEmbedColor = new Color(0x00, 0x80, 0xFF); @@ -73,7 +80,7 @@ public enum CannedResponseType "Once you have a good understanding of the basics, you can start working on larger projects.\n" + "See: [Project Scope](https://clintbellanger.net/toobig/advice.html)", }; - + private readonly EmbedBuilder _howToGoogleEmbed = new EmbedBuilder { Title = "How to Google", @@ -82,7 +89,73 @@ public enum CannedResponseType "See: [How to Google](https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html)", Url = "https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html", }; + + private readonly EmbedBuilder _programmingEmbed = new EmbedBuilder + { + Title = "Programming Resources", + Description = "Resources for programming, including languages, tools, and best practices.\n" + + "- Official Documentation [Manual](https://docs.unity3d.com/Manual/index.html) [Scripting API](https://docs.unity3d.com/ScriptReference/index.html)\n" + + "- Official Learn Pipeline [Unity Learn](https://learn.unity.com/)\n" + + "- Fundamentals: Unity [Roll-A-Ball](https://learn.unity.com/project/roll-a-ball)\n" + + "- Intermediate: Catlike Coding [Tutorials](https://catlikecoding.com/unity/tutorials/)\n" + + "- Best Practices: [Organizing Your Project](https://unity.com/how-to/organizing-your-project)\n" + + "- Design Patterns: [Game Programming Patterns](https://gameprogrammingpatterns.com/)", + Url = "https://learn.unity.com/project/roll-a-ball" + }; + + private readonly EmbedBuilder _artEmbed = new EmbedBuilder + { + Title = "Art Resources", + Description = "Resources for art\n" + + "- Learning Blender: [Blender Guru Donut](https://www.youtube.com/watch?v=TPrnSACiTJ4&list=PLjEaoINr3zgEq0u2MzVgAaHEBt--xLB6U&index=3)\n" + + "- Royalty Free Simple 2D/3D Assets: [Kenny](https://www.kenney.nl/assets)\n" + + "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-royalty-free)\n" + + "- Blender Discord: [Server Invite](https://discord.gg/blender)" + }; + + private readonly EmbedBuilder _threeDEmbed = new EmbedBuilder + { + Title = "3D Resources", + Description = "Resources for 3D\n" + + "- Learning Blender: [Blender Guru Donut](https://www.youtube.com/watch?v=TPrnSACiTJ4&list=PLjEaoINr3zgEq0u2MzVgAaHEBt--xLB6U&index=3)\n" + + "- Royalty Free Simple 3D Assets: [Kenny 3D](https://www.kenney.nl/assets/category:3D?sort=update)\n" + + "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-3d/tag-royalty-free)\n" + + "- Blender Discord: [Server Invite](https://discord.gg/blender)" + }; + private readonly EmbedBuilder _twoDEmbed = new EmbedBuilder + { + Title = "2D Resources", + Description = "Resources for 2D\n" + + "- Royalty Free Simple 2D Assets: [Kenny 2D](https://www.kenney.nl/assets/category:2D?sort=update)\n" + + "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-2d)\n" + + "- Blender Discord: [Server Invite](https://discord.gg/blender)" + }; + + private readonly EmbedBuilder _audioEmbed = new EmbedBuilder + { + Title = "Audio Resources", + Description = "Resources for audio\n" + + "- Music (Attribute): [Incompetech](https://incompetech.com/)\n" + + "- Effects & Music: [Freesound](https://freesound.org/)\n" + + "- Effects & Music: [Itch.io](https://itch.io/game-assets/free/tag-music)\n" + + "- Audio Editor: [Audacity](https://www.audacityteam.org/)\n" + + "- Sound Design Explained: [PitchBlends](https://www.pitchbends.com/posts/what-is-sound-design)" + }; + + private readonly EmbedBuilder _designEmbed = new EmbedBuilder + { + Title = "Design Resources", + Description = "Resources for design\n" + + "- Design Document: [David Fox](https://www.linkedin.com/pulse/free-game-design-doc-gdd-template-david-fox/)\n" + + "- Game Design: [Keep Things Clear](https://code.tutsplus.com/keep-things-clear-dont-confuse-your-players--cms-22780a)\n" + + "- Color Palettes: [Coolors](https://coolors.co/)\n" + + "- Font Pairing: [Font Pair](https://fontpair.co/)\n" + + "- Iconography: [Flaticon](https://www.flaticon.com/)\n" + + "- Free Icons: [Icon Monstr](https://iconmonstr.com/)" + }; + + #endregion // Configuration public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor) @@ -108,6 +181,13 @@ public EmbedBuilder GetUnbuiltCannedResponse(CannedResponseType type) // Passive Aggressive CannedResponseType.GameToBig => _gameToBigEmbed, CannedResponseType.HowToGoogle => _howToGoogleEmbed, + // General Help + CannedResponseType.Programming => _programmingEmbed, + CannedResponseType.Art => _artEmbed, + CannedResponseType.ThreeD => _threeDEmbed, + CannedResponseType.TwoD => _twoDEmbed, + CannedResponseType.Audio => _audioEmbed, + CannedResponseType.Design => _designEmbed, _ => null }; } From eceaefe6d97ba3111b1ccfc023c801353db0810e Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 8 Feb 2024 22:02:14 +1000 Subject: [PATCH 51/66] Update Program.cs --- DiscordBot/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 9884e18e..e52faeb9 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -2,6 +2,7 @@ using Discord.Commands; using Discord.Interactions; using Discord.WebSocket; +using DiscordBot.Service; using DiscordBot.Services; using DiscordBot.Settings; using DiscordBot.Utils; @@ -103,6 +104,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .BuildServiceProvider(); From 765520637564f5d915fdd4e679d91baa9e036b5a Mon Sep 17 00:00:00 2001 From: James Kellie Date: Fri, 9 Feb 2024 20:28:04 +1000 Subject: [PATCH 52/66] Add deltatime canned response --- .../Modules/UnityHelp/CannedResponseModule.cs | 7 ++++++ .../UnityHelp/CannedResponseService.cs | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs index 9400462e..bb8027ca 100644 --- a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs @@ -112,4 +112,11 @@ public async Task RespondWithDesignResources() { await RespondWithCannedResponse(CannedResponseType.Design); } + + [Command("delta"), Alias("deltatime", "fixedupdate")] + [Summary("When someone asks about delta time, respond with a link to the 'Delta Time' page.")] + public async Task RespondWithDeltaTime() + { + await RespondWithCannedResponse(CannedResponseType.DeltaTime); + } } \ No newline at end of file diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/UnityHelp/CannedResponseService.cs index 4b055da4..39d5cfff 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/UnityHelp/CannedResponseService.cs @@ -12,6 +12,7 @@ public enum CannedResponseType Paste, NoCode, XYProblem, + DeltaTime, // Passive Aggressive GameToBig, HowToGoogle, @@ -155,6 +156,27 @@ public enum CannedResponseType "- Free Icons: [Icon Monstr](https://iconmonstr.com/)" }; + private readonly EmbedBuilder _deltaTime = new EmbedBuilder + { + Title = "Frame Independence", + Description = "[Time.deltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html) is the time in seconds it took to complete the last frame.\n" + + "Avoid moving objects or making calculations based on constant values." + + "```cs\n" + + "var speed = 1.0f\n" + + "var dir = transform.forward;\n" + + "// Move 'speed' units forward speed Units **Per Frame**\n" + + "transform.position += dir * speed;\n" + + "// Move 'speed' units forward **Per Seconds**\n" + + "transform.position += dir * speed * Time.deltaTime;```" + + "Avoid per-frame speeds as FPS varies among players, affecting object speed. Use `deltaTime` " + + "[Update](https://docs.unity3d.com/ScriptReference/MonoBehaviour.Update.html) or " + + "`fixedDeltaTime` [FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html) for consistent speed.\n" + + "See: [Time Frame Management](https://docs.unity3d.com/Manual/TimeFrameManagement.html), " + + "[FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html), " + + "[DeltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html)", + Url = "https://docs.unity3d.com/Manual/TimeFrameManagement.html", + }; + #endregion // Configuration @@ -178,6 +200,7 @@ public EmbedBuilder GetUnbuiltCannedResponse(CannedResponseType type) CannedResponseType.Paste => _pasteEmbed, CannedResponseType.NoCode => _noCodeEmbed, CannedResponseType.XYProblem => _xyProblemEmbed, + CannedResponseType.DeltaTime => _deltaTime, // Passive Aggressive CannedResponseType.GameToBig => _gameToBigEmbed, CannedResponseType.HowToGoogle => _howToGoogleEmbed, From 624f3619631225d93585d36c7c26d12aff5a8096 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Fri, 9 Feb 2024 20:31:42 +1000 Subject: [PATCH 53/66] Update CannedResponseService.cs --- DiscordBot/Services/UnityHelp/CannedResponseService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/UnityHelp/CannedResponseService.cs index 39d5cfff..adf9d0d2 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/UnityHelp/CannedResponseService.cs @@ -164,9 +164,9 @@ public enum CannedResponseType "```cs\n" + "var speed = 1.0f\n" + "var dir = transform.forward;\n" + - "// Move 'speed' units forward speed Units **Per Frame**\n" + + "// Move 'speed' units forward **Per Frame**\n" + "transform.position += dir * speed;\n" + - "// Move 'speed' units forward **Per Seconds**\n" + + "// Move 'speed' units forward **Per Second**\n" + "transform.position += dir * speed * Time.deltaTime;```" + "Avoid per-frame speeds as FPS varies among players, affecting object speed. Use `deltaTime` " + "[Update](https://docs.unity3d.com/ScriptReference/MonoBehaviour.Update.html) or " + From 24532acd366cb13f99bb33f46c6bfe721fd72a23 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 12 Feb 2024 20:55:53 +1000 Subject: [PATCH 54/66] Slash command variant of Canned Help, 1 for `can` 1 for `resources` --- .../UnityHelp/CannedInteractiveModule.cs | 40 +++++++++++++++++++ .../UnityHelp/CannedResponseService.cs | 27 ++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs diff --git a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs new file mode 100644 index 00000000..fafcc682 --- /dev/null +++ b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs @@ -0,0 +1,40 @@ +using Discord.Interactions; +using Discord.WebSocket; +using DiscordBot.Service; +using DiscordBot.Services; +using DiscordBot.Settings; +using static DiscordBot.Service.CannedResponseService; + +namespace DiscordBot.Modules; + +public class CannedInteractiveModule : InteractionModuleBase +{ + #region Dependency Injection + + public UnityHelpService HelpService { get; set; } + public BotSettings BotSettings { get; set; } + public CannedResponseService CannedResponseService { get; set; } + + #endregion // Dependency Injection + + // Responses are any of the CannedResponseType enum + [SlashCommand("can", "Prepared responses to help answer common questions")] + public async Task CannedResponses(CannedHelp type) + { + if (Context.User.IsUserBotOrWebhook()) + return; + + var embed = CannedResponseService.GetCannedResponse((CannedResponseType)type); + await Context.Interaction.RespondAsync(string.Empty, embed: embed.Build()); + } + + [SlashCommand("resources", "Links to resources to help answer common questions")] + public async Task Resources(CannedResources type) + { + if (Context.User.IsUserBotOrWebhook()) + return; + + var embed = CannedResponseService.GetCannedResponse((CannedResponseType)type); + await Context.Interaction.RespondAsync(string.Empty, embed: embed.Build()); + } +} \ No newline at end of file diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/UnityHelp/CannedResponseService.cs index adf9d0d2..206bea40 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/UnityHelp/CannedResponseService.cs @@ -24,6 +24,28 @@ public enum CannedResponseType Audio, Design, } + + public enum CannedHelp + { + HowToAsk = CannedResponseType.HowToAsk, + CodePaste = CannedResponseType.Paste, + NoCode = CannedResponseType.NoCode, + XYProblem = CannedResponseType.XYProblem, + DeltaTime = CannedResponseType.DeltaTime, + // Passive Aggressive + GameToBig = CannedResponseType.GameToBig, + HowToGoogle = CannedResponseType.HowToGoogle, + } + + public enum CannedResources + { + Programming = CannedResponseType.Programming, + GeneralArt = CannedResponseType.Art, + Art2D = CannedResponseType.ThreeD, + Art3D = CannedResponseType.TwoD, + Audio = CannedResponseType.Audio, + Design = CannedResponseType.Design, + } private readonly Color _defaultEmbedColor = new Color(0x00, 0x80, 0xFF); @@ -180,13 +202,14 @@ public enum CannedResponseType #endregion // Configuration - public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor) + public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor = null) { var embed = GetUnbuiltCannedResponse(type); if (embed == null) return null; - embed.FooterRequestedBy(requestor); + if (requestor != null) + embed.FooterRequestedBy(requestor); embed.WithColor(_defaultEmbedColor); return embed; From 059b12980fd73f24e2124ae172c271d3524a750d Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 12 Feb 2024 20:55:58 +1000 Subject: [PATCH 55/66] Update WebUtil.cs --- DiscordBot/Utils/WebUtil.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebUtil.cs index 7381a132..b521eb86 100644 --- a/DiscordBot/Utils/WebUtil.cs +++ b/DiscordBot/Utils/WebUtil.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http; using HtmlAgilityPack; -using Newtonsoft.Json.Linq; using Newtonsoft.Json; namespace DiscordBot.Utils; From da8c2d27ea7129f951363ba64a6e8470a6a29b48 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Feb 2024 22:11:50 +1000 Subject: [PATCH 56/66] Update CannedInteractiveModule.cs --- DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs index fafcc682..fe745810 100644 --- a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs @@ -1,3 +1,4 @@ +using Discord.Commands; using Discord.Interactions; using Discord.WebSocket; using DiscordBot.Service; @@ -18,7 +19,7 @@ public class CannedInteractiveModule : InteractionModuleBase #endregion // Dependency Injection // Responses are any of the CannedResponseType enum - [SlashCommand("can", "Prepared responses to help answer common questions")] + [SlashCommand("faq", "Prepared responses to help answer common questions")] public async Task CannedResponses(CannedHelp type) { if (Context.User.IsUserBotOrWebhook()) From c0cd98ad4ec5f7887c22686ec6e8e7a9e019c7e6 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Feb 2024 12:48:48 +1000 Subject: [PATCH 57/66] Add Debugging and FolderStructure canned responses --- .../Modules/UnityHelp/CannedResponseModule.cs | 16 ++- .../UnityHelp/CannedResponseService.cs | 122 +++++++++++++----- 2 files changed, 108 insertions(+), 30 deletions(-) diff --git a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs index bb8027ca..6584e64d 100644 --- a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs @@ -61,7 +61,7 @@ public async Task RespondWithXYProblem() [Summary("When someone is asking for help with a large project, respond with a link to the 'Game Too Big' page.")] public async Task RespondWithGameToBig() { - await RespondWithCannedResponse(CannedResponseType.GameToBig); + await RespondWithCannedResponse(CannedResponseType.GameTooBig); } [Command("google"), Alias("search", "howtosearch")] @@ -71,6 +71,20 @@ public async Task RespondWithHowToGoogle() await RespondWithCannedResponse(CannedResponseType.HowToGoogle); } + [Command("debug")] + [Summary("When someone asks for help debugging, respond with a link to the 'How to Debug' page.")] + public async Task RespondWithHowToDebug() + { + await RespondWithCannedResponse(CannedResponseType.Debugging); + } + + [Command("folder"), Alias("directory", "structure")] + [Summary("When someone asks about folder structure, respond with a link to the 'Folder Structure' page.")] + public async Task RespondWithFolderStructure() + { + await RespondWithCannedResponse(CannedResponseType.FolderStructure); + } + [Command("programming")] [Summary("When someone asks for programming resources, respond with a link to the 'Programming Resources' page.")] public async Task RespondWithProgrammingResources() diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/UnityHelp/CannedResponseService.cs index 206bea40..ca826aba 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/UnityHelp/CannedResponseService.cs @@ -13,16 +13,28 @@ public enum CannedResponseType NoCode, XYProblem, DeltaTime, + // General Help + Debugging, + FolderStructure, + // VersionControl, + // ErrorMessages, + // CodeStructure, + // CodeComments, // Passive Aggressive - GameToBig, + GameTooBig, HowToGoogle, - // General Help + // Resources Programming, Art, ThreeD, TwoD, Audio, Design, + // Animation, + // Physics, + // Networking, + // PerformanceAndOptimization, + // UIUX } public enum CannedHelp @@ -32,8 +44,15 @@ public enum CannedHelp NoCode = CannedResponseType.NoCode, XYProblem = CannedResponseType.XYProblem, DeltaTime = CannedResponseType.DeltaTime, + // Mode general help + Debugging = CannedResponseType.Debugging, + FolderStructure = CannedResponseType.FolderStructure, + // VersionControl = CannedResponseType.VersionControl, + // ErrorMessages = CannedResponseType.ErrorMessages, + // CodeStructure = CannedResponseType.CodeStructure, + // CodeComments = CannedResponseType.CodeComments, // Passive Aggressive - GameToBig = CannedResponseType.GameToBig, + GameTooBig = CannedResponseType.GameTooBig, HowToGoogle = CannedResponseType.HowToGoogle, } @@ -45,10 +64,17 @@ public enum CannedResources Art3D = CannedResponseType.TwoD, Audio = CannedResponseType.Audio, Design = CannedResponseType.Design, + // Animation = CannedResponseType.Animation, + // Physics = CannedResponseType.Physics, + // Networking = CannedResponseType.Networking, + // PerformanceAndOptimization = CannedResponseType.PerformanceAndOptimization, + // UIUX = CannedResponseType.UIUX } private readonly Color _defaultEmbedColor = new Color(0x00, 0x80, 0xFF); + #region Canned Help + private readonly EmbedBuilder _howToAskEmbed = new EmbedBuilder { Title = "How to Ask", @@ -63,7 +89,7 @@ public enum CannedResources { Title = "How to Paste Code", // A unity based example - Description = "When sharing code on Discord, it’s best to use code blocks. You can create a code block by wrapping your code in backticks (\\`\\`\\`). For example:\n" + + Description = "When sharing code on Discord, it’s best to use code blocks. You can create a code block by wrapping your code in backticks (\\`\\`\\`). [Discord Markdown](https://gist.github.com/matthewzring/9f7bbfd102003963f9be7dbcf7d40e51#code-blocks)\n" + "```csharp\n" + "public void Start()\n" + "{\n" + @@ -80,14 +106,14 @@ public enum CannedResources private readonly EmbedBuilder _noCodeEmbed = new EmbedBuilder { Title = "No Code Provided", - Description = "***Where the code at?*** Your question is code focused, but you haven't provided much if any of the code involved." + + Description = "***Where the code at?*** It appears you're trying to ask something that would benefit from showing what you've tried, but you haven't provided much code. " + "Someone who wants to help you won't be able to do so without seeing the code you're working with." }; private readonly EmbedBuilder _xyProblemEmbed = new EmbedBuilder { Title = "XY Problem", - Description = "Don't ask about your attempted solution, ask about the actual problem.\n" + + Description = "Don't ask about your attempted solution, include details about the actual problem you're trying to solve.\n" + "This leads to a lot of wasted time and energy, both on the part of people asking for help, and on the part of those providing help.\n" + "See: [XY Problem](https://xyproblem.info/)\n" + "- Always include information about the broader problem.\n" + @@ -95,7 +121,7 @@ public enum CannedResources Url = "https://xyproblem.info/", }; - private readonly EmbedBuilder _gameToBigEmbed = new EmbedBuilder + private readonly EmbedBuilder _gameTooBigEmbed = new EmbedBuilder { Title = "Game Too Big", Description = "Managing project scope is important. It's important to start small and build up from there. " + @@ -112,7 +138,58 @@ public enum CannedResources "See: [How to Google](https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html)", Url = "https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html", }; + + private readonly EmbedBuilder _deltaTime = new EmbedBuilder + { + Title = "Frame Independence", + Description = "[Time.deltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html) is the time in seconds it took to complete the last frame.\n" + + "Avoid moving objects or making calculations based on constant values." + + "```cs\n" + + "var speed = 1.0f\n" + + "var dir = transform.forward;\n" + + "// Move 'speed' units forward **Per Frame**\n" + + "transform.position += dir * speed;\n" + + "// Move 'speed' units forward **Per Second**\n" + + "transform.position += dir * speed * Time.deltaTime;```" + + "Avoid per-frame adjustments as FPS varies among players, affecting object speed. Use `deltaTime` " + + "[Update](https://docs.unity3d.com/ScriptReference/MonoBehaviour.Update.html) or " + + "`fixedDeltaTime` [FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html) for consistent speed.\n" + + "See: [Time Frame Management](https://docs.unity3d.com/Manual/TimeFrameManagement.html), " + + "[FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html), " + + "[DeltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html)", + Url = "https://docs.unity3d.com/Manual/TimeFrameManagement.html", + }; + + private readonly EmbedBuilder _debugging = new EmbedBuilder + { + Title = "Debugging in Unity", + Description = "Debugging is key in game development, and usually required to fix 'bugs' in code. This can often be the most time consuming work involved with programming.\n__Here are some Unity debugging tips:__\n" + + "- **Error Messages**: Unity error messages indicate the error line and code (e.g. **CS1002**). [C# Compiler Errors](https://learn.microsoft.com/en-us/dotnet/csharp/misc/cs1002).\n" + + "- **Debug.Log**: Unity [Debug.Log](https://docs.unity3d.com/ScriptReference/Debug.Log.html) to print console messages for code tracking and variable values.\n" + + "- **Breakpoints**: Use [Breakpoints](https://docs.unity3d.com/Manual/ManagedCodeDebugging.html) to pause and step through code line by line at a specific point for game state examination.\n" + + "- **Unity Profiler**: The [Unity Profiler](https://docs.unity3d.com/Manual/Profiler.html) identifies performance bottlenecks for game optimization.\n" + + "- **Documentation**: Unity's documentation provides insights into functions, features, and error resolution.\n" + + "Debugging improves with practice, enhancing your bug identification and resolution skills.", + Url = "https://docs.unity3d.com/Manual/ManagedCodeDebugging.html", + }; + + private readonly EmbedBuilder _folderStructure = new EmbedBuilder + { + Title = "Folder Structure", + Description = "Organizing your project is important for maintainability and collaboration (Including yourself weeks later). + " + + "well-organized project is easier to navigate and understand, and makes it easier to find and fix problems.\n" + + "- **Consistency**: Keep it consistent, and make sure everyone on your team knows the structure.\n" + + "- **Separation**: Keep your assets separate from your code.\n" + + "- **Naming**: Use clear and consistent naming conventions.\n" + + "- **Documentation**: Keep a README file, either in the root of project, or in specific folders when additional information would be useful.\n" + + "See: [Organizing Your Project](https://unity.com/how-to/organizing-your-project)", + Url = "https://unity.com/how-to/organizing-your-project", + }; + + #endregion + #region Canned Resources + private readonly EmbedBuilder _programmingEmbed = new EmbedBuilder { Title = "Programming Resources", @@ -178,28 +255,8 @@ public enum CannedResources "- Free Icons: [Icon Monstr](https://iconmonstr.com/)" }; - private readonly EmbedBuilder _deltaTime = new EmbedBuilder - { - Title = "Frame Independence", - Description = "[Time.deltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html) is the time in seconds it took to complete the last frame.\n" + - "Avoid moving objects or making calculations based on constant values." + - "```cs\n" + - "var speed = 1.0f\n" + - "var dir = transform.forward;\n" + - "// Move 'speed' units forward **Per Frame**\n" + - "transform.position += dir * speed;\n" + - "// Move 'speed' units forward **Per Second**\n" + - "transform.position += dir * speed * Time.deltaTime;```" + - "Avoid per-frame speeds as FPS varies among players, affecting object speed. Use `deltaTime` " + - "[Update](https://docs.unity3d.com/ScriptReference/MonoBehaviour.Update.html) or " + - "`fixedDeltaTime` [FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html) for consistent speed.\n" + - "See: [Time Frame Management](https://docs.unity3d.com/Manual/TimeFrameManagement.html), " + - "[FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html), " + - "[DeltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html)", - Url = "https://docs.unity3d.com/Manual/TimeFrameManagement.html", - }; + #endregion - #endregion // Configuration public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor = null) @@ -224,8 +281,15 @@ public EmbedBuilder GetUnbuiltCannedResponse(CannedResponseType type) CannedResponseType.NoCode => _noCodeEmbed, CannedResponseType.XYProblem => _xyProblemEmbed, CannedResponseType.DeltaTime => _deltaTime, + // General Help + CannedResponseType.Debugging => _debugging, + CannedResponseType.FolderStructure => _folderStructure, + // CannedResponseType.VersionControl => + // CannedResponseType.ErrorMessages => + // CannedResponseType.CodeStructure => + // CannedResponseType.CodeComments => // Passive Aggressive - CannedResponseType.GameToBig => _gameToBigEmbed, + CannedResponseType.GameTooBig => _gameTooBigEmbed, CannedResponseType.HowToGoogle => _howToGoogleEmbed, // General Help CannedResponseType.Programming => _programmingEmbed, From 71b5ce97881e908385302cfe6f1c3b6751f9d635 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Feb 2024 12:50:18 +1000 Subject: [PATCH 58/66] Start of an "Error" command which can be used with CS error codes --- .../Modules/UnityHelp/GeneralHelpModule.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs diff --git a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs new file mode 100644 index 00000000..d9da9385 --- /dev/null +++ b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs @@ -0,0 +1,88 @@ +using Discord.Commands; +using DiscordBot.Services; +using DiscordBot.Settings; +using DiscordBot.Utils; +using HtmlAgilityPack; + +namespace DiscordBot.Modules; + +public class GeneralHelpModule : ModuleBase +{ + #region Dependency Injection + + public UserService UserService { get; set; } + public BotSettings BotSettings { get; set; } + + #endregion // Dependency Injection + + [Command("error")] + [Summary("Uses a C# error code, or Unity error code and returns a link to appropriate documentation.")] + public async Task RespondWithErrorDocumentation(string error) + { + if (Context.User.IsUserBotOrWebhook()) + return; + + // If we're dealing with C# error + if (error.StartsWith("CS")) + { + // an array of potential url + List urls = new() + { + "https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/", + "https://docs.microsoft.com/en-us/dotnet/csharp/misc/" + }; + + HtmlDocument errorPage = null; + string usedUrl = string.Empty; + + foreach (var url in urls) + { + errorPage = await WebUtil.GetHtmlDocument($"{url}{error}"); + if (errorPage.DocumentNode.InnerHtml.Contains("Page not found")) + { + continue; + } + usedUrl = url; + break; + } + + if (errorPage == null) + { + await respondFailure( + $"Failed to locate {error} error page, however you should try google the error code, there is likely documentation for it."); + return; + } + + // We try to pull the first header and pray it contains the error code + // We grab the first h1 inside the "main" tag, or has class main-column + string header = errorPage.DocumentNode.SelectSingleNode("//main//h1")? + .InnerText ?? string.Empty; + // Attempt to grab the first paragraph inside a class with the id "main" + string summary = errorPage.DocumentNode.SelectSingleNode("//main//p")? + .InnerText ?? string.Empty; + + if (string.IsNullOrEmpty(header)) + { + await respondFailure($"Couldn't find documentation for error code {error}."); + return; + } + + // Construct an Embed, Title "C# Error Code: {error}", Description: {summary}, with a link to {url}{error} + var embed = new EmbedBuilder() + .WithTitle($"C# Error Code: {error}") + .WithDescription(summary) + .WithUrl($"{usedUrl}{error}") + .FooterRequestedBy(Context.User) + .Build(); + + await ReplyAsync(string.Empty, false, embed); + } + } + + + private async Task respondFailure(string errorMessage) + { + await ReplyAsync(errorMessage).DeleteAfterSeconds(30); + await Context.Message.DeleteAfterSeconds(30); + } +} \ No newline at end of file From b65b3f17b537254500d4ce19fc40063d0e296dcc Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Feb 2024 13:41:44 +1000 Subject: [PATCH 59/66] Another useless command `Roll` and `D20` Does what it says on the tin --- DiscordBot/Modules/UserModule.cs | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index cad0ce5e..0cb46d00 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -635,6 +635,59 @@ public async Task CoinFlip() await ReplyAsync($"**{Context.User.Username}** flipped a coin and got **{coin[_random.Next() % 2]}**!"); await Context.Message.DeleteAfterSeconds(seconds: 1); } + + [Command("Roll"), Priority(23)] + [Summary("Roll a dice. Syntax : !roll [sides] (max 100)")] + public async Task RollDice(int sides = 20) + { + if (sides < 1 || sides > 1000) + { + await ReplyAsync("Invalid number of sides. Please choose a number between 1 and 1000.").DeleteAfterSeconds(seconds: 10); + await Context.Message.DeleteAsync(); + return; + } + + var roll = _random.Next(1, sides + 1); + await ReplyAsync($"**{Context.User.Username}** rolled a D{sides} and got **{roll}**!"); + await Context.Message.DeleteAfterSeconds(seconds: 1); + } + + [Command("Roll"), Priority(23)] + [Summary("Roll a dice. Syntax : !roll [sides] [number]")] + public async Task RollDice(int sides, int number) + { + if (sides < 1 || sides > 1000) + { + await ReplyAsync("Invalid number of sides. Please choose a number between 1 and 1000.").DeleteAfterSeconds(seconds: 10); + await Context.Message.DeleteAsync(); + return; + } + + var roll = _random.Next(1, sides + 1); + var message = $"**{Context.User.Username}** rolled a D{sides} and got **{roll}**!"; + if (roll > number) + message = " :white_check_mark: " + message + " [Needed: " + number + "]"; + else + message = " :x: " + message + " [Needed: " + number + "]"; + + await ReplyAsync(message); + await Context.Message.DeleteAfterSeconds(seconds: 1); + } + + [Command("D20"), Priority(23)] + [Summary("Roll a D20 dice.")] + public async Task RollD20(int number) + { + var roll = _random.Next(1, 21); + var message = $"**{Context.User.Username}** rolled a D20 and got **{roll}**!"; + if (roll > number) + message = " :white_check_mark: " + message + " [Needed: " + number + "]"; + else + message = " :x: " + message + " [Needed: " + number + "]"; + + await ReplyAsync(message); + await Context.Message.DeleteAfterSeconds(seconds: 1); + } #endregion From 4acea8b1f8dc5c6b32f74ea99cedefcc3d2c1336 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 24 Feb 2024 10:34:16 +1000 Subject: [PATCH 60/66] Log when bot starts to text log as well as channel (Helps debug) --- DiscordBot/Program.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index e52faeb9..08655ad2 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -63,9 +63,9 @@ private async Task MainAsync() _services = ConfigureServices(); _commandHandlingService = _services.GetRequiredService(); - _client.GetGuild(_settings.GuildId) - ?.GetTextChannel(_settings.BotAnnouncementChannel.Id) - ?.SendMessageAsync("Bot Started."); + // Announce, and Log bot started to track issues a bit easier + var logger = _services.GetRequiredService(); + logger.LogChannelAndFile("Bot Started.", ExtendedLogSeverity.Positive); LoggingService.LogToConsole("Bot is connected.", ExtendedLogSeverity.Positive); _isInitialized = true; From 743dccd7553127270e5864eb608409ccab464f7a Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 24 Feb 2024 17:06:38 +1000 Subject: [PATCH 61/66] Simple watcher for Memes, just delete any discord invite links They're probably THE most common ping in the server at this stage, rather just nip this one but watching for invites --- DiscordBot/Extensions/MessageExtensions.cs | 29 ++++++++++++++++++ DiscordBot/Services/ModerationService.cs | 32 +++++++++++++++++++- DiscordBot/Settings/Deserialized/Settings.cs | 3 ++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/DiscordBot/Extensions/MessageExtensions.cs b/DiscordBot/Extensions/MessageExtensions.cs index f2d9ac70..fc039d1e 100644 --- a/DiscordBot/Extensions/MessageExtensions.cs +++ b/DiscordBot/Extensions/MessageExtensions.cs @@ -1,7 +1,11 @@ +using System.Text.RegularExpressions; + namespace DiscordBot.Extensions; public static class MessageExtensions { + private const string InviteLinkPattern = @"(https?:\/\/)?(www\.)?(discord\.gg\/[a-zA-Z0-9]+)"; + public static async Task TrySendMessage(this IDMChannel channel, string message = "", Embed embed = null) { try @@ -22,4 +26,29 @@ public static bool HasAnyPingableMention(this IUserMessage message) { return message.MentionedUserIds.Count > 0 || message.MentionedRoleIds.Count > 0 || message.MentionedEveryone; } + + /// + /// Returns true if the message contains any discord invite links, ie; discord.gg/invite + /// + public static bool ContainsInviteLink(this IUserMessage message) + { + return Regex.IsMatch(message.Content, InviteLinkPattern, RegexOptions.IgnoreCase); + } + + /// + /// Returns true if the message contains any discord invite links, ie; discord.gg/invite + /// + public static bool ContainsInviteLink(this string message) + { + return Regex.IsMatch(message, InviteLinkPattern, RegexOptions.IgnoreCase); + } + + /// + /// Returns true if the message contains any discord invite links, ie; discord.gg/invite + /// + public static bool ContainsInviteLink(this IMessage message) + { + return Regex.IsMatch(message.Content, InviteLinkPattern, RegexOptions.IgnoreCase); + } + } \ No newline at end of file diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index 3e1545d6..4c5b9f91 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -1,4 +1,5 @@ -using Discord.WebSocket; +using System.Text.RegularExpressions; +using Discord.WebSocket; using DiscordBot.Settings; namespace DiscordBot.Services; @@ -7,18 +8,25 @@ public class ModerationService { private readonly ILoggingService _loggingService; private readonly BotSettings _settings; + private readonly DiscordSocketClient _client; private const int MaxMessageLength = 800; private static readonly Color DeletedMessageColor = new (200, 128, 128); private static readonly Color EditedMessageColor = new (255, 255, 128); + + private readonly IMessageChannel _botAnnouncementChannel; public ModerationService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService) { + _client = client; _settings = settings; _loggingService = loggingService; client.MessageDeleted += MessageDeleted; client.MessageUpdated += MessageUpdated; + client.MessageReceived += MessageReceived; + + _botAnnouncementChannel = _client.GetChannel(_settings.BotAnnouncementChannel.Id) as IMessageChannel; } private async Task MessageDeleted(Cacheable message, Cacheable channel) @@ -108,4 +116,26 @@ private async Task MessageUpdated(Cacheable before, SocketMessa await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); } + + // MessageReceived + private async Task MessageReceived(SocketMessage message) + { + if (message.Author.IsBot) + return; + + if (_settings.ModeratorNoInviteLinks == true) + { + if (_settings.MemeChannel.Id == message.Channel.Id) + { + if (message.ContainsInviteLink()) + { + await message.DeleteAsync(); + // Send a message in _botAnnouncementChannel about the deleted message, nothing fancy, name, userid, channel and message content + await _botAnnouncementChannel.SendMessageAsync( + $"{message.Author.Mention} tried to post an invite link in <#{message.Channel.Id}>: {message.Content}"); + return; + } + } + } + } } \ No newline at end of file diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index e25a1832..6dbdf6c3 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -18,6 +18,7 @@ public class BotSettings public int WelcomeMessageDelaySeconds { get; set; } = 300; public bool ModeratorCommandsEnabled { get; set; } + public bool ModeratorNoInviteLinks { get; set; } // How long between when the bot will scold a user for trying to ping everyone. Default 6 hours public ulong EveryoneScoldPeriodSeconds { get; set; } = 21600; @@ -70,6 +71,8 @@ public class BotSettings public ChannelInfo ReportedMessageChannel { get; set; } + public ChannelInfo MemeChannel { get; set; } + #region Complaint Channel public ulong ComplaintCategoryId { get; set; } From 6aba4c8ccfea00443f215155c58d38b7c76c55b0 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 20 Mar 2024 17:02:02 +1000 Subject: [PATCH 62/66] Tiny optimisation --- DiscordBot/Services/UserService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index 86da65db..26cf478d 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -215,13 +215,13 @@ public async Task UpdateXp(SocketMessage messageParam) return; var userId = messageParam.Author.Id; + if (_xpCooldown.HasUser(userId)) + return; + var waitTime = _rand.Next(_xpMinCooldown, _xpMaxCooldown); float baseXp = _rand.Next(_xpMinPerMessage, _xpMaxPerMessage); float bonusXp = 0; - if (_xpCooldown.HasUser(userId)) - return; - // Add Delay and delay action by 200ms to avoid some weird database collision? _xpCooldown.AddCooldown(userId, waitTime); Task.Run(async () => From 6a2c99f9d7c6bdfa26035da6182fe38e26db9b68 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 20 Mar 2024 17:02:49 +1000 Subject: [PATCH 63/66] fix: Have InsertUser return new user to avoid additional database call --- DiscordBot/Extensions/UserDBRepository.cs | 6 ++++-- DiscordBot/Services/DatabaseService.cs | 26 ++++++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/DiscordBot/Extensions/UserDBRepository.cs b/DiscordBot/Extensions/UserDBRepository.cs index 3824347b..b5c62837 100644 --- a/DiscordBot/Extensions/UserDBRepository.cs +++ b/DiscordBot/Extensions/UserDBRepository.cs @@ -37,8 +37,10 @@ public static class UserProps public interface IServerUserRepo { - [Sql($"INSERT INTO {UserProps.TableName} ({UserProps.UserID}) VALUES (@{UserProps.UserID})")] - Task InsertUser(ServerUser user); + [Sql($@" + INSERT INTO {UserProps.TableName} ({UserProps.UserID}) VALUES (@{UserProps.UserID}); + SELECT * FROM {UserProps.TableName} WHERE {UserProps.UserID} = @{UserProps.UserID}")] + Task InsertUser(ServerUser user); [Sql($"DELETE FROM {UserProps.TableName} WHERE {UserProps.UserID} = @userId")] Task RemoveUser(string userId); diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 95670a13..afe61ae4 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -12,7 +12,7 @@ public class DatabaseService private readonly ILoggingService _logging; private string ConnectionString { get; } - + public IServerUserRepo Query { get; } public DatabaseService(ILoggingService logging, BotSettings settings) @@ -159,6 +159,20 @@ await _logging.LogChannelAndFile( /// Existing or newly created user. Null on database error. public async Task GetOrAddUser(SocketGuildUser socketUser) { + if (socketUser == null) + { + await _logging.Log(LogBehaviour.ConsoleChannelAndFile, + $"SocketUser is null", ExtendedLogSeverity.Warning); + return null; + } + + if (Query == null) + { + await _logging.Log(LogBehaviour.ConsoleChannelAndFile, + $"Query is null", ExtendedLogSeverity.Warning); + return null; + } + try { var user = await Query.GetUser(socketUser.Id.ToString()); @@ -170,8 +184,14 @@ public async Task GetOrAddUser(SocketGuildUser socketUser) UserID = socketUser.Id.ToString(), }; - await Query.InsertUser(user); - user = await Query.GetUser(socketUser.Id.ToString()); + user = await Query.InsertUser(user); + + if (user == null) + { + await _logging.Log(LogBehaviour.ConsoleChannelAndFile, + $"User is null after InsertUser", ExtendedLogSeverity.Warning); + return null; + } await _logging.Log(LogBehaviour.File, $"User {socketUser.GetPreferredAndUsername()} successfully added to the database."); From 96d324bf1a02561b25e0c2a1326d3bb0464430eb Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 20 Mar 2024 17:19:28 +1000 Subject: [PATCH 64/66] fix: api change for Currencies --- DiscordBot/Services/CurrencyService.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/DiscordBot/Services/CurrencyService.cs b/DiscordBot/Services/CurrencyService.cs index c27f33b4..d274f5e2 100644 --- a/DiscordBot/Services/CurrencyService.cs +++ b/DiscordBot/Services/CurrencyService.cs @@ -10,6 +10,7 @@ public class CurrencyService #region Configuration private const int ApiVersion = 1; + private const string TargetDate = "latest"; private const string ValidCurrenciesEndpoint = "currencies.min.json"; private const string ExchangeRatesEndpoint = "currencies"; @@ -23,24 +24,25 @@ private class Currency private readonly Dictionary _currencies = new(); - private static readonly string ApiUrl = $"https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@{ApiVersion}/latest/"; + private static readonly string ApiUrl = $"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{TargetDate}/v{ApiVersion}/"; public async Task GetConversion(string toCurrency, string fromCurrency = "usd") { toCurrency = toCurrency.ToLower(); fromCurrency = fromCurrency.ToLower(); - var url = $"{ApiUrl}{ExchangeRatesEndpoint}/{fromCurrency.ToLower()}/{toCurrency.ToLower()}.min.json"; + var url = $"{ApiUrl}{ExchangeRatesEndpoint}/{fromCurrency.ToLower()}.min.json"; // Check if success var (success, response) = await WebUtil.TryGetObjectFromJson(url); if (!success) return -1; - // Check currency exists in response - response.TryGetValue($"{toCurrency}", out var value); + // json[fromCurrency][toCurrency] + var value = response.SelectToken($"{fromCurrency}.{toCurrency}"); if (value == null) return -1; + return value.Value(); } From 5c316488f77e0b44e48b4e21c70584238b93ed7a Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 20 Mar 2024 17:30:20 +1000 Subject: [PATCH 65/66] fix: prevent < 1 values for D20 command --- DiscordBot/Modules/UserModule.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index 0cb46d00..e3c93d77 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -678,6 +678,12 @@ public async Task RollDice(int sides, int number) [Summary("Roll a D20 dice.")] public async Task RollD20(int number) { + if (number < 1) + { + await ReplyAsync("Invalid number. Please choose a number 1 or above.").DeleteAfterSeconds(seconds: 10); + await Context.Message.DeleteAsync(); + return; + } var roll = _random.Next(1, 21); var message = $"**{Context.User.Username}** rolled a D20 and got **{roll}**!"; if (roll > number) From 881c1cfa72db49035d20ce457ad6bb18efbf442a Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 20 Mar 2024 17:57:01 +1000 Subject: [PATCH 66/66] Additional slap choices --- DiscordBot/Settings/Deserialized/Settings.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 6dbdf6c3..19f282b1 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -24,8 +24,8 @@ public class BotSettings #region Fun Commands - public List UserModuleSlapChoices { get; set; } = new List() { "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", "low poly donut" }; - + public List UserModuleSlapChoices { get; set; } = new List() { "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", "life choice", "bug report", "unhandled exception", "null pointer", "keyboard", "cheese wheel", "banana peel", "unresolved bug", "low poly donut" }; + #endregion // Fun Commands #region Service Enabling