(count);
- int total = 0;
- for (int i = 0; i < count; i++)
- {
- var roll = _random.Next(1, sides + 1);
- rolls.Add($"{roll}");
- total += roll;
- }
- var message = $"**{uname}** rolled a D{sides} and got **{total}**!";
- if (count == 1 && (total == 1 || total == sides))
- message = $"**{uname}** rolled a D{sides} and got a natural **{total}**!";
- if (count > 1 && count < ordinals.Length)
- message = $"**{uname}** rolled {ordinals[count]} D{sides} showing {rolls.ToArray().ToCommaList()} for a total of **{total}**!";
- if (needed < 1)
- message = " :game_die: " + message;
- else if (total >= needed)
- message = " :white_check_mark: " + message + " [Needed: " + needed + "]";
- else
- message = " :x: " + message + " [Needed: " + needed + "]";
-
- await ReplyAsync(message);
- await Context.Message.DeleteAfterSeconds(seconds: 1);
- }
-
- [Command("D20"), Priority(23)]
- [Summary("Roll a D20 dice. Syntax: !d20 [needed]")]
- public async Task RollD20(int number = 0)
- {
- await RollDice("1d20", number);
- }
-
- // Parse a string that describes one or more equal dice.
- // Either a simple integer number of sides of a single die, e.g., "6", or
- // a Dungeons & Dragons standard format of a set of dice, e.g., "3d6" for three six-sided dice.
- //
- public static bool TryParseDice(string dice, out int sides, out int count)
- {
- sides = 6;
- count = 1;
- if (string.IsNullOrEmpty(dice))
- return false;
-
- // "20"
- dice = dice.Trim();
- if (Int32.TryParse(dice, out sides))
- {
- if (sides < 2 || sides > 1000)
- return false;
- return true;
- }
-
- // "d20" or "3d20"
- var separators = new char[] { 'd','D' };
- var parts = dice.Split(separators, 2, StringSplitOptions.TrimEntries);
- if (parts == null || parts.Length != 2)
- return false;
- if (!Int32.TryParse(parts[0], out count))
- count = 1;
- if (!Int32.TryParse(parts[1], out sides))
- sides = 6;
-
- if (count < 1 || count > 10)
- return false;
- if (sides < 2 || sides > 1000)
- return false;
-
- return true;
- }
-
- #endregion
-
- #region Search
- [Command("Search"), Priority(25)]
- [Summary("Searches DuckDuckGo for results. Syntax: !search c# lambda help")]
- [Alias("s", "ddg")]
- public async Task SearchResults(params string[] messages)
- {
- StringBuilder sb = new();
- foreach (var msg in messages)
- sb.Append(msg).Append(" ");
- await SearchResults(sb.ToString());
- }
-
- [Command("Search"), HideFromHelp]
- [Summary("Searches DuckDuckGo for web results. Syntax : !search \"query\" resNum site")]
- [Alias("s", "ddg")]
- public async Task SearchResults(string query, uint resNum = 3, string site = "")
- {
- // Cleaning inputs from user (maybe we can ban certain domains or keywords)
- resNum = resNum <= 5 ? resNum : 5;
- var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+');
-
- if (site != string.Empty) searchQuery += "+site:" + site;
-
- var doc = new HtmlWeb().Load(searchQuery);
- var counter = 1;
-
- EmbedBuilder embedBuilder = new();
- embedBuilder.Title = $"Q: {WebUtility.UrlDecode(query)}";
- string resultTitle = string.Empty;
-
- // XPath for DuckDuckGo as of 10/05/2018, if results stop showing up, check this first!
- // Still working (13/05/21)
- foreach (var row in doc.DocumentNode.SelectNodes("/html/body/div[1]/div[3]/div/div/div[*]/div/h2/a"))
- {
- if (counter > resNum) break;
-
- // Seems to be some weird additional data attached to links. Fix added (13/05/21)
- row.Attributes["href"].Value = row.Attributes["href"].Value.Replace("//duckduckgo.com/l/?uddg=", string.Empty);
-
- // Check if we are within the allowed number of results and if the result is valid (i.e. no evil ads)
- if (counter <= resNum && IsValidResult(row)) // && IsValidResult(row))
- {
- var url = WebUtility.UrlDecode(row.Attributes["href"].Value); // .Replace("/l/?kh=-1&uddg=", "")); <- no longer works (14/05/21)
-
- // We count how many & there are, as links with multiple may be broken, so we include a ~ just to try give a bit more info if there is more than 1.
- int andCount = url.Count(c => c == '&');
- url = url.Substring(0, url.LastIndexOf('&'));
-
- resultTitle += $"{counter}. {(row.InnerText.Length > 60 ? $"{row.InnerText[..60]}.." : row.InnerText)}" + $" [__Read More..__{(andCount > 1 ? "~" : string.Empty)}]({url})\n";
-
- counter++;
- }
- }
-
- embedBuilder.AddField("Search Query", searchQuery);
- embedBuilder.AddField("Results", resultTitle, inline: false);
-
- embedBuilder.Color = new Color(81, 50, 169);
- embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from DuckDuckGo.");
-
- var embed = embedBuilder.Build();
- await ReplyAsync(embed: embed);
- }
-
- // Utility function for avoiding evil ads from DuckDuckGo
- bool IsValidResult(HtmlNode node)
- {
- return (!node.Attributes["href"].Value.Contains("duckduckgo.com") &&
- !node.Attributes["href"].Value.Contains("duck.co"));
- }
-
- [Command("Manual"), Priority(8)]
- [Summary("Searches Unity3D manual for results. Syntax : !manual \"query\"")]
- public async Task SearchManual(params string[] queries)
- {
- // Download Unity3D Documentation Database (lol)
-
- // Calculate the closest match to the input query
- var minimumScore = double.MaxValue;
- string[] mostSimilarPage = null;
- var pages = await UpdateService.GetManualDatabase();
- var query = string.Join(" ", queries);
- foreach (var p in pages)
- {
- var curScore = CalculateScore(p[1], query);
- if (!(curScore < minimumScore)) continue;
-
- minimumScore = curScore;
- mostSimilarPage = p;
- }
-
- // If a page has been found (should be), return the message, else return information
- if (mostSimilarPage != null)
- {
- EmbedBuilder embedBuilder = new();
- embedBuilder.Title = $"Found {mostSimilarPage[0]}";
- embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)";
- embedBuilder.Color = new Color(81, 50, 169);
- embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs.");
- var message = await ReplyAsync(embed: embedBuilder.Build());
-
- var doc = new HtmlWeb().Load($"https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html");
- // Get first Header as this'll contain the main part we need
- var descriptionNode = doc.DocumentNode.SelectSingleNode("//h1");
- if (descriptionNode == null) return;
- // Description is in next , but we need to strip out tooltips
- descriptionNode = descriptionNode.SelectSingleNode("following-sibling::p");
- descriptionNode.Descendants().Where(n => n.GetAttributeValue("class", "").Contains("tooltip")).ToList().ForEach(n => n.Remove());
- var description = descriptionNode.InnerText;
-
- embedBuilder.WithDescription($"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + $"[Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)");
- await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build());
- }
- else
- await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10);
- }
-
- [Command("Doc"), Priority(9)]
- [Summary("Searches Unity3D API for results. Syntax : !api \"query\"")]
- [Alias("ref", "reference", "api", "docs")]
- public async Task SearchApi(params string[] queries)
- {
- // Download Unity3D Documentation Database (lol)
-
- // Calculate the closest match to the input query
- var minimumScore = double.MaxValue;
- string[] mostSimilarPage = null;
- var pages = await UpdateService.GetApiDatabase();
- var query = string.Join(" ", queries);
- foreach (var p in pages)
- {
- var curScore = CalculateScore(p[1], query);
- if (!(curScore < minimumScore)) continue;
-
- minimumScore = curScore;
- mostSimilarPage = p;
- }
-
- // If a page has been found (should be), return the message, else return information
- if (mostSimilarPage != null)
- {
- EmbedBuilder embedBuilder = new();
- embedBuilder.Title = $"Found {mostSimilarPage[0]}";
- embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)";
- embedBuilder.Color = new Color(81, 50, 169);
- embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs.");
- var message = await ReplyAsync(embed: embedBuilder.Build());
-
- // Load the page, and look for a
Description
tag, and then get the next tag
- var doc = new HtmlWeb().Load($"https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html");
- var descriptionNode = doc.DocumentNode.SelectSingleNode("//h3[contains(text(), 'Description')]");
-
- string descriptionString = "";
- string manualLinkString = "";
- if (descriptionNode != null)
- {
- var description = descriptionNode.SelectSingleNode("following-sibling::p").InnerText;
- descriptionString =
- $"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" +
- $"[Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)";
-
- }
-
- // We check the page for the first "switch-link" class, which will be a link to a Manual page
- var manualLink = doc.DocumentNode.SelectSingleNode("//a[contains(@class, 'switch-link')]");
- if (manualLink != null && manualLink.Attributes.Contains("title"))
- {
- var manualLinkText = manualLink.GetAttributes("title").First().Value;
- var manualLinkUrl = "https://docs.unity3d.com/" + manualLink.GetAttributeValue("href", "");
- manualLinkString = $"\n**Manual:** [{manualLinkText}]({manualLinkUrl})";
- }
-
- embedBuilder.WithDescription(descriptionString + manualLinkString);
- await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build());
- }
- else
- await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10);
- }
-
- private double CalculateScore(string s1, string s2)
- {
- double curScore = 0;
- var i = 0;
-
- foreach (var q in s1.Split(' '))
- {
- foreach (var x in s2.Split(' '))
- {
- i++;
- if (x.Equals(q))
- curScore -= 50;
- else
- curScore += x.CalculateLevenshteinDistance(q);
- }
- }
-
- curScore /= i;
- return curScore;
- }
-
- [Command("FAQ")]
- [Summary("Searches UDC FAQs. Syntax : !faq \"query\"")]
- public async Task SearchFaqs(params string[] queries)
- {
- var faqDataList = UpdateService.GetFaqData();
-
- // Check if query is faq ID (e.g. "!faq 1")
- if (queries.Length == 1 && ParseNumber(queries[0]) > 0)
- {
- var id = ParseNumber(queries[0]) - 1;
- if (id < faqDataList.Count)
- await ReplyAsync(embed: GetFaqEmbed(faqDataList[id]));
- else
- await ReplyAsync("Invalid FAQ ID selected.");
- }
- // Check if query contains "list" command (i.e. "!faq list")
- else if (queries.Length > 0 && !(queries.Length == 1 && queries[0].Equals("list")))
- {
- // Calculate the closest match to the input query
- var minimumScore = double.MaxValue;
- FaqData mostSimilarFaq = null;
- var query = string.Join(" ", queries);
-
- // Go through each FAQ in the list and check the most similar
- foreach (var faq in faqDataList)
- {
- foreach (var keyword in faq.Keywords)
- {
- var curScore = CalculateScore(keyword, query);
- if (curScore < minimumScore)
- {
- minimumScore = curScore;
- mostSimilarFaq = faq;
- }
- }
- }
-
- // If an FAQ has been found (should be), return the FAQ, else return information msg
- if (mostSimilarFaq != null)
- await ReplyAsync(embed: GetFaqEmbed(mostSimilarFaq));
- else
- await ReplyAsync("No FAQs Found.");
- }
- else
- // List all the FAQs available
- await ListFaqs(faqDataList);
- }
-
- private async Task ListFaqs(List faqs)
- {
- var sb = new StringBuilder(faqs.Count);
- var index = 1;
- var keywordSb = new StringBuilder();
- foreach (var faq in faqs)
- {
- sb.Append(FormatFaq(index, faq) + "\n");
- keywordSb.Append("[");
- for (var i = 0; i < faq.Keywords.Length; i++)
- {
- keywordSb.Append(faq.Keywords[i]);
- keywordSb.Append(i < faq.Keywords.Length - 1 ? ", " : "]\n\n");
- }
-
- index++;
- sb.Append(keywordSb);
- keywordSb.Clear();
- }
-
- await ReplyAsync(sb.ToString()).DeleteAfterTime(minutes: 3);
- }
-
- private Embed GetFaqEmbed(FaqData faq)
- {
- var builder = new EmbedBuilder()
- .WithTitle($"{faq.Question}")
- .WithDescription($"{faq.Answer}")
- .WithColor(new Color(0x33CC00));
- return builder.Build();
- }
-
- private string FormatFaq(int id, FaqData faq) => $"{id}. **{faq.Question}** - {faq.Answer}";
-
- [Command("Wiki"), Priority(26)]
- [Summary("Searches Wikipedia. Syntax : !wiki \"query\"")]
- [Alias("wikipedia")]
- public async Task SearchWikipedia([Remainder] string query)
- {
- var article = await UpdateService.DownloadWikipediaArticle(query);
-
- // If an article is found return it, else return error message
- if (article.url == null)
- {
- await ReplyAsync($"No Articles for \"{query}\" were found.");
- return;
- }
-
- await ReplyAsync(embed: GetWikipediaEmbed(article.name, article.extract, article.url));
- }
-
- private Embed GetWikipediaEmbed(string subject, string articleExtract, string articleUrl)
- {
- var builder = new EmbedBuilder()
- .WithTitle($"Wikipedia | {subject}")
- .WithDescription($"{articleExtract}")
- .WithUrl(articleUrl)
- .WithColor(new Color(0x33CC00));
- return builder.Build();
- }
-
- private int ParseNumber(string s)
- {
- int id;
- if (int.TryParse(s, out id)) return id;
-
- return -1;
- }
-
- #endregion
-
- #region Birthday
-
- [Command("Birthday"), HideFromHelp]
- [Summary("Display next member birthday.")]
- [Alias("bday")]
- public async Task Birthday()
- {
- // URL to cell C15/"Next birthday" cell from Corn's google sheet
- 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);
- await Context.Message.DeleteAfterTime(minutes: 3);
- }
-
- [Command("Birthday"), Priority(27)]
- [Summary("Display birthday of mentioned user. Syntax : !birthday @user")]
- [Alias("bday")]
- public async Task Birthday(IUser user)
- {
- var searchName = user.Username;
- // URL to columns B to D of Corn's google sheet
- 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 relevantNodes)
- {
- // XPath to the name column (C)
- var nameNode = row.SelectSingleNode("td[2]");
- var name = nameNode.InnerText;
-
- 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)
- {
- // XPath to the date column (B)
- var dateNode = matchedNode.SelectSingleNode("td[1]");
- // XPath to the year column (D)
- var yearNode = matchedNode.SelectSingleNode("td[3]");
-
- var provider = CultureInfo.InvariantCulture;
- var wrongFormat = "M/d/yyyy";
- //string rightFormat = "dd-MMMM-yyyy";
-
- var dateString = dateNode.InnerText;
- if (!yearNode.InnerText.Contains(" ")) dateString = dateString + "/" + yearNode.InnerText;
-
- dateString = dateString.Trim();
-
- try
- {
- // Converting the birthdate from the wrong format to the right format WITH year
- birthdate = DateTime.ParseExact(dateString, wrongFormat, provider);
- }
- catch (FormatException)
- {
- // Converting the birthdate from the wrong format to the right format WITHOUT year
- birthdate = DateTime.ParseExact(dateString, "M/d", provider);
- }
- }
-
- // Business as usual
- if (birthdate == default)
- {
- await ReplyAsync(
- $"Sorry, I couldn't find **{searchName}**'s birthday date. They can add it at https://docs.google.com/forms/d/e/1FAIpQLSfUglZtJ3pyMwhRk5jApYpvqT3EtKmLBXijCXYNwHY-v-lKxQ/viewform !")
- .DeleteAfterSeconds(30);
- }
- else
- {
- var date = birthdate.ToUnixTimestamp();
- var message =
- $"**{searchName}**'s birthdate: __**{birthdate.ToString("dd MMMM yyyy", CultureInfo.InvariantCulture)}**__ " +
- $"({(int)((DateTime.Now - birthdate).TotalDays / 365)}yo)";
-
- await ReplyAsync(message).DeleteAfterTime(minutes: 3);
- }
-
- await Context.Message.DeleteAfterTime(minutes: 3);
- }
-
- #endregion
-
- #region Temperatures
-
- [Command("FtoC"), Priority(28)]
- [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")]
- public async Task FahrenheitToCelsius(float f)
- {
- await ReplyAsync($"{Context.User.Mention} {f}°F is {MathUtility.FahrenheitToCelsius(f)}°C.");
- }
-
- [Command("CtoF"), Priority(28)]
- [Summary("Converts a temperature in celsius to fahrenheit. Syntax : !ftoc temperature")]
- public async Task CelsiusToFahrenheit(float c)
- {
- await ReplyAsync($"{Context.User.Mention} {c}°C is {MathUtility.CelsiusToFahrenheit(c)}°F");
- }
-
- #endregion
-
- #region Translate
-
- [Command("Translate"), HideFromHelp]
- [Summary("Translate a message. Syntax : !translate messageId language")]
- public async Task Translate(ulong messageId, string language = "en")
- {
- await Translate((await Context.Channel.GetMessageAsync(messageId)).Content, language);
- }
-
- [Command("Translate"), HideFromHelp]
- [Summary("Translate a message. Syntax : !translate text language")]
- public async Task Translate(string text, string language = "en")
- {
- var msg = await ReplyAsync($"Here: ");
- await Context.Message.DeleteAfterSeconds(seconds: 1);
- await msg.DeleteAfterSeconds(seconds: 20);
- }
-
- #endregion
-
- #region Currency
-
- [Command("CurrencyName"), Priority(29)]
- [Summary("Get the name of a currency. Syntax : !currname USD")]
- [Alias("currname")]
- public async Task CurrencyName(string currency)
- {
- if (Context.HasAnyPingableMention())
- return;
- var name = await CurrencyService.GetCurrencyName(currency);
- if (name == string.Empty)
- {
- await Context.Message.ReplyAsync($"Sorry, I couldn't find the name of the currency **{currency}**.");
- return;
- }
- await Context.Message.ReplyAsync($"The name of the currency **{currency.ToUpper()}** is **{name}**.");
- }
-
- [Command("Currency"), HideFromHelp]
- [Summary("Converts a currency. Syntax : !currency fromCurrency toCurrency")]
- [Alias("curr")]
- 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())
- {
- // 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();
-
- // We check if both currencies are valid
- bool fromValid = await CurrencyService.IsCurrency(from.ToLower());
- bool toValid = await CurrencyService.IsCurrency(to.ToLower());
-
- // Check if valid
- if (!fromValid || !toValid)
- {
- await Context.Message.ReplyAsync("One of the currencies provided is invalid.");
- return;
- }
-
- var response = await CurrencyService.GetConversion(to, from);
- if (Math.Abs(response - (-1)) < 0.01)
- {
- await Context.Message.ReplyAsync("An error occured while converting the currency, the API may be down!");
- return;
- }
-
- var totalAmount = Math.Round(amount * response, 2);
- await Context.Message.ReplyAsync($"**{amount} {from.ToUpper()}** = **{totalAmount} {to.ToUpper()}**");
- }
-
- #endregion
-}
diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs
deleted file mode 100644
index 1d2febd2..00000000
--- a/DiscordBot/Modules/UserSlashModule.cs
+++ /dev/null
@@ -1,544 +0,0 @@
-using System.Collections.Concurrent;
-using Discord.Interactions;
-using DiscordBot.Services;
-using DiscordBot.Settings;
-
-namespace DiscordBot.Modules;
-
-// For commands that only require a single interaction, these can be done automatically and don't require complex setup or configuration.
-// ie; A command that might just return the result of a service method such as Ping, or Welcome
-public class UserSlashModule : InteractionModuleBase
-{
- #region Dependency Injection
-
- public CommandHandlingService CommandHandlingService { get; set; }
- public UserService UserService { get; set; }
- public BotSettings BotSettings { get; set; }
- public ILoggingService LoggingService { get; set; }
-
- #endregion
-
- #region Help
-
- [SlashCommand("help", "Shows available commands")]
- private async Task Help(string search = "")
- {
- await Context.Interaction.DeferAsync(ephemeral: true);
-
- var helpEmbed = HelpEmbed(0, search);
- if (helpEmbed.Item1 >= 0)
- {
- ComponentBuilder builder = new();
- builder.WithButton("Next Page", $"user_module_help_next:{0}");
-
- await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true,
- components: builder.Build());
- }
- else
- {
- await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true);
- }
- }
-
- [ComponentInteraction("user_module_help_next:*")]
- private async Task InteractionHelp(string pageString)
- {
- await Context.Interaction.DeferAsync(ephemeral: true);
-
- int page = int.Parse(pageString);
-
- var helpEmbed = HelpEmbed(page + 1);
- ComponentBuilder builder = new();
- builder.WithButton("Next Page", $"user_module_help_next:{helpEmbed.Item1}");
-
- await Context.Interaction.ModifyOriginalResponseAsync(msg =>
- {
- msg.Components = builder.Build();
- msg.Embed = helpEmbed.Item2;
- });
- }
-
- // Returns an embed with the help text for a module, if the page is outside the bounds (high) it will return to the first page.
- private (int, Embed) HelpEmbed(int page, string search = "")
- {
- EmbedBuilder embedBuilder = new();
- embedBuilder.Title = "User Module Commands";
- embedBuilder.Color = Color.LighterGrey;
-
- List helpMessages = null;
- if (search == string.Empty)
- {
- helpMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false);
-
- if (page >= helpMessages.Count)
- page = 0;
- else if (page < 0)
- page = helpMessages.Count - 1;
-
- embedBuilder.WithFooter(text: $"Page {page + 1} of {helpMessages.Count}");
- embedBuilder.Description = helpMessages[page];
- }
- else
- {
- // We need search results which we don't cache, so we don't want to provide a page number
- page = -1;
- helpMessages = CommandHandlingService.SearchForCommand(("UserModule", false, true, false), search);
- if (helpMessages[0].Length > 0)
- {
- embedBuilder.WithFooter(text: $"Search results for {search}");
- embedBuilder.Description = helpMessages[0];
- }
- else
- {
- embedBuilder.WithFooter(text: $"No results for {search}");
- embedBuilder.Description = "No commands found";
- }
- }
-
- return (page, embedBuilder.Build());
- }
-
- #endregion
-
- [SlashCommand("welcome", "An introduction to the server!")]
- public async Task SlashWelcome()
- {
- await Context.Interaction.RespondAsync(string.Empty,
- embed: UserService.GetWelcomeEmbed(Context.User.Username), ephemeral: true);
- }
-
- [SlashCommand("ping", "Bot latency")]
- public async Task Ping()
- {
- await Context.Interaction.RespondAsync("Bot latency: ...", ephemeral: true);
- await Context.Interaction.ModifyOriginalResponseAsync(m =>
- m.Content = $"Bot latency: {UserService.GetGatewayPing().ToString()}ms");
- }
-
- [SlashCommand("invite", "Returns the invite link for the server.")]
- public async Task ReturnInvite()
- {
- await Context.Interaction.RespondAsync(text: BotSettings.Invite, ephemeral: true);
- }
-
- #region Moderation
-
- [MessageCommand("Report Message")]
- public async Task ReportMessage(IMessage reportedMessage)
- {
- if (reportedMessage.Author.Id == Context.User.Id)
- {
- await Context.Interaction.RespondAsync(text: "You can't report your own messages!", ephemeral: true);
- return;
- }
- if (reportedMessage.Author.IsBot) // Don't report bots
- {
- await Context.Interaction.RespondAsync(text: "You can't report bot messages!", ephemeral: true);
- return;
- }
- if (reportedMessage.Author.IsWebhook) // Don't report webhooks
- {
- await Context.Interaction.RespondAsync(text: "You can't report webhook messages!", ephemeral: true);
- return;
- }
- await Context.Interaction.RespondWithModalAsync($"report_{reportedMessage.Id}");
- }
-
- // Defines the modal that will be sent.
- public class ReportMessageModal : IModal
- {
- public string Title => "Report a message";
-
- // Additional parameters can be specified to further customize the input.
- [InputLabel("Reason")]
- [ModalTextInput("report_reason", TextInputStyle.Paragraph, maxLength: 500)]
- public string Reason { get; set; }
- }
-
- // Responds to the modal.
- [ModalInteraction("report_*")]
- public async Task ModalResponse(ulong id, ReportMessageModal modal)
- {
- var reportedMessage = await Context.Channel.GetMessageAsync(id);
-
- var reportedMessageChannel = await Context.Guild.GetTextChannelAsync(BotSettings.ReportedMessageChannel.Id);
- if (reportedMessageChannel == null)
- return;
-
- var embed = new EmbedBuilder()
- .WithColor(new Color(0xFF0000))
- .WithDescription(reportedMessage.Content)
- .WithTimestamp(reportedMessage.Timestamp)
- .WithFooter(footer =>
- {
- footer
- .WithText($"Reported by {Context.User.GetPreferredAndUsername()} • From channel {reportedMessage.Channel.Name}")
- .WithIconUrl(Context.User.GetAvatarUrl());
- })
- .AddAuthor(reportedMessage.Author);
-
- embed.Description += $"\n\n***[Linkback]({reportedMessage.GetJumpUrl()})***";
-
- if (reportedMessage.Attachments.Count > 0)
- {
- var attachments = reportedMessage.Attachments.Select(a => a.Url).ToList();
- string attachmentString = string.Empty;
- for (int i = 0; i < attachments.Count; i++)
- {
- attachmentString += $"• {attachments[i]}";
- if (i < attachments.Count - 1)
- attachmentString += "\n";
- }
- embed.AddField("Attachments", attachmentString);
- }
- embed.AddField("Reason", modal.Reason);
-
- await reportedMessageChannel.SendMessageAsync(string.Empty, embed: embed.Build());
- await RespondAsync("Message has been reported.", ephemeral: true);
- }
-
- #endregion // Moderation
-
- #region User Roles
-
- [SlashCommand("roles", "Give or Remove roles for yourself (Programmer, Artist, Designer, etc)")]
- public async Task UserRoles()
- {
- await Context.Interaction.DeferAsync(ephemeral: true);
-
- ComponentBuilder builder = new();
-
- foreach (var userRole in BotSettings.UserAssignableRoles.Roles)
- {
- builder.WithButton(userRole, $"user_role_add:{userRole}");
- }
-
- builder.Build();
-
- await Context.Interaction.FollowupAsync(text: "Click any role that applies to you!", embed: null,
- ephemeral: true, components: builder.Build());
- }
-
- [ComponentInteraction("user_role_add:*")]
- public async Task UserRoleAdd(string role)
- {
- await Context.Interaction.DeferAsync(ephemeral: true);
-
- var user = Context.User as IGuildUser;
- var guild = Context.Guild;
-
- // Try get the role from the guild
- var roleObj = guild.Roles.FirstOrDefault(r => r.Name == role);
- if (roleObj == null)
- {
- await Context.Interaction.ModifyOriginalResponseAsync(msg =>
- msg.Content = $"Failed to add role {role}, role not found.");
- return;
- }
- // We make sure the role is in our UserAssignableRoles just in case
- if (BotSettings.UserAssignableRoles.Roles.Contains(roleObj.Name))
- {
- if (user.RoleIds.Contains(roleObj.Id))
- {
- await user.RemoveRoleAsync(roleObj);
- await Context.Interaction.ModifyOriginalResponseAsync(msg =>
- msg.Content = $"{roleObj.Name} has been removed!");
- }
- else
- {
- await user.AddRoleAsync(roleObj);
- await Context.Interaction.ModifyOriginalResponseAsync(msg =>
- msg.Content = $"You now have the {roleObj.Name} role!");
- }
- }
- }
-
- #endregion
-
- #region Duel System
-
- private static readonly ConcurrentDictionary _activeDuels = new ConcurrentDictionary();
- private static readonly Random _random = new Random();
-
- private static readonly string[] _normalWinMessages =
- {
- "{winner} lands a solid hit on {loser} and wins the duel!",
- "{winner} uses their sword to attack {loser}, but {loser} fails to dodge and {winner} wins!",
- "{winner} outmaneuvers {loser} with a swift strike and claims victory!",
- "{winner} blocks {loser}'s attack and counters with a decisive blow!",
- "{winner} dodges {loser}'s clumsy swing and delivers the winning hit!",
- "{winner} parries {loser}'s blade and strikes back to win the duel!",
- "{winner} feints left, strikes right, and defeats {loser}!",
- "{winner} overwhelms {loser} with superior technique and emerges victorious!"
- };
-
- [SlashCommand("duel", "Challenge another user to a duel!")]
- public async Task Duel(
- [Summary(description: "The user you want to duel")] IUser opponent,
- [Summary(description: "Type of duel")]
- [Choice("Normal", "normal")]
- [Choice("Mute", "mute")]
- string type = "normal")
- {
- // Prevent self-dueling
- if (opponent.Id == Context.User.Id)
- {
- await Context.Interaction.RespondAsync("You cannot duel yourself!", ephemeral: true);
- return;
- }
-
- // Prevent dueling bots
- if (opponent.IsBot)
- {
- await Context.Interaction.RespondAsync("You cannot duel a bot!", ephemeral: true);
- return;
- }
-
- // Check for active duel
- string duelKey = $"{Context.User.Id}_{opponent.Id}";
- string reverseDuelKey = $"{opponent.Id}_{Context.User.Id}";
-
- if (_activeDuels.ContainsKey(duelKey) || _activeDuels.ContainsKey(reverseDuelKey))
- {
- await Context.Interaction.RespondAsync("There's already an active duel between you two!", ephemeral: true);
- return;
- }
-
- // Store the duel with both user IDs for timeout tracking
- _activeDuels[duelKey] = (Context.User.Id, opponent.Id);
-
- var embed = new EmbedBuilder()
- .WithColor(Color.Orange)
- .WithTitle("⚔️ Duel Challenge!")
- .WithDescription($"{Context.User.Mention} has challenged {opponent.Mention} to a duel!")
- .WithFooter($"This challenge will expire in 60 seconds");
-
- if (type == "mute")
- {
- embed.AddField("Risk", "The loser will be muted for 5 minutes.");
- }
-
- var components = new ComponentBuilder()
- .WithButton("⚔️ Accept", $"duel_accept:{duelKey}:{type}", ButtonStyle.Success)
- .WithButton("🛡️ Refuse", $"duel_refuse:{duelKey}", ButtonStyle.Danger)
- .WithButton("❌ Cancel", $"duel_cancel:{duelKey}", ButtonStyle.Secondary)
- .Build();
-
- await Context.Interaction.RespondAsync(embed: embed.Build(), components: components);
-
- // Store the message reference for timeout
- var originalResponse = await Context.Interaction.GetOriginalResponseAsync();
-
- // Auto-timeout after 60 seconds
- _ = Task.Run(async () =>
- {
- await Task.Delay(60000); // 60 seconds
- if (_activeDuels.ContainsKey(duelKey))
- {
- var (challengerId, opponentId) = _activeDuels[duelKey];
- _activeDuels.TryRemove(duelKey, out _);
-
- try
- {
- var challenger = await Context.Guild.GetUserAsync(challengerId);
- var challengedUser = await Context.Guild.GetUserAsync(opponentId);
-
- string timeoutMessage = challengedUser != null
- ? $"⏰ Duel challenge to {challengedUser.Mention} expired."
- : "⏰ Duel challenge expired.";
-
- await originalResponse.ModifyAsync(msg =>
- {
- msg.Content = string.Empty;
- msg.Embed = new EmbedBuilder()
- .WithColor(Color.LightGrey)
- .WithDescription(timeoutMessage)
- .Build();
- msg.Components = new ComponentBuilder().Build();
- });
- }
- catch (Exception ex)
- {
- await LoggingService.LogChannelAndFile($"Failed to modify duel timeout message: {ex.Message}", ExtendedLogSeverity.Warning);
- }
- }
- });
- }
-
- [ComponentInteraction("duel_accept:*:*")]
- public async Task DuelAccept(string duelKey, string type)
- {
- // Extract user IDs from the duel key
- var userIds = duelKey.Split('_');
- if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId))
- {
- await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true);
- return;
- }
-
- // Only the challenged user can accept
- if (Context.User.Id != opponentId)
- {
- await Context.Interaction.RespondAsync("Only the challenged user can accept this duel!", ephemeral: true);
- return;
- }
-
- // Check if duel is still active
- if (!_activeDuels.ContainsKey(duelKey))
- {
- await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true);
- return;
- }
-
- // Remove from active duels
- _activeDuels.TryRemove(duelKey, out _);
-
- await Context.Interaction.DeferAsync();
-
- // Get users
- var challenger = await Context.Guild.GetUserAsync(challengerId);
- var opponent = await Context.Guild.GetUserAsync(opponentId);
-
- if (challenger == null || opponent == null)
- {
- await Context.Interaction.FollowupAsync("One of the duel participants is no longer available!");
- return;
- }
-
- // Randomly select winner (50/50)
- bool challengerWins = _random.Next(2) == 0;
- var winner = challengerWins ? challenger : opponent;
- var loser = challengerWins ? opponent : challenger;
- if (type == "mute")
- {
- var isChallengerAdmin = challenger.GuildPermissions.Has(GuildPermission.Administrator);
- var isOpponentAdmin = opponent.GuildPermissions.Has(GuildPermission.Administrator);
- if (isChallengerAdmin || isOpponentAdmin)
- {
- // Unfair advantages are unfair. Also, bot can't mute admins. Remove the stakes.
- type = "friendly";
- }
- }
-
- // Generate flavor message
- string flavorMessage = _normalWinMessages[_random.Next(_normalWinMessages.Length)];
- flavorMessage = flavorMessage.Replace("{winner}", winner.Mention).Replace("{loser}", loser.Mention);
-
- var resultEmbed = new EmbedBuilder()
- .WithColor(Color.Gold)
- .WithTitle("⚔️ Duel Results!")
- .WithDescription(flavorMessage)
- .AddField("Winner", winner.Mention, inline: true)
- .Build();
-
- await Context.Interaction.ModifyOriginalResponseAsync(msg =>
- {
- msg.Embed = resultEmbed;
- msg.Components = new ComponentBuilder().Build();
- });
-
- // Handle mute duel using Discord timeout
- if (type == "mute")
- {
- try
- {
- var guildLoser = loser as IGuildUser;
- if (guildLoser != null)
- {
- // Use Discord's timeout feature for 5 minutes
- await guildLoser.SetTimeOutAsync(TimeSpan.FromMinutes(5), new RequestOptions { AuditLogReason = "Lost /duel" });
- await Context.Interaction.FollowupAsync($"💀 {loser.Mention} has been timed out for 5 minutes as the duel loser!", ephemeral: false);
- }
- }
- catch (Exception ex)
- {
- await LoggingService.LogChannelAndFile($"Failed to timeout the loser of the duel: {ex.Message}", ExtendedLogSeverity.Error);
- await Context.Interaction.FollowupAsync("Failed to timeout the loser.", ephemeral: false);
- }
- }
- }
-
- [ComponentInteraction("duel_refuse:*")]
- public async Task DuelRefuse(string duelKey)
- {
- // Extract user IDs from the duel key
- var userIds = duelKey.Split('_');
- if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId))
- {
- await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true);
- return;
- }
-
- // Only the challenged user can refuse
- if (Context.User.Id != opponentId)
- {
- await Context.Interaction.RespondAsync("Only the challenged user can refuse this duel!", ephemeral: true);
- return;
- }
-
- // Check if duel is still active
- if (!_activeDuels.ContainsKey(duelKey))
- {
- await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true);
- return;
- }
-
- // Remove from active duels
- _activeDuels.TryRemove(duelKey, out _);
-
- // Edit the embed to show refusal instead of deleting
- await Context.Interaction.DeferAsync();
- await Context.Interaction.ModifyOriginalResponseAsync(msg =>
- {
- msg.Content = string.Empty;
- msg.Embed = new EmbedBuilder()
- .WithColor(Color.LightGrey)
- .WithDescription("🛡️ Duel challenge was refused.")
- .Build();
- msg.Components = new ComponentBuilder().Build();
- });
- }
-
- [ComponentInteraction("duel_cancel:*")]
- public async Task DuelCancel(string duelKey)
- {
- // Extract user IDs from the duel key
- var userIds = duelKey.Split('_');
- if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId))
- {
- await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true);
- return;
- }
-
- // Only the challenger can cancel
- if (Context.User.Id != challengerId)
- {
- await Context.Interaction.RespondAsync("Only the challenger can cancel this duel!", ephemeral: true);
- return;
- }
-
- // Check if duel is still active
- if (!_activeDuels.ContainsKey(duelKey))
- {
- await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true);
- return;
- }
-
- // Remove from active duels
- _activeDuels.TryRemove(duelKey, out _);
-
- // Edit the embed to show cancellation
- await Context.Interaction.DeferAsync();
- await Context.Interaction.ModifyOriginalResponseAsync(msg =>
- {
- msg.Content = string.Empty;
- msg.Embed = new EmbedBuilder()
- .WithColor(Color.LightGrey)
- .WithDescription("❌ Duel challenge was cancelled by the challenger.")
- .Build();
- msg.Components = new ComponentBuilder().Build();
- });
- }
-
- #endregion
-}
diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/Utils/AirportModule.cs
similarity index 77%
rename from DiscordBot/Modules/AirportModule.cs
rename to DiscordBot/Modules/Utils/AirportModule.cs
index 5a35ae31..0d543cc8 100644
--- a/DiscordBot/Modules/AirportModule.cs
+++ b/DiscordBot/Modules/Utils/AirportModule.cs
@@ -1,9 +1,8 @@
using Discord.Commands;
-using DiscordBot.Modules.Weather;
using DiscordBot.Services;
using DiscordBot.Settings;
-namespace DiscordBot.Modules;
+namespace DiscordBot.Modules.Utils;
// Allows UserModule !help to show commands from this module
[Group("UserModule"), Alias("")]
@@ -11,25 +10,25 @@ public class AirportModule : ModuleBase
{
#region Dependency Injection
- public AirportService AirportService { get; set; }
- public BotSettings Settings { get; set; }
+ public AirportService AirportService { get; set; } = null!;
+ public BotSettings Settings { get; set; } = null!;
// Needed to locate cities lon/lat easier
- public WeatherService WeatherService { get; set; }
+ public WeatherService WeatherService { get; set; } = null!;
#endregion // Dependency Injection
#region API Results
-
+
public class FlightResults
{
- public string iata { get; set; }
- public string fs { get; set; }
- public string name { get; set; }
+ public string iata { get; set; } = string.Empty;
+ public string fs { get; set; } = string.Empty;
+ public string name { get; set; } = string.Empty;
}
public class FlightRoot
{
- public List data { get; set; }
+ public List data { get; set; } = [];
}
#endregion // API Results
@@ -41,19 +40,19 @@ public class FlightRoot
public async Task FlyTo(string from, string to)
{
// Make sure command is in Bot-Commands or OffTopic
- if (Context.Channel.Id != Settings.BotCommandsChannel.Id && Context.Channel.Id != Settings.GeneralChannel.Id)
+ if (Context.Channel.Id != Settings.Channels.BotCommands.Id && Context.Channel.Id != Settings.Channels.General.Id)
{
- await ReplyAsync($"Command can only be used in <#{Settings.BotCommandsChannel.Id}> or <#{Settings.GeneralChannel.Id}>.").DeleteAfterSeconds(5f);
- await Context.Message.DeleteAfterSeconds(2f);
+ await (ReplyAsync($"Command can only be used in <#{Settings.Channels.BotCommands.Id}> or <#{Settings.Channels.General.Id}>.").DeleteAfterSeconds(5f) ?? Task.CompletedTask);
+ await (Context.Message.DeleteAfterSeconds(2f) ?? Task.CompletedTask);
return;
}
-
+
EmbedBuilder embed = new();
embed.Title = "Flight Finder";
embed.Description = "Finding cities";
var msg = await ReplyAsync(string.Empty, false, embed.Build());
-
+
// Use Weather API to get lon/lat of cities
var fromCity = await GetCity(from, embed, msg);
if (fromCity == null)
@@ -61,7 +60,7 @@ public async Task FlyTo(string from, string to)
var toCity = await GetCity(to, embed, msg);
if (toCity == null)
return;
-
+
// Find closest Airport using AirLabs API
embed.Description = "Finding airports";
await msg.ModifyAsync(x => x.Embed = embed.Build());
@@ -72,11 +71,11 @@ public async Task FlyTo(string from, string to)
var toAirport = await GetAirport(toCity, embed, msg);
if (toAirport == null)
return;
-
+
// Find cheapest flight using GetFlightInfo
embed.Description = $"Searching {fromAirport.name} to {toAirport.name}";
await msg.ModifyAsync(x => x.Embed = embed.Build());
-
+
var daysUntilTuesday = (int)DateTime.Now.DayOfWeek - 2;
if (daysUntilTuesday < 0)
daysUntilTuesday += 7;
@@ -86,12 +85,12 @@ public async Task FlyTo(string from, string to)
{
embed.Description += "\\nNo flights found, sorry.";
await msg.ModifyAsync(x => x.Embed = embed.Build());
- await msg.DeleteAfterSeconds(30f);
+ await (msg.DeleteAfterSeconds(30f) ?? Task.CompletedTask);
return;
}
var flight = flights[0];
-
+
var itinerary = flight.itineraries.First();
var numberOfStops = itinerary.segments.Count - 1;
var departTime = itinerary.segments.First().departure;
@@ -106,7 +105,7 @@ public async Task FlyTo(string from, string to)
// embed.Description +=
// $"\nSeats remaining: {flight.numberOfBookableSeats}, Bags: {(flight.pricingOptions.includedCheckedBagsOnly ? "Y" : "N")}, OneWay: {(flight.oneWay ? "Y" : "N")}";
embed.Description += $"\nDepart: {departTime.at:dd/MM/yy HH:MM}, Arrive: {arriveTime.at:dd/MM/yy HH:MM}";
-
+
// string price = $"Base: {flight.price.@base}";
// foreach (var fee in flight.price.fees)
// {
@@ -121,35 +120,35 @@ public async Task FlyTo(string from, string to)
}
#endregion // Commands
-
+
#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)
{
embed.Description += $"\n{city} could not be found.";
await msg.ModifyAsync(x => x.Embed = embed.Build());
- await msg.DeleteAfterSeconds(10f);
+ await (msg.DeleteAfterSeconds(10f) ?? Task.CompletedTask);
return null;
}
return cityResult;
}
-
- private async Task GetAirport(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)
{
embed.Description += $"\nAirport near {weather.name} ({weather.sys.country}) could not be found.";
await msg.ModifyAsync(x => x.Embed = embed.Build());
- await msg.DeleteAfterSeconds(10f);
+ await (msg.DeleteAfterSeconds(10f) ?? Task.CompletedTask);
return null;
}
return airportResult;
}
#endregion // Utility Methods
-
+
}
\ No newline at end of file
diff --git a/DiscordBot/Modules/Utils/ConvertModule.cs b/DiscordBot/Modules/Utils/ConvertModule.cs
new file mode 100644
index 00000000..293bc7e5
--- /dev/null
+++ b/DiscordBot/Modules/Utils/ConvertModule.cs
@@ -0,0 +1,102 @@
+using Discord.Commands;
+using DiscordBot.Attributes;
+using DiscordBot.Services;
+using DiscordBot.Utils;
+
+namespace DiscordBot.Modules.Utils;
+
+[Group("UserModule"), Alias("")]
+public class ConvertModule : ModuleBase
+{
+ public CurrencyService CurrencyService { get; set; } = null!;
+
+ [Command("FtoC"), Priority(28)]
+ [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")]
+ public async Task FahrenheitToCelsius(float f)
+ {
+ await ReplyAsync($"{Context.User.Mention} {f}°F is {MathUtility.FahrenheitToCelsius(f)}°C.");
+ }
+
+ [Command("CtoF"), Priority(28)]
+ [Summary("Converts a temperature in celsius to fahrenheit. Syntax : !ftoc temperature")]
+ public async Task CelsiusToFahrenheit(float c)
+ {
+ await ReplyAsync($"{Context.User.Mention} {c}°C is {MathUtility.CelsiusToFahrenheit(c)}°F");
+ }
+
+ [Command("Translate"), HideFromHelp]
+ [Summary("Translate a message. Syntax : !translate messageId language")]
+ public async Task Translate(ulong messageId, string language = "en")
+ {
+ await Translate((await Context.Channel.GetMessageAsync(messageId)).Content, language);
+ }
+
+ [Command("Translate"), HideFromHelp]
+ [Summary("Translate a message. Syntax : !translate text language")]
+ public async Task Translate(string text, string language = "en")
+ {
+ var msg = await ReplyAsync($"Here: ");
+ await Context.Message.DeleteAfterSeconds(seconds: 1)!;
+ await msg.DeleteAfterSeconds(seconds: 20)!;
+ }
+
+ [Command("CurrencyName"), Priority(29)]
+ [Summary("Get the name of a currency. Syntax : !currname USD")]
+ [Alias("currname")]
+ public async Task CurrencyName(string currency)
+ {
+ if (Context.HasAnyPingableMention())
+ return;
+ var name = await CurrencyService.GetCurrencyName(currency);
+ if (name == string.Empty)
+ {
+ await Context.Message.ReplyAsync($"Sorry, I couldn't find the name of the currency **{currency}**.");
+ return;
+ }
+ await Context.Message.ReplyAsync($"The name of the currency **{currency.ToUpper()}** is **{name}**.");
+ }
+
+ [Command("Currency"), HideFromHelp]
+ [Summary("Converts a currency. Syntax : !currency fromCurrency toCurrency")]
+ [Alias("curr")]
+ 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())
+ {
+ if (!Context.IsReply())
+ return;
+ if (!Context.IsOnlyReplyingToAuthor())
+ return;
+ }
+
+ from = from.ToLower();
+ to = to.ToLower();
+
+ bool fromValid = await CurrencyService.IsCurrency(from.ToLower());
+ bool toValid = await CurrencyService.IsCurrency(to.ToLower());
+
+ if (!fromValid || !toValid)
+ {
+ await Context.Message.ReplyAsync("One of the currencies provided is invalid.");
+ return;
+ }
+
+ var response = await CurrencyService.GetConversion(to, from);
+ if (Math.Abs(response - (-1)) < 0.01)
+ {
+ await Context.Message.ReplyAsync("An error occured while converting the currency, the API may be down!");
+ return;
+ }
+
+ var totalAmount = Math.Round(amount * response, 2);
+ await Context.Message.ReplyAsync($"**{amount} {from.ToUpper()}** = **{totalAmount} {to.ToUpper()}**");
+ }
+}
diff --git a/DiscordBot/Modules/Utils/SearchModule.cs b/DiscordBot/Modules/Utils/SearchModule.cs
new file mode 100644
index 00000000..2c3a14c0
--- /dev/null
+++ b/DiscordBot/Modules/Utils/SearchModule.cs
@@ -0,0 +1,147 @@
+using System.Net;
+using System.Text;
+using Discord.Commands;
+using DiscordBot.Services;
+using DiscordBot.Settings;
+using DiscordBot.Attributes;
+
+namespace DiscordBot.Modules.Utils;
+
+[Group("UserModule"), Alias("")]
+public class SearchModule : ModuleBase
+{
+ public ILoggingService LoggingService { get; set; } = null!;
+ public BotSettings Settings { get; set; } = null!;
+ public UpdateService UpdateService { get; set; } = null!;
+ public SearchService SearchService { get; set; } = null!;
+
+ [Command("Search"), Priority(25)]
+ [Summary("Searches DuckDuckGo for results. Syntax: !search c# lambda help")]
+ [Alias("s", "ddg")]
+ public async Task SearchResults(params string[] messages)
+ {
+ StringBuilder sb = new();
+ foreach (var msg in messages)
+ sb.Append(msg).Append(" ");
+ await SearchResults(sb.ToString());
+ }
+
+ [Command("Search"), HideFromHelp]
+ [Summary("Searches DuckDuckGo for web results. Syntax : !search \"query\" resNum site")]
+ [Alias("s", "ddg")]
+ public async Task SearchResults(string query, uint resNum = 3, string site = "")
+ {
+ var results = SearchService.SearchDuckDuckGo(query, resNum, site);
+
+ var resultTitle = string.Empty;
+ for (int i = 0; i < results.Count; i++)
+ {
+ resultTitle += $"{i + 1}. {results[i].Title} [__Read More__]({results[i].Url})\n";
+ }
+
+ var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+');
+ if (site != string.Empty) searchQuery += "+site:" + site;
+
+ EmbedBuilder embedBuilder = new();
+ embedBuilder.Title = $"Q: {WebUtility.UrlDecode(query)}";
+ embedBuilder.AddField("Search Query", searchQuery);
+ embedBuilder.AddField("Results", resultTitle.Length > 0 ? resultTitle : "No results found.", inline: false);
+ embedBuilder.Color = new Color(81, 50, 169);
+ embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from DuckDuckGo.");
+
+ await ReplyAsync(embed: embedBuilder.Build());
+ }
+
+ [Command("Manual"), Priority(8)]
+ [Summary("Searches Unity3D manual for results. Syntax : !manual \"query\"")]
+ public async Task SearchManual(params string[] queries)
+ {
+ var pages = await UpdateService.GetManualDatabase();
+ var query = string.Join(" ", queries);
+ var match = SearchService.FindBestMatch(query, pages!, "https://docs.unity3d.com/Manual");
+
+ if (match != null)
+ {
+ var url = $"{match.BaseUrl}/{match.PageName}.html";
+
+ EmbedBuilder embedBuilder = new();
+ embedBuilder.Title = $"Found {match.PageName}";
+ embedBuilder.Description = $"**{match.Title}** - [Read More..]({url})";
+ embedBuilder.Color = new Color(81, 50, 169);
+ embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs.");
+ var message = await ReplyAsync(embed: embedBuilder.Build());
+
+ var description = SearchService.FetchPageDescription(url, "//h1", "following-sibling::p");
+ if (description != null)
+ {
+ embedBuilder.WithDescription($"**Description:** {description}\n[Read More..]({url})");
+ await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build());
+ }
+ }
+ else
+ await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10)!;
+ }
+
+ [Command("Doc"), Priority(9)]
+ [Summary("Searches Unity3D API for results. Syntax : !api \"query\"")]
+ [Alias("ref", "reference", "api", "docs")]
+ public async Task SearchApi(params string[] queries)
+ {
+ var pages = await UpdateService.GetApiDatabase();
+ var query = string.Join(" ", queries);
+ var match = SearchService.FindBestMatch(query, pages!, "https://docs.unity3d.com/ScriptReference");
+
+ if (match != null)
+ {
+ var url = $"{match.BaseUrl}/{match.PageName}.html";
+
+ EmbedBuilder embedBuilder = new();
+ embedBuilder.Title = $"Found {match.PageName}";
+ embedBuilder.Description = $"**{match.Title}** - [Read More..]({url})";
+ embedBuilder.Color = new Color(81, 50, 169);
+ embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs.");
+ var message = await ReplyAsync(embed: embedBuilder.Build());
+
+ var description = SearchService.FetchPageDescription(url, "//h3[contains(text(), 'Description')]", "following-sibling::p");
+ var manualLink = SearchService.FetchManualLink(url);
+
+ string descriptionString = description != null
+ ? $"**Description:** {description}\n[Read More..]({url})"
+ : string.Empty;
+ string manualLinkString = manualLink != null
+ ? $"\n**Manual:** {manualLink}"
+ : string.Empty;
+
+ embedBuilder.WithDescription(descriptionString + manualLinkString);
+ await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build());
+ }
+ else
+ await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10)!;
+ }
+
+ [Command("Wiki"), Priority(26)]
+ [Summary("Searches Wikipedia. Syntax : !wiki \"query\"")]
+ [Alias("wikipedia")]
+ public async Task SearchWikipedia([Remainder] string query)
+ {
+ var article = await UpdateService.DownloadWikipediaArticle(query);
+
+ if (article.url == null)
+ {
+ await ReplyAsync($"No Articles for \"{query}\" were found.");
+ return;
+ }
+
+ await ReplyAsync(embed: GetWikipediaEmbed(article.name!, article.extract!, article.url!));
+ }
+
+ private Embed GetWikipediaEmbed(string subject, string articleExtract, string articleUrl)
+ {
+ var builder = new EmbedBuilder()
+ .WithTitle($"Wikipedia | {subject}")
+ .WithDescription($"{articleExtract}")
+ .WithUrl(articleUrl)
+ .WithColor(new Color(0x33CC00));
+ return builder.Build();
+ }
+}
diff --git a/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs b/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs
new file mode 100644
index 00000000..dde28698
--- /dev/null
+++ b/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs
@@ -0,0 +1,129 @@
+using Newtonsoft.Json;
+
+namespace DiscordBot.Modules.Utils.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; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string Icon { get; set; } = string.Empty;
+ }
+
+ 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; } = string.Empty;
+ public int sunrise { get; set; }
+ public int sunset { get; set; }
+ }
+
+ public class Result
+ {
+ public Coord coord { get; set; } = null!;
+ public List weather { get; set; } = [];
+ public string @base { get; set; } = string.Empty;
+ public Main main { get; set; } = null!;
+ public int visibility { get; set; }
+ public Wind wind { get; set; } = null!;
+ public Clouds clouds { get; set; } = null!;
+ public Rain rain { get; set; } = null!;
+ public Snow snow { get; set; } = null!;
+ public int dt { get; set; }
+ public Sys sys { get; set; } = null!;
+ public int timezone { get; set; }
+ public int id { get; set; }
+ public string name { get; set; } = string.Empty;
+ 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; } = null!;
+ public Components components { get; set; } = null!;
+ public int dt { get; set; }
+ }
+ public class Result
+ {
+ public Coord coord { get; set; } = null!;
+ public List list { get; set; } = [];
+ }
+}
+
+// ReSharper restore InconsistentNaming
+#pragma warning restore 0649
+#endregion
\ No newline at end of file
diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Utils/Weather/WeatherModule.cs
similarity index 79%
rename from DiscordBot/Modules/Weather/WeatherModule.cs
rename to DiscordBot/Modules/Utils/Weather/WeatherModule.cs
index 4da268fd..a4abfe1f 100644
--- a/DiscordBot/Modules/Weather/WeatherModule.cs
+++ b/DiscordBot/Modules/Utils/Weather/WeatherModule.cs
@@ -1,10 +1,9 @@
using Discord.Commands;
using DiscordBot.Attributes;
-using DiscordBot.Modules.Weather;
using DiscordBot.Services;
using Newtonsoft.Json;
-namespace DiscordBot.Modules;
+namespace DiscordBot.Modules.Utils.Weather;
// https://openweathermap.org/current#call
// Allows UserModule !help to show commands from this module
@@ -12,12 +11,12 @@ namespace DiscordBot.Modules;
public class WeatherModule : ModuleBase
{
#region Dependency Injection
-
- public WeatherService WeatherService { get; set; }
- public UserExtendedService UserExtendedService { get; set; }
-
+
+ public WeatherService WeatherService { get; set; } = null!;
+ public UserExtendedService UserExtendedService { get; set; } = null!;
+
#endregion
-
+
private List AQI_Index = new List()
{"Invalid", "Good", "Fair", "Moderate", "Poor", "Very Poor"};
@@ -31,15 +30,15 @@ public async Task WeatherHelp()
.WithDescription(
"If the city isn't correct you will need to include the correct [city codes](https://www.iso.org/obp/ui/#search).\n**Example Usage**: *!Weather Wellington, UK*");
await Context.Message.DeleteAsync();
- await ReplyAsync(embed: builder.Build()).DeleteAfterSeconds(seconds: 30);
+ await ReplyAsync(embed: builder.Build()).DeleteAfterSeconds(seconds: 30)!;
}
#region Temperature
-
- private async Task TemperatureEmbed(string city, string replaceCityWith = "")
+
+ private async Task TemperatureEmbed(string city, string replaceCityWith = "")
{
- WeatherContainer.Result res = await WeatherService.GetWeather(city: city);
- if (!await IsResultsValid(res))
+ WeatherContainer.Result? res = await WeatherService.GetWeather(city: city);
+ if (!await IsResultsValid(res) || res is null)
return null;
EmbedBuilder builder = new EmbedBuilder()
@@ -50,16 +49,16 @@ private async Task TemperatureEmbed(string city, string replaceCit
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 = null)
+ public async Task Temperature(IUser? user = null)
{
user ??= Context.User;
if (!await DoesUserHaveDefaultCity(user))
return;
-
+
var city = await UserExtendedService.GetUserDefaultCity(user);
var builder = await TemperatureEmbed(city, user.GetUserPreferredName());
if (builder == null)
@@ -68,7 +67,7 @@ public async Task Temperature(IUser user = null)
await ReplyAsync(embed: builder.Build());
}
-
+
[Command("Temperature")]
[Summary("Attempts to provide the temperature of the city provided.")]
[Alias("temp"), Priority(20)]
@@ -80,30 +79,30 @@ public async Task Temperature(params string[] city)
await ReplyAsync(embed: builder.Build());
}
-
+
#endregion // Temperature
#region Weather
-
- private async Task WeatherEmbed(string city, string replaceCityWith = "")
+
+ private async Task WeatherEmbed(string city, string replaceCityWith = "")
{
- WeatherContainer.Result res = await WeatherService.GetWeather(city: city);
- if (!await IsResultsValid(res))
+ WeatherContainer.Result? res = await WeatherService.GetWeather(city: city);
+ if (!await IsResultsValid(res) || res is null)
return null;
string extraInfo = string.Empty;
-
+
DateTime sunrise = DateTime.UnixEpoch.AddSeconds(res.sys.sunrise)
.AddSeconds(res.timezone);
DateTime sunset = DateTime.UnixEpoch.AddSeconds(res.sys.sunset)
.AddSeconds(res.timezone);
-
+
// Sun rise/set
if (res.sys.sunrise > 0)
extraInfo += $"Sunrise **{sunrise:hh\\:mmtt}**, ";
- if (res.sys.sunrise > 0)
+ if (res.sys.sunset > 0)
extraInfo += $"Sunset **{sunset:hh\\:mmtt}**\n";
-
+
if (res.main.Temp > 0 && res.rain != null)
{
if (res.rain.Rain3h > 0)
@@ -128,18 +127,18 @@ private async Task WeatherEmbed(string city, string replaceCityWit
.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 = null)
+ public async Task CurentWeather(IUser? user = null)
{
user ??= Context.User;
if (!await DoesUserHaveDefaultCity(user))
return;
-
+
var city = await UserExtendedService.GetUserDefaultCity(user);
var builder = await WeatherEmbed(city, user.GetUserPreferredName());
if (builder == null)
@@ -159,21 +158,23 @@ public async Task CurentWeather(params string[] city)
await ReplyAsync(embed: builder.Build());
}
-
+
#endregion // Weather
#region Pollution
- private async Task PollutionEmbed(string city, string replaceCityWith = "")
+ private async Task PollutionEmbed(string city, string replaceCityWith = "")
{
- WeatherContainer.Result res = await WeatherService.GetWeather(city: city);
- if (!await IsResultsValid(res))
+ WeatherContainer.Result? res = await WeatherService.GetWeather(city: city);
+ if (!await IsResultsValid(res) || res is null)
return null;
// We can't really combine the call as having WeatherResults helps with other details
- PollutionContainer.Result polResult =
+ PollutionContainer.Result? polResult =
await WeatherService.GetPollution(Math.Round(res.coord.Lon, 4), Math.Round(res.coord.Lat, 4));
+ if (polResult is null)
+ return null;
var comp = polResult.list[0].components;
double combined = comp.CarbonMonoxide + comp.NitrogenMonoxide + comp.NitrogenDioxide + comp.Ozone +
@@ -211,12 +212,12 @@ 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 = null)
+ public async Task Pollution(IUser? user = null)
{
user ??= Context.User;
if (!await DoesUserHaveDefaultCity(user))
return;
-
+
var city = await UserExtendedService.GetUserDefaultCity(user);
var builder = await PollutionEmbed(city, user.GetUserPreferredName());
if (builder == null)
@@ -225,7 +226,7 @@ public async Task Pollution(IUser user = null)
await ReplyAsync(embed: builder.Build());
}
-
+
[Command("Pollution"), Priority(21)]
[Summary("Attempts to provide the pollution conditions of the city provided.")]
public async Task Pollution(params string[] city)
@@ -236,15 +237,15 @@ public async Task Pollution(params string[] city)
await ReplyAsync(embed: builder.Build());
}
-
+
#endregion // Pollution
#region Time
-
- private async Task TimeEmbed(string city, string replaceCityWith = "")
+
+ private async Task TimeEmbed(string city, string replaceCityWith = "")
{
- WeatherContainer.Result res = await WeatherService.GetWeather(city: city);
- if (!await IsResultsValid(res))
+ WeatherContainer.Result? res = await WeatherService.GetWeather(city: city);
+ if (!await IsResultsValid(res) || res is null)
return null;
var timezone = res.timezone / 3600;
@@ -256,15 +257,15 @@ private async Task TimeEmbed(string city, string replaceCityWith =
return builder;
}
-
+
[Command("Time"), HideFromHelp, Priority(22)]
[Summary("Attempts to provide the time of the user provided.")]
- public async Task Time(IUser user = null)
+ public async Task Time(IUser? user = null)
{
user ??= Context.User;
if (!await DoesUserHaveDefaultCity(user))
return;
-
+
var city = await UserExtendedService.GetUserDefaultCity(user);
var builder = await TimeEmbed(city, user.GetUserPreferredName());
if (builder == null)
@@ -273,7 +274,7 @@ public async Task Time(IUser user = null)
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)
@@ -284,9 +285,9 @@ public async Task Time(params string[] city)
await ReplyAsync(embed: builder.Build());
}
-
+
#endregion // Time
-
+
#region Utility Methods
private async Task IsResultsValid(T res)
@@ -313,18 +314,18 @@ private Color GetColour(float temp)
_ => new Color(255, 0, 0)
};
}
-
+
private async Task DoesUserHaveDefaultCity(IUser user)
{
// If they do, return true
if (await UserExtendedService.DoesUserHaveDefaultCity(user)) return true;
-
+
// Otherwise respond and return false
var uname = user.GetUserPreferredName();
await ReplyAsync($"User {uname} does not have a default city set.");
return false;
}
-
+
private static string GetWindDirection(float windDeg)
{
if (windDeg < 22.5)
@@ -345,6 +346,44 @@ private static string GetWindDirection(float windDeg)
return "NW";
return "N";
}
-
+
#endregion Utility Methods
+
+ #region City Settings
+
+ [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 uname = Context.User.GetUserPreferredName();
+ var fullCityName = string.Join(" ", city);
+ var (exists, result) = await WeatherService.CityExists(fullCityName);
+ if (!exists || result is null)
+ {
+ await ReplyAsync($"Sorry, {uname}, but I couldn't find a city with that name.").DeleteAfterSeconds(30)!;
+ await Context.Message.DeleteAsync();
+ return;
+ }
+ await UserExtendedService.SetUserDefaultCity(Context.User, result.name);
+ await ReplyAsync($"{uname}, 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()
+ {
+ var uname = Context.User.GetUserPreferredName();
+ if (!await UserExtendedService.DoesUserHaveDefaultCity(Context.User))
+ {
+ await ReplyAsync($"{uname}, you don't have a default city set.").DeleteAfterSeconds(30)!;
+ await Context.Message.DeleteAsync();
+ return;
+ }
+ await UserExtendedService.RemoveUserDefaultCity(Context.User);
+ await ReplyAsync($"{uname}, your default city has been removed.");
+ }
+
+ #endregion City Settings
}
diff --git a/DiscordBot/Modules/Weather/WeatherContainers.cs b/DiscordBot/Modules/Weather/WeatherContainers.cs
deleted file mode 100644
index 1f5d351c..00000000
--- a/DiscordBot/Modules/Weather/WeatherContainers.cs
+++ /dev/null
@@ -1,129 +0,0 @@
-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/Program.cs b/DiscordBot/Program.cs
index f0f22093..e5174511 100644
--- a/DiscordBot/Program.cs
+++ b/DiscordBot/Program.cs
@@ -2,9 +2,7 @@
using Discord.Commands;
using Discord.Interactions;
using Discord.WebSocket;
-using DiscordBot.Service;
using DiscordBot.Services;
-using DiscordBot.Services.Tips;
using DiscordBot.Settings;
using DiscordBot.Utils;
using Microsoft.Extensions.DependencyInjection;
@@ -14,26 +12,27 @@ namespace DiscordBot;
public class Program
{
- private bool _isInitialized = false;
+ private int _isInitialized = 0;
- private static Rules _rules;
- private static BotSettings _settings;
- private static UserSettings _userSettings;
- private DiscordSocketClient _client;
- private CommandHandlingService _commandHandlingService;
+ private static Rules _rules = null!;
+ private static BotSettings _settings = null!;
+ private static UserSettings _userSettings = null!;
+ private DiscordSocketClient _client = null!;
- private CommandService _commandService;
- private InteractionService _interactionService;
- private IServiceProvider _services;
+ private CommandService _commandService = null!;
+ private InteractionService _interactionService = null!;
+ private IServiceProvider _services = null!;
- private UnityHelpService _unityHelpService;
- private RecruitService _recruitService;
+ private readonly CancellationTokenSource _cts = new();
public static void Main(string[] args) =>
new Program().MainAsync().GetAwaiter().GetResult();
private async Task MainAsync()
{
+ Console.CancelKeyPress += (_, e) => { e.Cancel = true; _cts.Cancel(); };
+ AppDomain.CurrentDomain.ProcessExit += (_, _) => _cts.Cancel();
+
DeserializeSettings();
_client = new DiscordSocketClient(new DiscordSocketConfig
@@ -41,7 +40,12 @@ private async Task MainAsync()
LogLevel = LogSeverity.Verbose,
AlwaysDownloadUsers = true,
MessageCacheSize = 1024,
- GatewayIntents = GatewayIntents.All,
+ GatewayIntents = GatewayIntents.Guilds
+ | GatewayIntents.GuildMembers
+ | GatewayIntents.GuildMessages
+ | GatewayIntents.GuildMessageReactions
+ | GatewayIntents.DirectMessages
+ | GatewayIntents.MessageContent,
});
_client.Log += LoggingService.DiscordNetLogger;
@@ -52,7 +56,7 @@ private async Task MainAsync()
{
// Ready can be called additional times if the bot disconnects for long enough,
// so we need to make sure we only initialize commands and such for the bot once if it manages to re-establish connection
- if (_isInitialized) return Task.CompletedTask;
+ if (Interlocked.CompareExchange(ref _isInitialized, 1, 0) != 0) return Task.CompletedTask;
_interactionService = new InteractionService(_client);
_commandService = new CommandService(new CommandServiceConfig
@@ -62,29 +66,47 @@ private async Task MainAsync()
});
_services = ConfigureServices();
- _commandHandlingService = _services.GetRequiredService();
+ _services.GetRequiredService();
// 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;
- _unityHelpService = _services.GetRequiredService();
- _recruitService = _services.GetRequiredService();
- _services.GetRequiredService();
+ _services.GetRequiredService();
+ _services.GetRequiredService();
_services.GetRequiredService();
+ _services.GetRequiredService();
+ _services.GetRequiredService();
+ _services.GetRequiredService();
+ _services.GetRequiredService();
+ _services.GetRequiredService();
+ _services.GetRequiredService();
+ _services.GetRequiredService();
_services.GetRequiredService();
return Task.CompletedTask;
};
- await Task.Delay(-1);
+ try
+ {
+ await Task.Delay(Timeout.Infinite, _cts.Token);
+ }
+ catch (TaskCanceledException) { }
+
+ LoggingService.LogToConsole("Shutdown signal received, stopping...", ExtendedLogSeverity.Warning);
+ using var shutdownTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ try { await _client.StopAsync().WaitAsync(shutdownTimeout.Token); }
+ catch (OperationCanceledException) { LoggingService.LogToConsole("Client stop timed out.", ExtendedLogSeverity.Warning); }
+ LoggingService.LogToConsole("Bot stopped.", ExtendedLogSeverity.Positive);
}
private IServiceProvider ConfigureServices() =>
new ServiceCollection()
+ .AddHttpClient()
+ .AddSingleton()
+ .AddSingleton(_cts)
.AddSingleton(_settings)
.AddSingleton(_rules)
.AddSingleton(_userSettings)
@@ -94,13 +116,23 @@ private IServiceProvider ConfigureServices() =>
.AddSingleton()
.AddSingleton()
.AddSingleton()
- .AddSingleton()
- .AddSingleton()
- .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
.AddSingleton()
@@ -110,6 +142,7 @@ private IServiceProvider ConfigureServices() =>
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
.BuildServiceProvider();
@@ -119,5 +152,17 @@ private static void DeserializeSettings()
_settings = SerializeUtil.DeserializeFile(@"Settings/Settings.json");
_rules = SerializeUtil.DeserializeFile(@"Settings/Rules.json");
_userSettings = SerializeUtil.DeserializeFile(@"Settings/UserSettings.json");
+
+ var (errors, warnings) = _settings.Validate();
+ warnings.AddRange(_userSettings.Validate());
+ foreach (var warning in warnings)
+ Console.WriteLine($"[Settings Warning] {warning}");
+ if (errors.Count > 0)
+ {
+ foreach (var error in errors)
+ Console.Error.WriteLine($"[Settings Error] {error}");
+ throw new InvalidOperationException(
+ $"Bot settings validation failed with {errors.Count} error(s). See output above.");
+ }
}
}
diff --git a/DiscordBot/Services/Code/CodeCheckService.cs b/DiscordBot/Services/Code/CodeCheckService.cs
new file mode 100644
index 00000000..7dca07ac
--- /dev/null
+++ b/DiscordBot/Services/Code/CodeCheckService.cs
@@ -0,0 +1,140 @@
+using System.Text.RegularExpressions;
+using Discord.WebSocket;
+using DiscordBot.Settings;
+
+namespace DiscordBot.Services.Code;
+
+public class CodeCheckService
+{
+ private readonly DiscordSocketClient _client;
+ private readonly BotSettings _settings;
+ private readonly UpdateService _updateService;
+ private readonly CancellationToken _shutdownToken;
+
+ private readonly Regex _x3CodeBlock =
+ new("^(?`{3}((?\\w*?$)|$).+?({.+?}).+?`{3})", RegexOptions.Multiline | RegexOptions.Singleline);
+
+ private readonly Regex _x2CodeBlock = new("^(`{2})[^`].+?([^`]`{2})$", RegexOptions.Multiline);
+ private readonly List _codeBlockWarnPatterns;
+ private readonly short _maxCodeBlockLengthWarning = 800;
+
+ public readonly string CodeFormattingExample;
+ private readonly string _codeReminderFormattingExample;
+ public Dictionary CodeReminderCooldown { get; private set; }
+
+ public CodeCheckService(DiscordSocketClient client, BotSettings settings,
+ UpdateService updateService, CancellationTokenSource cts)
+ {
+ _client = client;
+ _settings = settings;
+ _updateService = updateService;
+ _shutdownToken = cts.Token;
+
+ CodeReminderCooldown = new Dictionary();
+
+ CodeFormattingExample = @"\`\`\`cs" + Environment.NewLine +
+ "Write your code on new line here." + Environment.NewLine +
+ @"\`\`\`" + Environment.NewLine;
+
+ _codeReminderFormattingExample = CodeFormattingExample + "*To disable these reminders use \"!disablecodetips\"*";
+
+ _codeBlockWarnPatterns = new List
+ {
+ new(".*?({.+?}).*?", RegexOptions.Singleline),
+ new("(if|else\\sif).?\\(.+\\).?($|\\/{2}|\\s?)", RegexOptions.Multiline),
+ new("^(\\w*.\\w*)\\(\\w*?\\);($|.?($|.*?\\/{2}))", RegexOptions.Multiline),
+ new("^.+? =.+?($|.*?\\/\\/)", RegexOptions.Multiline)
+ };
+
+ _client.MessageReceived += EventGuard.Guarded(CodeCheck, nameof(CodeCheck));
+
+ LoadData();
+ UpdateLoop();
+ }
+
+ private async void UpdateLoop()
+ {
+ try
+ {
+ while (!_shutdownToken.IsCancellationRequested)
+ {
+ await Task.Delay(10000, _shutdownToken);
+ SaveData();
+ }
+ }
+ catch (OperationCanceledException) { SaveData(); }
+ catch (Exception e)
+ {
+ LoggingService.LogToConsole($"[CodeCheckService.UpdateLoop] Unhandled exception: {e}", LogSeverity.Error);
+ }
+ }
+
+ private void LoadData()
+ {
+ var data = _updateService.GetUserData();
+ CodeReminderCooldown = data.CodeReminderCooldown ?? new Dictionary();
+ }
+
+ private void SaveData()
+ {
+ var data = new UserData
+ {
+ CodeReminderCooldown = CodeReminderCooldown
+ };
+ _updateService.SetUserData(data);
+ }
+
+ public async Task CodeCheck(SocketMessage messageParam)
+ {
+ if (messageParam.Author.IsBot || messageParam.Channel.Id == _settings.Channels.General.Id)
+ return;
+
+ if (messageParam.Content.Length < 200)
+ return;
+
+ var userId = messageParam.Author.Id;
+
+ if (!CodeReminderCooldown.HasUser(userId))
+ {
+ var content = messageParam.Content;
+
+ var foundTrippleCodeBlock = _x3CodeBlock.Match(content);
+ if (foundTrippleCodeBlock.Groups["CS"].Length > 0)
+ return;
+ if (foundTrippleCodeBlock.Groups["CodeBlock"].Success)
+ {
+ await (messageParam.Channel.SendMessageAsync(
+ $"{messageParam.Author.Mention} when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{_codeReminderFormattingExample}")
+ .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask);
+ return;
+ }
+
+ var foundDoubleCodeBlock = _x2CodeBlock.Match(content).Success;
+
+ int hits = 0;
+ foreach (var regex in _codeBlockWarnPatterns)
+ {
+ hits += regex.Match(content).Captures.Count;
+ }
+
+ if (!foundDoubleCodeBlock && hits >= 3)
+ {
+ await (messageParam.Channel.SendMessageAsync(
+ $"{messageParam.Author.Mention} are you sharing C# scripts? Remember to use codeblocks to help readability!\n{_codeReminderFormattingExample}")
+ .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask);
+ if (content.Length > _maxCodeBlockLengthWarning)
+ {
+ await (messageParam.Channel.SendMessageAsync(
+ "The code you're sharing is quite long, maybe use a free service like and share the link here instead.")
+ .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask);
+ }
+ }
+ else if (foundDoubleCodeBlock && hits > 0)
+ {
+ await (messageParam.Channel.SendMessageAsync(
+ $"{messageParam.Author.Mention} when using code blocks remember to use \\`\\`\\`cs as this will help improve readability for C# scripts.\n{_codeReminderFormattingExample}")
+ .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask);
+ }
+ }
+ }
+}
diff --git a/DiscordBot/Services/Code/Tips/Components/Tip.cs b/DiscordBot/Services/Code/Tips/Components/Tip.cs
new file mode 100644
index 00000000..c8032ebf
--- /dev/null
+++ b/DiscordBot/Services/Code/Tips/Components/Tip.cs
@@ -0,0 +1,12 @@
+using Discord;
+
+namespace DiscordBot.Services.Code.Tips.Components;
+
+public class Tip : IEntity
+{
+ public ulong Id { get; set; }
+ public string Content { get; set; } = string.Empty;
+ public List Keywords { get; set; } = [];
+ public List ImagePaths { get; set; } = [];
+ public int Requests { get; set; }
+}
diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Code/Tips/TipService.cs
similarity index 89%
rename from DiscordBot/Services/Tips/TipService.cs
rename to DiscordBot/Services/Code/Tips/TipService.cs
index 24cd0404..718ac914 100644
--- a/DiscordBot/Services/Tips/TipService.cs
+++ b/DiscordBot/Services/Code/Tips/TipService.cs
@@ -5,31 +5,32 @@
using System.Net.Http;
using Discord;
using Discord.WebSocket;
-using DiscordBot.Services.Tips.Components;
using DiscordBot.Settings;
using Newtonsoft.Json;
-namespace DiscordBot.Services.Tips;
+namespace DiscordBot.Services.Code.Tips;
public class TipService
{
- private const string ServiceName = "TipService";
+ private const string ServiceName = "TipService";
private const string DatabaseName = "tips.json";
private readonly BotSettings _settings;
private readonly ILoggingService _loggingService;
- private readonly string _imageDirectory;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly string _imageDirectory = null!;
private ConcurrentDictionary> _tips = new();
private bool _isRunning = false;
private bool _readOnly = false;
- private Regex keywordPattern = null;
+ private Regex? keywordPattern = null;
- public TipService(BotSettings settings, ILoggingService loggingService)
+ public TipService(BotSettings settings, ILoggingService loggingService, IHttpClientFactory httpClientFactory)
{
_settings = settings;
_loggingService = loggingService;
+ _httpClientFactory = httpClientFactory;
if (string.IsNullOrEmpty(_settings.ServerRootPath))
{
@@ -37,25 +38,25 @@ public TipService(BotSettings settings, ILoggingService loggingService)
_isRunning = false;
return;
}
-
- if (string.IsNullOrEmpty(_settings.TipImageDirectory))
+
+ if (string.IsNullOrEmpty(_settings.UnityHelp.TipImageDirectory))
{
_loggingService.LogAction($"[{ServiceName}] TipImageDirectory not set, service will not run.", ExtendedLogSeverity.Warning);
_isRunning = false;
return;
}
- _imageDirectory = Path.Combine(_settings.ServerRootPath, _settings.TipImageDirectory);
+ _imageDirectory = Path.Combine(_settings.ServerRootPath, _settings.UnityHelp.TipImageDirectory);
Initialize();
}
-
+
private void Initialize()
{
if (_isRunning) return;
_readOnly = false;
- var jsonPath = GetTipPath(DatabaseName);;
+ var jsonPath = GetTipPath(DatabaseName); ;
if (!Directory.Exists(_imageDirectory))
{
_loggingService.LogAction($"[{ServiceName}] Tip directory {_imageDirectory} did not exist.", ExtendedLogSeverity.Info);
@@ -65,14 +66,14 @@ private void Initialize()
else
{
var directorySize = new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Sum(file => file.Length);
- if (directorySize > _settings.TipMaxDirectoryFileSize)
+ if (directorySize > _settings.UnityHelp.TipMaxDirectoryFileSize)
{
- _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, exceeding the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning);
+ _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, exceeding the limit of {_settings.UnityHelp.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning);
_readOnly = true;
}
else
{
- _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, within the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB.", ExtendedLogSeverity.Info);
+ _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, within the limit of {_settings.UnityHelp.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB.", ExtendedLogSeverity.Info);
_loggingService.LogAction($"[{ServiceName}] Tip directory contains {new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Count()} files.",
ExtendedLogSeverity.Info);
}
@@ -102,7 +103,7 @@ private bool IsValidTipKeyword(string keyword)
private bool IsValidTipAttachment(IAttachment attachment)
{
- if (attachment.Size > _settings.TipMaxImageFileSize)
+ if (attachment.Size > _settings.UnityHelp.TipMaxImageFileSize)
return false;
// Discord-friendly attachment image file formats only
@@ -154,11 +155,11 @@ public async Task AddTip(IUserMessage message, string keywords, string content)
attachment.Filename.Substring(attachment.Filename.LastIndexOf('.'));
var filePath = GetTipPath(newFileName);
- using var client = new HttpClient();
+ using var client = _httpClientFactory.CreateClient();
await using var stream = await client.GetStreamAsync(attachment.Url);
await using var file = File.Create(filePath);
await stream.CopyToAsync(file);
-
+
imagePaths.Add(newFileName);
}
@@ -258,19 +259,19 @@ public async Task ReplaceTip(IUserMessage message, Tip tip, string content)
return;
}
- RemoveTip(message, tip);
- AddTip(message, string.Join(",", tip.Keywords), content);
+ await RemoveTip(message, tip);
+ await AddTip(message, string.Join(",", tip.Keywords), content);
// REVIEW: causes two CommitTipDatabase calls
}
public async Task ReloadTipDatabase()
{
- var jsonPath = GetTipPath(DatabaseName);;
+ var jsonPath = GetTipPath(DatabaseName); ;
if (File.Exists(jsonPath))
{
- var json = File.ReadAllText(jsonPath);
- _tips = JsonConvert.DeserializeObject>>(json);
- _loggingService.LogAction(
+ var json = File.ReadAllText(jsonPath);
+ _tips = JsonConvert.DeserializeObject>>(json)!;
+ _ = _loggingService.LogAction(
$"[{ServiceName}] Tip index has {_tips.Count} keywords.",
ExtendedLogSeverity.Info);
}
@@ -302,7 +303,7 @@ public async Task ReloadTipDatabase()
if (touched)
{
- _loggingService.LogAction(
+ _ = _loggingService.LogAction(
$"[{ServiceName}] Tip index was de-duplicated.",
ExtendedLogSeverity.Info);
await CommitTipDatabase();
@@ -323,12 +324,7 @@ await File.WriteAllTextAsync(jsonPath,
settings));
}
- public string DumpTipDatabase()
- {
- return JsonConvert.SerializeObject(_tips);
- }
-
- public Tip GetTip(ulong Id)
+ public Tip? GetTip(ulong Id)
{
foreach (var kvp in _tips)
foreach (var tip in kvp.Value)
diff --git a/DiscordBot/Services/Code/Unity/FeedService.cs b/DiscordBot/Services/Code/Unity/FeedService.cs
new file mode 100644
index 00000000..488f6d19
--- /dev/null
+++ b/DiscordBot/Services/Code/Unity/FeedService.cs
@@ -0,0 +1,221 @@
+using System.IO;
+using System.ServiceModel.Syndication;
+using System.Xml;
+using Discord.WebSocket;
+using DiscordBot.Settings;
+using DiscordBot.Utils;
+
+namespace DiscordBot.Services.Code.Unity;
+
+public class FeedService
+{
+ private const string ServiceName = "FeedService";
+ private readonly DiscordSocketClient _client;
+
+ private readonly BotSettings _settings;
+ private readonly ILoggingService _logging;
+ private readonly IWebClient _webClient;
+ private readonly ReleaseNotesParser _releaseNotesParser;
+
+ #region Configurable Settings
+
+ private const int MaxFeedLengthBuffer = 400;
+ #region News Feed Config
+
+ private class ForumNewsFeed
+ {
+ public string TitleFormat { get; set; } = null!;
+ public string Url { get; set; } = null!;
+ public List IncludeTags { get; set; } = null!;
+ 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" },
+ IsRelease = true
+ };
+ private readonly ForumNewsFeed _releaseNews = new()
+ {
+ TitleFormat = "New Release - {0}",
+ Url = "https://unity3d.com/unity/releases.xml",
+ IncludeTags = new() { "New Release" },
+ IsRelease = true
+ };
+ private readonly ForumNewsFeed _blogNews = new()
+ {
+ TitleFormat = "Blog - {0}",
+ Url = "https://blogs.unity3d.com/feed/",
+ IncludeTags = new() { "Unity Blog" },
+ IsRelease = false
+ };
+
+ #endregion // News Feed Config
+
+ // We store the title of the last 40 posts, and check against them to prevent duplicate posts
+ private const int MaxHistoryCheck = 40;
+ private readonly List _postedFeeds = new(MaxHistoryCheck);
+
+ private const int MaximumCheck = 3;
+ private const ThreadArchiveDuration ForumArchiveDuration = ThreadArchiveDuration.OneWeek;
+
+ #endregion // Configurable Settings
+
+ public FeedService(DiscordSocketClient client, BotSettings settings, ILoggingService logging, IWebClient webClient, ReleaseNotesParser releaseNotesParser)
+ {
+ _client = client;
+ _settings = settings;
+ _logging = logging;
+ _webClient = webClient;
+ _releaseNotesParser = releaseNotesParser;
+ }
+
+ private async Task GetFeedData(string url)
+ {
+ SyndicationFeed? feed = null;
+ try
+ {
+ var content = await _webClient.GetXMLContent(url);
+ var reader = XmlReader.Create(new StringReader(content));
+ feed = SyndicationFeed.Load(reader);
+ }
+ catch (Exception e)
+ {
+ LoggingService.LogToConsole($"[{ServiceName} Feed failure: {e.ToString()}", ExtendedLogSeverity.LowWarning);
+ }
+
+ // Return the feed, empty feed if null to prevent additional checks for null on return
+ return feed ??= new SyndicationFeed();
+ }
+
+ #region Feed Handlers
+
+ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong channelId, ulong? roleId)
+ {
+ try
+ {
+ var feed = await GetFeedData(newsFeed.Url);
+ if (_client.GetChannel(channelId) is not IForumChannel channel)
+ {
+ await _logging.LogAction($"[{ServiceName}] Error: Channel {channelId} not found", ExtendedLogSeverity.Error);
+ return;
+ }
+ foreach (var item in feed.Items.Take(MaximumCheck))
+ {
+ if (feedData.PostedIds.Contains(item.Id))
+ continue;
+ feedData.PostedIds.Add(item.Id);
+
+ // Title
+ var newsTitle = string.Format(newsFeed.TitleFormat, item.Title.Text);
+ if (newsTitle.Length > 90)
+ newsTitle = newsTitle[..90] + "...";
+
+ // Confirm we haven't posted this title before
+ if (_postedFeeds.Contains(newsTitle))
+ continue;
+ _postedFeeds.Add(newsTitle);
+ if (_postedFeeds.Count > MaxHistoryCheck)
+ _postedFeeds.RemoveAt(0);
+
+ // Message
+ var newsContent = string.Empty;
+ List releaseNotes = new();
+ if (!newsFeed.IsRelease)
+ newsContent = GetSummary(newsFeed, item);
+ else
+ {
+ try
+ {
+ releaseNotes = _releaseNotesParser.Parse(item.Summary.Text);
+ }
+ catch (Exception e)
+ {
+ _ = _logging.LogChannelAndFile($"[{ServiceName}] Error generating release notes: {e}\nLikely updated format.", ExtendedLogSeverity.Warning);
+ releaseNotes = new List { "No release notes found" };
+ }
+ 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 += $"\n{role.Mention}";
+ // 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);
+ 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++)
+ {
+ if (releaseNotes[i].Length == 0)
+ continue;
+ await post.SendMessageAsync(releaseNotes[i].SanitizeEveryoneHereMentions());
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ 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 = global::DiscordBot.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;
+ }
+
+ #endregion // Feed Handlers
+
+ #region Public Feed Actions
+
+ public async Task CheckUnityBetasAsync(FeedData feedData)
+ {
+ await HandleFeed(feedData, _betaNews, _settings.Channels.UnityReleases.Id, _settings.Roles.SubsReleases);
+ }
+
+ public async Task CheckUnityReleasesAsync(FeedData feedData)
+ {
+ await HandleFeed(feedData, _releaseNews, _settings.Channels.UnityReleases.Id, _settings.Roles.SubsReleases);
+ }
+
+ public async Task CheckUnityBlogAsync(FeedData feedData)
+ {
+ await HandleFeed(feedData, _blogNews, _settings.Channels.UnityNews.Id, _settings.Roles.SubsNews);
+ }
+
+ #endregion // Feed Actions
+}
\ No newline at end of file
diff --git a/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs b/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs
new file mode 100644
index 00000000..3391c829
--- /dev/null
+++ b/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs
@@ -0,0 +1,120 @@
+using HtmlAgilityPack;
+
+namespace DiscordBot.Services.Code.Unity;
+
+public class ReleaseNotesParser
+{
+ private const int MaxFeedLengthBuffer = 400;
+
+ public List Parse(string summaryHtml)
+ {
+ var htmlDoc = new HtmlDocument();
+ summaryHtml = summaryHtml.Replace("→", "->");
+ htmlDoc.LoadHtml(summaryHtml);
+
+ var summaryNode = htmlDoc.DocumentNode.SelectSingleNode("//div[@class='release-notes']");
+ if (summaryNode == null)
+ return new List { "No release notes found" };
+
+ var knownIssueNode = FindH3Sibling(summaryNode, "Known Issues");
+ var entriesSinceNode = summaryNode.ChildNodes
+ .FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Entries since"));
+
+ var featuresNode = FindH4Sibling(summaryNode, "Features");
+ var improvementsNode = FindH4Sibling(summaryNode, "Improvements");
+ var apiChangesNode = FindH4Sibling(summaryNode, "API Changes");
+ var changesNode = FindH4Sibling(summaryNode, "Changes");
+ var fixesNode = FindH4Sibling(summaryNode, "Fixes");
+ var packagesUpdatedNode = summaryNode.ChildNodes
+ .FirstOrDefault(x => x.Name == "h4" && x.InnerText.ToLower().Contains("package changes"))
+ ?.NextSibling?.NextSibling?.NextSibling;
+
+ var summary = "**Summary**\n";
+ summary += GetNodeLiCountString("Known Issues", knownIssueNode?.NextSibling);
+
+ if (entriesSinceNode != null)
+ summary += $"__{entriesSinceNode.InnerText}__\n\n";
+
+ 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);
+
+ var releaseNotes = new List
+ {
+ BuildSection("Packages Updated", packagesUpdatedNode, summary),
+ BuildSection("Features", featuresNode),
+ BuildSection("Improvements", improvementsNode, "", 1000),
+ BuildSection("API Changes", apiChangesNode),
+ BuildSection("Changes", changesNode),
+ BuildSection("Fixes", fixesNode, ""),
+ BuildSection("Known Issues", knownIssueNode, "", 1200)
+ };
+
+ return releaseNotes;
+ }
+
+ private static HtmlNode? FindH3Sibling(HtmlNode parent, string text)
+ {
+ return parent.ChildNodes
+ .FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains(text))
+ ?.NextSibling;
+ }
+
+ private static HtmlNode? FindH4Sibling(HtmlNode parent, string text)
+ {
+ return parent.ChildNodes
+ .FirstOrDefault(x => x.Name == "h4" && x.InnerText == text)
+ ?.NextSibling;
+ }
+
+ private string BuildSection(string title, HtmlNode? node, string contents = "",
+ int maxLength = Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer)
+ {
+ if (node == null)
+ return string.Empty;
+
+ 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 extraText = string.Empty;
+ if (needsExtraProcessing)
+ {
+ var nodeContents = feature.ChildNodes[0];
+ nodeContents.InnerHtml = nodeContents.InnerHtml.Replace("\n", " ");
+
+ var linkNode = nodeContents.SelectSingleNode("a");
+ if (linkNode != null)
+ {
+ nodeContents = nodeContents.RemoveChild(linkNode);
+ feature.InnerHtml = feature.InnerHtml.Replace("()", "");
+ extraText = $" ([{linkNode.InnerText}](<{linkNode.Attributes["href"].Value}>))";
+ }
+ }
+
+ summary += $"- {feature.InnerText}{extraText}\n";
+ if (summary.Length > maxLength)
+ {
+ var lastLine = summary[..maxLength].LastIndexOf('\n');
+ summary = summary[..lastLine] + $"\n{title} truncated...\n";
+ return summary;
+ }
+ }
+
+ return summary;
+ }
+
+ private static 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";
+ }
+}
diff --git a/DiscordBot/Services/Code/Unity/UnityDocParser.cs b/DiscordBot/Services/Code/Unity/UnityDocParser.cs
new file mode 100644
index 00000000..6630edda
--- /dev/null
+++ b/DiscordBot/Services/Code/Unity/UnityDocParser.cs
@@ -0,0 +1,32 @@
+using DiscordBot.Domain;
+using HtmlAgilityPack;
+
+namespace DiscordBot.Services.Code.Unity;
+
+public static class UnityDocParser
+{
+ public static DocEntry[] ConvertJsToArray(string data, bool isManual)
+ {
+ var list = new List();
+ string pagesInput;
+
+ if (isManual)
+ {
+ pagesInput = data.Split("info = [")[0].Split("pages=")[1];
+ pagesInput = pagesInput[2..^2];
+ }
+ else
+ {
+ pagesInput = data.Split("info =")[0];
+ pagesInput = pagesInput[63..^2];
+ }
+
+ foreach (var s in pagesInput.Split("],["))
+ {
+ var ps = s.Split(",");
+ list.Add(new DocEntry(ps[0].Replace("\"", ""), ps[1].Replace("\"", "")));
+ }
+
+ return list.ToArray();
+ }
+}
diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs
similarity index 97%
rename from DiscordBot/Services/UnityHelp/CannedResponseService.cs
rename to DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs
index ca826aba..7ea74154 100644
--- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs
+++ b/DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs
@@ -1,11 +1,9 @@
-namespace DiscordBot.Service;
+namespace DiscordBot.Services.Code.Unity.UnityHelp;
public class CannedResponseService
{
- private const string ServiceName = "CannedResponseService";
-
#region Configuration
-
+
public enum CannedResponseType
{
HowToAsk,
@@ -55,7 +53,7 @@ public enum CannedHelp
GameTooBig = CannedResponseType.GameTooBig,
HowToGoogle = CannedResponseType.HowToGoogle,
}
-
+
public enum CannedResources
{
Programming = CannedResponseType.Programming,
@@ -70,11 +68,11 @@ public enum CannedResources
// 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",
@@ -84,7 +82,7 @@ public enum CannedResources
"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",
@@ -102,14 +100,14 @@ public enum CannedResources
"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?*** 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",
@@ -120,7 +118,7 @@ public enum CannedResources
"- If you've tried something, tell us what you tried",
Url = "https://xyproblem.info/",
};
-
+
private readonly EmbedBuilder _gameTooBigEmbed = new EmbedBuilder
{
Title = "Game Too Big",
@@ -138,7 +136,7 @@ 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",
@@ -155,11 +153,11 @@ public enum CannedResources
"[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), " +
+ "[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",
@@ -172,7 +170,7 @@ public enum CannedResources
"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",
@@ -185,11 +183,11 @@ public enum CannedResources
"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",
@@ -202,7 +200,7 @@ public enum CannedResources
"- 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",
@@ -212,7 +210,7 @@ public enum CannedResources
"- 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",
@@ -222,7 +220,7 @@ public enum CannedResources
"- 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",
@@ -231,7 +229,7 @@ public enum CannedResources
"- 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",
@@ -242,7 +240,7 @@ public enum CannedResources
"- 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",
@@ -254,25 +252,25 @@ public enum CannedResources
"- Iconography: [Flaticon](https://www.flaticon.com/)\n" +
"- Free Icons: [Icon Monstr](https://iconmonstr.com/)"
};
-
+
#endregion
-
+
#endregion // Configuration
-
- public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor = null)
+
+ public EmbedBuilder? GetCannedResponse(CannedResponseType type, IUser? requestor = null)
{
var embed = GetUnbuiltCannedResponse(type);
if (embed == null)
return null;
-
+
if (requestor != null)
embed.FooterRequestedBy(requestor);
embed.WithColor(_defaultEmbedColor);
-
+
return embed;
}
-
- public EmbedBuilder GetUnbuiltCannedResponse(CannedResponseType type)
+
+ public EmbedBuilder? GetUnbuiltCannedResponse(CannedResponseType type)
{
return type switch
{
diff --git a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs b/DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs
similarity index 88%
rename from DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs
rename to DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs
index 48c7677a..2ab19a3b 100644
--- a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs
+++ b/DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs
@@ -1,4 +1,4 @@
-namespace DiscordBot.Services.UnityHelp;
+namespace DiscordBot.Services.Code.Unity.UnityHelp;
public enum HelpMessageType
{
@@ -12,7 +12,7 @@ public class HelpBotMessage
{
public ulong MessageId { get; set; }
public HelpMessageType Type { get; set; }
-
+
public HelpBotMessage(ulong messageId, HelpMessageType type)
{
MessageId = messageId;
diff --git a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs b/DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs
similarity index 89%
rename from DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs
rename to DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs
index a3779b3a..de98d804 100644
--- a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs
+++ b/DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs
@@ -1,4 +1,4 @@
-namespace DiscordBot.Services.UnityHelp;
+namespace DiscordBot.Services.Code.Unity.UnityHelp;
public class ThreadContainer
{
@@ -10,17 +10,17 @@ public class ThreadContainer
public bool IsResolved { get; set; } = false;
public bool HasInteraction { get; set; } = false;
-
-
+
+
public ulong BotsLastMessage { get; set; }
- public CancellationTokenSource CancellationToken { get; set; }
+ public CancellationTokenSource? CancellationToken { get; set; }
public DateTime ExpectedShutdownTime { get; set; }
-
+
///
/// Any message the bot sends that could need to be tracked/deleted later is stored here.
///
public Dictionary HelpMessages { get; set; } = new();
-
+
public bool HasMessage(HelpMessageType type) => HelpMessages.ContainsKey(type);
public ulong GetMessageId(HelpMessageType type) => HelpMessages[type].MessageId;
public void AddMessage(HelpMessageType type, ulong messageId) => HelpMessages.Add(type, new HelpBotMessage(messageId, type));
diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs
similarity index 91%
rename from DiscordBot/Services/UnityHelp/UnityHelpService.cs
rename to DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs
index 2ae6b3c4..18f6d6a6 100644
--- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs
+++ b/DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs
@@ -1,8 +1,7 @@
using Discord.WebSocket;
using DiscordBot.Settings;
-using DiscordBot.Services.UnityHelp;
-namespace DiscordBot.Services;
+namespace DiscordBot.Services.Code.Unity.UnityHelp;
// TODO : (James) Better Slash Command Support
@@ -11,13 +10,12 @@ public class UnityHelpService
private const string ServiceName = "UnityHelpService";
private readonly DiscordSocketClient _client;
- private readonly ILoggingService _logging;
private SocketRole ModeratorRole { get; set; }
-
+
#region Configuration
-
+
private static readonly Emoji ThumbUpEmoji = new Emoji("👍");
-
+
private const int TimeBeforeClosedForResolvedTag = 10;
private readonly Embed _resolvedWarnOfPendingCloseEmbedHasPin = new EmbedBuilder()
.WithTitle($"Issue Resolved")
@@ -46,7 +44,7 @@ public class UnityHelpService
.WithColor(Color.LightOrange)
.Build();
private const int StealthDeleteTime = 60 * 5;
-
+
private readonly Embed _noAppliedTagsEmbed = new EmbedBuilder()
.WithTitle("Warning: No Tags Applied")
.WithDescription($"Consider adding tags to your question to help others find it!\n" +
@@ -66,37 +64,36 @@ public class UnityHelpService
.WithFooter("Be descriptive of the problem!")
.WithColor(Color.LightOrange)
.Build();
-
+
#endregion // Configuration
#region Extra Details
-
- private readonly IForumChannel _helpChannel;
-
+
+ private readonly IForumChannel _helpChannel = null!;
+
private readonly ForumTag _resolvedForumTag;
#endregion // Extra Details
- public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggingService logging)
+ public UnityHelpService(DiscordSocketClient client, BotSettings settings)
{
_client = client;
- _logging = logging;
-
- ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId);
- if (!settings.UnityHelpBabySitterEnabled)
+ ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.Roles.Moderator);
+
+ if (!settings.UnityHelp.BabySitterEnabled)
{
- LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelpBabySitterEnabled));
+ LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelp.BabySitterEnabled));
return;
}
-
- // get the help channel settings.GenericHelpChannel
- _helpChannel = _client.GetChannel(settings.GenericHelpChannel.Id) as IForumChannel;
+
+ // get the help channel settings.Channels.GenericHelp
+ _helpChannel = (_client.GetChannel(settings.Channels.GenericHelp.Id) as IForumChannel)!;
if (_helpChannel == null)
{
LoggingService.LogToConsole($"[{ServiceName}] Help channel not found", LogSeverity.Error);
}
- var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.TagUnitHelpResolvedTag));
+ var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.UnityHelp.TagResolved));
if (resolvedTag == null || resolvedTag.Id <= 0)
LoggingService.LogToConsole($"[{ServiceName}] Resolved tag not found", LogSeverity.Error);
_resolvedForumTag = resolvedTag;
@@ -107,15 +104,15 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggi
_client.ThreadCreated += GatewayOnThreadCreated;
_client.ThreadUpdated += GatewayOnThreadUpdated;
_client.ThreadDeleted += GatewayOnThreadDeleted;
-
+
_client.ThreadMemberJoined += GatewayOnThreadMemberJoinedThread;
_client.ThreadMemberLeft += GatewayOnThreadMemberLeftThread;
-
+
_client.MessageReceived += GatewayOnMessageReceived;
_client.MessageUpdated += GatewayOnMessageUpdated;
Task.Run(LoadActiveThreads);
-
+
LoggingService.LogServiceEnabled(ServiceName);
}
@@ -139,13 +136,11 @@ private async Task LoadActiveThreads()
if (threadContainer.IsResolved)
{
// Run in new task so we don't block the other threads from being processed
-#pragma warning disable CS4014
- Task.Run(() => CloseThreadInTime(threadContainer, string.Empty,
+ CloseThreadInTime(threadContainer, string.Empty,
TimeBeforeClosedForResolvedTag,
(threadContainer.PinnedAnswer != 0
? _resolvedWarnOfPendingCloseEmbedHasPin
- : _resolvedWarnOfPendingCloseEmbedNoPin)));
-#pragma warning restore CS4014
+ : _resolvedWarnOfPendingCloseEmbedNoPin)).SafeFireAndForget(ServiceName);
}
else
{
@@ -155,12 +150,12 @@ private async Task LoadActiveThreads()
}
#region Thread Tracking
-
+
// Threads we're currently tracking
private readonly Dictionary _activeThreads = new();
#region Thread Creation
-
+
private async Task OnThreadCreated(SocketThreadChannel thread)
{
ThreadContainer container = new()
@@ -170,9 +165,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread)
Owner = thread.Owner.Id,
};
_activeThreads.Add(thread.Id, container);
-
- bool warnHelpTitle = false;
-
+
// Check message length and inform user if too short
var firstMessage = (await thread.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault();
container.FirstUserMessage = firstMessage!.Id;
@@ -182,7 +175,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread)
container.AddMessage(HelpMessageType.QuestionLength, botResponse.Id);
// container.WarningMessage = botResponse.Id;
}
-
+
var threadTitle = thread.Name;
if (threadTitle.IsAllCaps())
{
@@ -190,9 +183,6 @@ private async Task OnThreadCreated(SocketThreadChannel thread)
}
await thread.ModifyAsync(x => x.Name = threadTitle.ToCapitalizeFirstLetter());
- if (thread.Name.Contains(" help", StringComparison.CurrentCultureIgnoreCase))
- warnHelpTitle = true;
-
// If not tags attached, let them know they should add some
if (thread.AppliedTags.Count == 0)
{
@@ -205,7 +195,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread)
// Sets up the thread to be closed after a certain amount of time (This will quickly be removed if anyone interacts with the thread)
await StealthDeleteThreadInTime(container);
}
-
+
private Task GatewayOnThreadCreated(SocketThreadChannel thread)
{
if (!thread.IsThreadInChannel(_helpChannel.Id))
@@ -220,13 +210,13 @@ private Task GatewayOnThreadCreated(SocketThreadChannel thread)
// 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));
-
+ OnThreadCreated(thread).SafeFireAndForget(ServiceName);
+
return Task.CompletedTask;
}
-
+
#endregion // Thread Creation
#region Thread Update
@@ -278,7 +268,7 @@ private async Task OnThreadUpdated(SocketThreadChannel before, SocketThreadChann
//
// }
}
-
+
private async Task GatewayOnThreadUpdated(Cacheable before, SocketThreadChannel after)
{
if (!after.IsThreadInChannel(_helpChannel.Id))
@@ -296,16 +286,14 @@ private async Task GatewayOnThreadUpdated(Cacheable
}
LoggingService.DebugLog($"[{ServiceName}] Thread Updated: {after.Id} - {after.Name}", LogSeverity.Debug);
-
-#pragma warning disable CS4014
- Task.Run(() => OnThreadUpdated(beforeThread, afterThread));
-#pragma warning restore CS4014
+
+ OnThreadUpdated(beforeThread, afterThread).SafeFireAndForget(ServiceName);
}
-
+
#endregion // Thread Update
#region Thread Deleted
-
+
private async Task OnThreadDeleted(SocketThreadChannel channel)
{
await EndThreadTracking(channel.Id);
@@ -315,24 +303,22 @@ private async Task GatewayOnThreadDeleted(Cacheable
{
if (!_activeThreads.ContainsKey(threadId.Id))
return;
-
+
LoggingService.DebugLog($"[{ServiceName}] Thread Deleted: {threadId.Id}", LogSeverity.Debug);
var thread = await threadId.GetOrDownloadAsync();
-#pragma warning disable CS4014
- Task.Run(() => OnThreadDeleted(thread));
-#pragma warning restore CS4014
+ OnThreadDeleted(thread).SafeFireAndForget(ServiceName);
}
-
+
#endregion // Thread Deleted
#region User Joins/Leaves Thread
-
+
private Task GatewayOnThreadMemberJoinedThread(SocketThreadUser user)
{
if (user.IsUserBotOrWebhook())
return Task.CompletedTask;
-
+
if (!user.Thread.IsThreadInChannel(_helpChannel.Id))
return Task.CompletedTask;
if (!_activeThreads.TryGetValue(user.Thread.Id, out var thread))
@@ -346,27 +332,25 @@ private Task GatewayOnThreadMemberLeftThread(SocketThreadUser user)
{
if (!user.Thread.IsThreadInChannel(_helpChannel.Id))
return Task.CompletedTask;
-
+
return Task.CompletedTask;
// TODO : (James) Check if user was author? If so, close thread?
}
-
+
#endregion // User Joins/Leaves Thread
#region Message Received
-
+
private async Task OnMessageReceived(SocketMessage message)
{
var thread = _activeThreads[message.Channel.Id];
-
+
thread.LatestUserMessage = message.Id;
// If Author is only one who has interacted with the thread, we don't need to update anything else
if (!thread.HasInteraction && message.Author.Id == thread.Owner)
{
-#pragma warning disable CS4014
- Task.Run(() => StealthDeleteThreadInTime(thread));
-#pragma warning restore CS4014
+ StealthDeleteThreadInTime(thread).SafeFireAndForget(ServiceName);
return;
}
@@ -382,7 +366,7 @@ private async Task OnMessageReceived(SocketMessage message)
await RequestThreadShutdownInTime(thread, HasResponseMessageRequestClose + HasResponseExtraMessage, HasResponseIdleTimeOtherUser);
}
}
-
+
private Task GatewayOnMessageReceived(SocketMessage message)
{
if (!message.Channel.IsThreadInChannel(_helpChannel.Id))
@@ -391,16 +375,16 @@ private Task GatewayOnMessageReceived(SocketMessage message)
return Task.CompletedTask;
if (!_activeThreads.TryGetValue(message.Channel.Id, out var thread))
return Task.CompletedTask;
-
+
LoggingService.DebugLog($"[{ServiceName}] Help Message Received: {message.Id} - {message.Content}", LogSeverity.Debug);
- Task.Run(() => OnMessageReceived(message));
+ OnMessageReceived(message).SafeFireAndForget(ServiceName);
return Task.CompletedTask;
}
private async Task OnMessageUpdated(IMessage before, IMessage after, SocketThreadChannel channel)
{
var thread = _activeThreads[channel.Id];
-
+
if (thread.HasMessage(HelpMessageType.QuestionLength) && before.Id == thread.FirstUserMessage)
{
if (after.Content.Length > MinimumLengthMessage)
@@ -412,7 +396,7 @@ private async Task OnMessageUpdated(IMessage before, IMessage after, SocketThrea
}
}
}
-
+
private async Task GatewayOnMessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel)
{
if (channel is not SocketThreadChannel threadChannel)
@@ -421,22 +405,20 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So
return;
if (after.Author.IsUserBotOrWebhook())
return;
-
+
if (!_activeThreads.TryGetValue(channel.Id, out var thread))
return;
-
+
// This is done a bit late as we may need to check message from other authors
if (thread.Owner != after.Author.Id)
return;
-
+
var beforeMsg = await before.GetOrDownloadAsync();
if (beforeMsg == null)
return;
LoggingService.DebugLog($"[{ServiceName}] Help Message Updated: {after.Id} - {after.Content}", LogSeverity.Debug);
-#pragma warning disable CS4014
- Task.Run(() => OnMessageUpdated(beforeMsg, after, channel as SocketThreadChannel));
-#pragma warning restore CS4014
+ OnMessageUpdated(beforeMsg, after, (channel as SocketThreadChannel)!).SafeFireAndForget(ServiceName);
if (after.Reactions.ContainsKey(CloseEmoji))
{
@@ -450,7 +432,7 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So
}
#endregion // Message Received
-
+
#endregion // Thread Tracking
#region Event Handlers
@@ -465,29 +447,22 @@ private async Task OnReactionAdded(Cacheable messageCache,
if (message == null || message.Author.Id != _client.CurrentUser.Id)
return;
-#pragma warning disable CS4014
- Task.Run(async () =>
-#pragma warning restore CS4014
- {
- // Check the owner is the one reacting
- var threadOwner = channel.Owner.Id;
- if (reaction.UserId != threadOwner)
- return;
+ if (reaction.UserId != channel.Owner.Id)
+ return;
- await CloseThread(channel, true);
- });
+ CloseThread(channel, true).SafeFireAndForget(ServiceName);
}
-
+
public async Task OnUserRequestChannelClose(IUser user, SocketThreadChannel channel)
{
if (channel.ParentChannel.Id != _helpChannel.Id)
return string.Empty;
if (!_activeThreads.TryGetValue(channel.Id, out var thread))
return string.Empty;
-
+
if (thread.Owner != user.Id)
return string.Empty;
-
+
await CloseThread(channel, true);
return "Your thread has been closed.";
}
@@ -496,16 +471,16 @@ public async Task OnUserRequestChannelClose(IUser user, SocketThreadChan
#region Bulk Behaviour Handler
- private async Task CloseThreadInTime(ThreadContainer thread, string message, int minutes, Embed embed = null)
+ private async Task CloseThreadInTime(ThreadContainer thread, string message, int minutes, Embed? embed = null)
{
await Task.Delay(TimeSpan.FromMinutes(minutes));
if (thread.HasInteraction)
return;
-
+
var channel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel;
if (channel == null)
return;
-
+
if (!string.IsNullOrEmpty(message))
await channel.SendMessageAsync(message);
else
@@ -516,19 +491,23 @@ private async Task CloseThreadInTime(ThreadContainer thread, string message, int
if (!(await IsValidThread(thread)))
return;
-
+
var expectedShutdownTime = DateTime.Now.AddMinutes(minutes);
var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel;
// Check if token already created, each thread shares its own token with any relevant action (close, delete, etc)
await CancelPreviousWarning(thread, expectedShutdownTime);
-
+
thread.CancellationToken ??= new CancellationTokenSource();
+ if (threadChannel == null)
+ return;
+
// Send our message
if (!string.IsNullOrEmpty(message))
{
await threadChannel.SendMessageAsync(message);
}
thread.ExpectedShutdownTime = expectedShutdownTime;
+ // Wait for the time to pass
await Task.Delay(minutes * 60 * 1000, thread.CancellationToken.Token);
if (await IsTaskCancelled(thread))
return;
@@ -540,25 +519,27 @@ private async Task RequestThreadShutdownInTime(ThreadContainer thread, string ms
{
if (!(await IsValidThread(thread)))
return;
-
+
var expectedWarnTime = DateTime.Now.AddMinutes(minutes);
var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel;
// Check if token already created, each thread shares its own token with any relevant action (close, delete, etc)
await CancelPreviousWarning(thread, expectedWarnTime);
thread.CancellationToken ??= new CancellationTokenSource();
-
+ if (threadChannel == null)
+ return;
+
thread.ExpectedShutdownTime = expectedWarnTime;
await Task.Delay(minutes * 60 * 1000, thread.CancellationToken.Token);
if (await IsTaskCancelled(thread))
return;
-
+
msgString = string.Format(msgString, threadChannel.Owner.Mention);
var sentMessage = await threadChannel.SendMessageAsync(msgString);
// add the lock reaction
await sentMessage.AddReactionAsync(CloseEmoji);
thread.LatestUserMessage = sentMessage.Id;
}
-
+
///
/// When a thread is first started, this is called first to set it up to be closed after a certain amount of time
/// This will quickly be canceled if the thread is interacted with.
@@ -569,13 +550,15 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread)
return;
var expectedShutdownTime = DateTime.Now.AddMinutes(NoResponseNotResolvedIdleTime);
-
+
await CancelPreviousWarning(thread, expectedShutdownTime);
var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel;
thread.CancellationToken ??= new CancellationTokenSource();
+ if (threadChannel == null)
+ return;
+
thread.ExpectedShutdownTime = expectedShutdownTime;
- // Wait for the time to pass
await Task.Delay(NoResponseNotResolvedIdleTime * 60 * 1000, thread.CancellationToken.Token);
if (await IsTaskCancelled(thread))
return;
@@ -583,7 +566,7 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread)
// We prompt chat that the thread is going to be deleted in x number of hours, which will double as a bump.
var botResponse = await threadChannel.SendMessageAsync(embed: _stealthDeleteEmbed);
thread.BotsLastMessage = botResponse.Id;
-
+
// Wait for the next set of time to pass
thread.ExpectedShutdownTime = DateTime.Now.AddMinutes(StealthDeleteTime);
await Task.Delay(StealthDeleteTime * 60 * 1000, thread.CancellationToken.Token);
@@ -594,10 +577,10 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread)
}
#endregion // Bulk Behaviour Handler
-
-
+
+
#region Generic Methods
-
+
private async Task CloseThread(IThreadChannel channel, bool includeResolvedTag = false)
{
var appliedTags = channel.AppliedTags.ToList();
@@ -639,14 +622,14 @@ private async Task CancelPreviousWarning(ThreadContainer thread, DateTime newShu
await RemoveContainerPreviousComment(thread);
}
}
-
+
private async Task> GetHelpActiveThreads()
{
var messages = await _helpChannel.GetActiveThreadsAsync();
var helpThreads = messages.Where(x => x.CategoryId == _helpChannel.Id).ToList();
return helpThreads;
}
-
+
public async Task MarkResponseAsAnswer(IUser requester, IMessage message)
{
if (message.Channel is not IThreadChannel channel)
@@ -681,7 +664,7 @@ public async Task MarkResponseAsAnswer(IUser requester, IMessage message
if (!thread.IsResolved)
await CloseThread(channel, true);
-
+
thread.PinnedAnswer = message.Id;
return "New answer pinned";
}
@@ -715,7 +698,7 @@ private async Task IsValidThread(ThreadContainer thread)
}
return true;
}
-
+
private Task IsTaskCancelled(ThreadContainer thread)
{
if (thread.CancellationToken == null)
@@ -727,27 +710,27 @@ private Task IsTaskCancelled(ThreadContainer thread)
}
return Task.FromResult(false);
}
-
+
// Check if the user is the expected id and return true if so, if not then return false (Special: Moderator will return true)
- private bool IsValidAuthorUser(SocketGuildUser user, ulong authorId)
+ private bool IsValidAuthorUser(SocketGuildUser? user, ulong authorId)
{
if (user == null || user.IsUserBotOrWebhook())
return false;
-
+
if (user.Id == authorId) return true;
// If the user is moderator they can act on behalf of the author
if (user.HasRoleGroup(ModeratorRole))
return true;
-
+
return false;
}
-
+
public int GetTrackedQuestionCount()
{
return _activeThreads.Count;
}
#endregion // Utility Methods
-
+
}
diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs
index f2f8b199..7c9cf674 100644
--- a/DiscordBot/Services/CommandHandlingService.cs
+++ b/DiscordBot/Services/CommandHandlingService.cs
@@ -1,4 +1,4 @@
-using System.Reflection;
+using System.Reflection;
using System.Text;
using Discord.Commands;
using Discord.Interactions;
@@ -13,19 +13,19 @@ namespace DiscordBot.Services;
public class CommandHistoryInfo
{
- public string Command { get; set; }
- public string User { get; set; }
+ public string Command { get; set; } = null!;
+ public string User { get; set; } = null!;
public ulong UserId { get; set; }
- public string Channel { get; set; }
+ public string Channel { get; set; } = null!;
public DateTime Time { get; set; }
- public string Error { get; set; } = string.Empty;
+ public string? Error { get; set; } = string.Empty;
}
public class CommandHandlingService
{
private const string ServiceName = "CommandHandlingService";
public bool IsInitialized { get; private set; }
-
+
private readonly DiscordSocketClient _client;
private readonly CommandService _commandService;
private readonly InteractionService _interactionService;
@@ -39,7 +39,7 @@ public class CommandHandlingService
// Tuple of string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true for a dictionary
private readonly Dictionary<(string moduleName, bool orderByName, bool includeArgs, bool includeModuleName), string> _commandList = new();
private readonly Dictionary<(string moduleName, bool orderByName, bool includeArgs, bool includeModuleName), List> _commandListMessages = new();
-
+
// A Collection to store the command history
private const int MaxCommandHistory = 200;
private readonly List _commandHistory = new List(MaxCommandHistory);
@@ -60,15 +60,15 @@ ILoggingService loggingService
_loggingService = loggingService;
// Events
- _client.MessageReceived += HandleCommand;
- _client.InteractionCreated += HandleInteraction;
-
+ _client.MessageReceived += EventGuard.Guarded(HandleCommand, nameof(HandleCommand));
+ _client.InteractionCreated += EventGuard.Guarded(HandleInteraction, nameof(HandleInteraction));
+
if (settings.GuildId == default)
{
_loggingService.Log(LogBehaviour.Console | LogBehaviour.File, $"{ServiceName}: GuildId not set, commands will not be registered.", ExtendedLogSeverity.Critical);
return;
}
-
+
_commandPrefix = settings.Prefix;
if (_commandPrefix == default)
{
@@ -98,7 +98,7 @@ ILoggingService loggingService
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);
@@ -110,16 +110,16 @@ ILoggingService loggingService
}
});
}
-
+
#region Command Lists
-
+
/// Generates a command list that can provide users with information. Commands require [Command][Summary] and [Priority](If not ordering by name)
/// The results are cached, so this method can be called frequently without performance issues.
/// List of strings that can be sent to the user without worry of being over the message length limit.
public List GetCommandListMessages(string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true)
{
var tupleKey = (moduleName, orderByName, includeArgs, includeModuleName);
- if (!_commandListMessages.TryGetValue(tupleKey, out List