diff --git a/DiscordBot.Tests/Domain/Dice/DiceExpressionTests.cs b/DiscordBot.Tests/Domain/Dice/DiceExpressionTests.cs new file mode 100644 index 00000000..2a420a37 --- /dev/null +++ b/DiscordBot.Tests/Domain/Dice/DiceExpressionTests.cs @@ -0,0 +1,207 @@ +using DiscordBot.Domain.Dice; + +namespace DiscordBot.Tests.Domain.Dice; + +public class DiceExpressionTests +{ + private static DiceExpression Parse(string text) + { + Assert.True(DiceExpression.TryParse(text, out var expression), $"Expected '{text}' to parse."); + return expression!; + } + + private static int SumFaces(DiceExpressionResult result) => + result.Dice.SelectMany(faces => faces).Sum(); + + [Theory] + [InlineData("2d6", 2, 6, 0)] + [InlineData("d20", 1, 20, 0)] + [InlineData("1d20", 1, 20, 0)] + [InlineData("10d1000", 10, 1000, 0)] + [InlineData("2d6+4", 2, 6, 4)] + [InlineData("1d20-2", 1, 20, -2)] + public void TryParse_SingleTermExpressions(string text, int count, int sides, int modifier) + { + var expression = Parse(text); + + var term = Assert.Single(expression.Terms); + Assert.Equal(count, term.Count); + Assert.Equal(sides, term.Sides); + Assert.Equal(modifier, expression.Modifier); + } + + [Fact] + public void TryParse_MultipleTerms_SumsModifiers() + { + var expression = Parse("1d20+1d4-1"); + + Assert.Equal(2, expression.Terms.Count); + Assert.Equal(new DiceTerm(1, 20), expression.Terms[0]); + Assert.Equal(new DiceTerm(1, 4), expression.Terms[1]); + Assert.Equal(-1, expression.Modifier); + } + + [Theory] + [InlineData("20", 20)] + [InlineData("6", 6)] + public void TryParse_BareNumber_IsLegacySingleDie(string text, int sides) + { + var expression = Parse(text); + + var term = Assert.Single(expression.Terms); + Assert.Equal(1, term.Count); + Assert.Equal(sides, term.Sides); + Assert.True(expression.IsSimple); + } + + [Theory] + [InlineData("2d6 + 4", "2d6+4")] + [InlineData("2D6+4", "2d6+4")] + [InlineData(" 2d6+4 ", "2d6+4")] + public void TryParse_IsWhitespaceAndCaseInsensitive(string text, string expected) + { + Assert.Equal(expected, Parse(text).ToString()); + } + + [Theory] + [InlineData("2d6", "2d6")] + [InlineData("1d20", "1d20")] + [InlineData("2d6+4", "2d6+4")] + [InlineData("2d6-1", "2d6-1")] + [InlineData("1d20+1d4-1", "1d20+1d4-1")] + public void ToString_NormalisesExpression(string text, string expected) + { + Assert.Equal(expected, Parse(text).ToString()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("abc")] + [InlineData("1")] + [InlineData("1d1")] + [InlineData("0d6")] + [InlineData("11d6")] + [InlineData("d")] + [InlineData("2d")] + [InlineData("2d6+")] + [InlineData("2d6++4")] + [InlineData("2d6+2000")] + [InlineData("4+4")] + [InlineData("2x6")] + [InlineData("2d6d4")] + public void TryParse_InvalidInput_ReturnsFalse(string? text) + { + Assert.False(DiceExpression.TryParse(text, out var expression)); + Assert.Null(expression); + } + + [Fact] + public void TryParse_InvalidInput_ExplainsWhy() + { + Assert.False(DiceExpression.TryParse("4+4", out _, out var error)); + Assert.Contains("at least one dice term", error); + } + + [Fact] + public void Roll_AppliesModifierToTotal() + { + var expression = Parse("2d6+4"); + var result = expression.Roll(new Random(1)); + + var faces = Assert.Single(result.Dice); + Assert.Equal(2, faces.Length); + Assert.Equal(faces.Sum() + 4, result.Total); + Assert.Equal(2, result.Expression.DiceCount); + } + + [Fact] + public void Roll_ReturnsOneFaceArrayPerTerm() + { + var result = Parse("2d6+1d4").Roll(new Random(7)); + + Assert.Equal(2, result.Dice.Count); + Assert.Equal(2, result.Dice[0].Length); + Assert.Single(result.Dice[1]); + } + + [Fact] + public void Roll_MultipleTerms_CombinesEveryTerm() + { + var expression = Parse("1d20+1d4-1"); + var result = expression.Roll(new Random(2)); + + Assert.Equal(2, result.Dice.Count); + Assert.Single(result.Dice[0]); + Assert.Single(result.Dice[1]); + Assert.Equal(SumFaces(result) - 1, result.Total); + } + + [Fact] + public void Roll_FacesStayWithinSides() + { + var expression = Parse("10d1000"); + var random = new Random(3); + + for (var i = 0; i < 100; i++) + { + var result = expression.Roll(random); + foreach (var face in result.Dice.SelectMany(faces => faces)) + { + Assert.InRange(face, 1, 1000); + } + } + } + + [Fact] + public void Roll_MultiDie_IsNeverNatural() + { + var expression = Parse("2d6"); + var result = expression.Roll(new Random(4)); + + Assert.False(result.IsNatural); + Assert.Equal(0, result.NaturalFace); + } + + [Fact] + public void Roll_MultiTerm_IsNeverNatural() + { + var expression = Parse("1d20+1d4"); + var result = expression.Roll(new Random(5)); + + Assert.False(result.IsNatural); + Assert.Equal(0, result.NaturalFace); + } + + [Fact] + public void Roll_SingleDie_MarksNaturalOnOneOrHighestFaceOnly() + { + var expression = Parse("d20"); + var random = new Random(6); + var naturals = 0; + + // A d20 is natural 10% of the time, so 5000 rolls essentially guarantee at least one. + for (var i = 0; i < 5000; i++) + { + var result = expression.Roll(random); + var face = result.Dice[0][0]; + + Assert.Equal(face == 1 || face == 20, result.IsNatural); + Assert.Equal(result.IsNatural ? face : 0, result.NaturalFace); + + if (result.IsNatural) + naturals++; + } + + Assert.True(naturals > 0, "Expected at least one natural roll across 5000 attempts."); + } + + [Fact] + public void IsSimple_OnlyForSingleTermWithoutModifier() + { + Assert.True(Parse("3d6").IsSimple); + Assert.False(Parse("3d6+1").IsSimple); + Assert.False(Parse("3d6+1d4").IsSimple); + } +} diff --git a/DiscordBot.Tests/Extensions/StringExtensionsTests.cs b/DiscordBot.Tests/Extensions/StringExtensionsTests.cs index 1db11f26..a818b7e9 100644 --- a/DiscordBot.Tests/Extensions/StringExtensionsTests.cs +++ b/DiscordBot.Tests/Extensions/StringExtensionsTests.cs @@ -15,6 +15,54 @@ public void Truncate_ReturnsExpected(string? input, int max, string? expected) Assert.Equal(expected, input!.Truncate(max)); } + [Fact] + public void ToSelectOptionText_LeavesShortTextUnchanged() + { + Assert.Equal("Helpful Member", "Helpful Member".ToSelectOptionText()); + } + + [Theory] + [InlineData("a\n b", "a b")] + [InlineData("a\t\tb", "a b")] + [InlineData(" a b ", "a b")] + [InlineData("a\r\nb", "a b")] + public void ToSelectOptionText_FlattensOntoSingleLine(string input, string expected) + { + Assert.Equal(expected, input.ToSelectOptionText()); + } + + [Fact] + public void ToSelectOptionText_TextAtTheLimitIsNotTruncated() + { + var text = new string('a', 100); + + Assert.Equal(text, text.ToSelectOptionText()); + } + + [Fact] + public void ToSelectOptionText_TruncatesToTheLimitWithAnEllipsis() + { + var result = new string('a', 101).ToSelectOptionText(); + + Assert.Equal(100, result.Length); + Assert.EndsWith("…", result); + } + + [Fact] + public void ToSelectOptionText_HonoursACustomLimit() + { + var result = new string('a', 20).ToSelectOptionText(maxLength: 10); + + Assert.Equal(10, result.Length); + Assert.EndsWith("…", result); + } + + [Fact] + public void ToSelectOptionText_WhitespaceOnlyBecomesEmpty() + { + Assert.Equal(string.Empty, " \n\t ".ToSelectOptionText()); + } + [Fact] public void CalculateLevenshteinDistance_IdenticalStrings_ReturnsZero() { diff --git a/DiscordBot.Tests/Modules/Fun/FunRollFormatterTests.cs b/DiscordBot.Tests/Modules/Fun/FunRollFormatterTests.cs new file mode 100644 index 00000000..02f1e8fc --- /dev/null +++ b/DiscordBot.Tests/Modules/Fun/FunRollFormatterTests.cs @@ -0,0 +1,120 @@ +using DiscordBot.Domain.Dice; +using DiscordBot.Modules.Fun; + +namespace DiscordBot.Tests.Modules.Fun; + +public class FunRollFormatterTests +{ + private const string User = "Pierre"; + + private static DiceExpressionResult Result(string expression, int[][] dice) + { + Assert.True(DiceExpression.TryParse(expression, out var parsed), $"Expected '{expression}' to parse."); + return new DiceExpressionResult(parsed!, dice); + } + + [Fact] + public void SingleDie_UsesLegacyWording() + { + var result = Result("1d6", [[4]]); + + Assert.Equal("**Pierre** rolled a D6 and got **4**!", FunRollFormatter.Format(User, result)); + } + + [Theory] + [InlineData(20, 20)] + [InlineData(1, 1)] + public void SingleDie_Natural_UsesLegacyWording(int face, int total) + { + var result = Result("1d20", [[face]]); + + Assert.Equal($"**Pierre** rolled a D20 and got a natural **{total}**!", FunRollFormatter.Format(User, result)); + } + + [Fact] + public void MultipleDice_ListsEveryFaceWithCountWord() + { + var result = Result("2d6", [[2, 5]]); + + Assert.Equal("**Pierre** rolled a pair of D6 showing 2 and 5 for a total of **7**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void MultipleDice_JoinsFacesWithConjunction() + { + var result = Result("6d6", [[1, 2, 3, 4, 5, 6]]); + + Assert.Equal("**Pierre** rolled six D6 showing 1, 2, 3, 4, 5, and 6 for a total of **21**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void DiceWithModifier_ShowsFacesAndModifier() + { + var result = Result("2d6+4", [[2, 5]]); + + Assert.Equal("**Pierre** rolled 2d6+4 showing [2, 5] + 4 for a total of **11**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void MultipleTermsAndNegativeModifier_ShowsEachTerm() + { + var result = Result("1d20+1d4-1", [[15], [3]]); + + Assert.Equal("**Pierre** rolled 1d20+1d4-1 showing [15] + [3] - 1 for a total of **17**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void NaturalDieWithModifier_MarksTheDieNotTheTotal() + { + var result = Result("1d20+5", [[20]]); + + Assert.Equal("**Pierre** rolled 1d20+5 showing [20] (natural) + 5 for a total of **25**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void NonNaturalDieWithModifier_DoesNotMentionNatural() + { + var result = Result("1d20+5", [[7]]); + + Assert.Equal("**Pierre** rolled 1d20+5 showing [7] + 5 for a total of **12**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void MultipleTermsWithModifier_ShowsEveryTerm() + { + var result = Result("2d6+1d4-2", [[2, 5], [3]]); + + Assert.Equal("**Pierre** rolled 2d6+1d4-2 showing [2, 5] + [3] - 2 for a total of **8**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void Format_DisplaysTotalConsistentWithFaces() + { + var result = Result("2d6+4", [[1, 1]]); + + Assert.Equal(6, result.Total); + Assert.Contains("for a total of **6**!", FunRollFormatter.Format(User, result)); + } + + [Fact] + public void Format_UsesEveryDiceCountWord() + { + for (var count = 2; count <= DiceExpression.MaxDicePerTerm; count++) + { + var faces = Enumerable.Repeat(1, count).ToArray(); + var result = Result($"{count}d6", [faces]); + + var message = FunRollFormatter.Format(User, result); + + Assert.Contains("showing", message); + Assert.Contains($"for a total of **{count}**!", message); + } + } +} diff --git a/DiscordBot.Tests/Modules/Profiles/RankEmbedFormatterTests.cs b/DiscordBot.Tests/Modules/Profiles/RankEmbedFormatterTests.cs new file mode 100644 index 00000000..0f1fe81b --- /dev/null +++ b/DiscordBot.Tests/Modules/Profiles/RankEmbedFormatterTests.cs @@ -0,0 +1,77 @@ +using DiscordBot.Modules.Profiles; + +namespace DiscordBot.Tests.Modules.Profiles; + +public class RankEmbedFormatterTests +{ + private static string Build(params string[] names) => + RankEmbedFormatter.BuildDescription( + names.Select((name, index) => (name, index + 1)).ToList(), "Karma"); + + [Fact] + public void BuildDescription_NoRows_ReturnsEmpty() + { + Assert.Equal(string.Empty, RankEmbedFormatter.BuildDescription([], "Karma")); + } + + [Fact] + public void BuildDescription_SingleRow_FormatsWithoutPadding() + { + Assert.Equal("`1.` **`Pierre`** `Karma: 1`\n", Build("Pierre")); + } + + [Fact] + public void BuildDescription_PadsRanksForTwoDigitCounts() + { + var names = Enumerable.Range(1, 10).Select(i => $"User{i}").ToArray(); + + var lines = Build(names).Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(10, lines.Length); + Assert.StartsWith("` 1.`", lines[0]); + Assert.StartsWith("`10.`", lines[9]); + } + + [Fact] + public void BuildDescription_DoesNotPadRanksForSmallCounts() + { + var lines = Build("A", "B", "C").Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.StartsWith("`1.`", lines[0]); + Assert.StartsWith("`3.`", lines[2]); + } + + [Fact] + public void BuildDescription_PadsShorterNamesToTheLongest() + { + var lines = Build("Bo", "Alexander").Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // "Bo" is padded out to the nine characters of "Alexander". + Assert.Contains($"`Bo{new string('\u2000', 7)}`", lines[0]); + Assert.Contains("`Alexander`", lines[1]); + } + + [Fact] + public void BuildDescription_UsesValueAndLabelPerRow() + { + var rows = new List<(string Name, int Value)> { ("Bo", 12), ("Al", 7) }; + + var description = RankEmbedFormatter.BuildDescription(rows, "Weekly Karma"); + + Assert.Contains("`Weekly Karma: 12`", description); + Assert.Contains("`Weekly Karma: 7`", description); + } + + [Fact] + public void BuildDescription_OrdersRowsAsGiven() + { + var rows = new List<(string Name, int Value)> { ("First", 99), ("Second", 1) }; + + var lines = RankEmbedFormatter.BuildDescription(rows, "Karma") + .Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.StartsWith("`1.`", lines[0]); + Assert.Contains("First", lines[0]); + Assert.Contains("Second", lines[1]); + } +} diff --git a/DiscordBot.Tests/Modules/Utils/CurrencyAutocompleteHandlerTests.cs b/DiscordBot.Tests/Modules/Utils/CurrencyAutocompleteHandlerTests.cs new file mode 100644 index 00000000..4d5aac16 --- /dev/null +++ b/DiscordBot.Tests/Modules/Utils/CurrencyAutocompleteHandlerTests.cs @@ -0,0 +1,87 @@ +using DiscordBot.Modules.Utils; + +namespace DiscordBot.Tests.Modules.Utils; + +public class CurrencyAutocompleteHandlerTests +{ + private static readonly Dictionary Currencies = new() + { + ["usd"] = "United States Dollar", + ["eur"] = "Euro", + ["sek"] = "Swedish Krona", + ["jpy"] = "Japanese Yen", + }; + + [Fact] + public void BuildSuggestions_EmptyInput_ReturnsEveryCurrency() + { + var suggestions = CurrencyAutocompleteHandler.BuildSuggestions(Currencies, ""); + + Assert.Equal(4, suggestions.Count); + Assert.All(suggestions, suggestion => Assert.NotNull(suggestion.Value)); + } + + [Fact] + public void BuildSuggestions_NullInput_ReturnsEveryCurrency() + { + Assert.Equal(4, CurrencyAutocompleteHandler.BuildSuggestions(Currencies, null).Count); + } + + [Theory] + [InlineData("sek")] + [InlineData("SEK")] + [InlineData(" sek ")] + public void BuildSuggestions_MatchesCodePrefixCaseInsensitively(string input) + { + var suggestion = Assert.Single(CurrencyAutocompleteHandler.BuildSuggestions(Currencies, input)); + + Assert.Equal("sek", suggestion.Value); + Assert.Equal("SEK - Swedish Krona", suggestion.Name); + } + + [Fact] + public void BuildSuggestions_MatchesByNameWhenCodeDoesNotMatch() + { + var suggestion = Assert.Single(CurrencyAutocompleteHandler.BuildSuggestions(Currencies, "krona")); + + Assert.Equal("sek", suggestion.Value); + } + + [Fact] + public void BuildSuggestions_PrefersCodeMatchesOverNameMatches() + { + // "aa" only matches because its name contains "bb"; "bb" matches on its code. + var currencies = new Dictionary + { + ["aa"] = "Contains bb inside", + ["bb"] = "Bee", + }; + + var suggestions = CurrencyAutocompleteHandler.BuildSuggestions(currencies, "bb"); + + Assert.Equal(new[] { "bb", "aa" }, suggestions.Select(suggestion => suggestion.Value)); + } + + [Fact] + public void BuildSuggestions_NoMatch_ReturnsEmpty() + { + Assert.Empty(CurrencyAutocompleteHandler.BuildSuggestions(Currencies, "zzz")); + } + + [Fact] + public void BuildSuggestions_OrdersByCode() + { + var suggestions = CurrencyAutocompleteHandler.BuildSuggestions(Currencies, ""); + + Assert.Equal(new[] { "eur", "jpy", "sek", "usd" }, suggestions.Select(s => s.Value)); + } + + [Fact] + public void BuildSuggestions_IsCappedAtDiscordLimit() + { + var many = Enumerable.Range(0, 60).ToDictionary(i => $"c{i:00}", i => $"Currency {i}"); + var suggestions = CurrencyAutocompleteHandler.BuildSuggestions(many, ""); + + Assert.Equal(25, suggestions.Count); + } +} diff --git a/DiscordBot/Domain/Dice/DiceExpression.cs b/DiscordBot/Domain/Dice/DiceExpression.cs new file mode 100644 index 00000000..14287715 --- /dev/null +++ b/DiscordBot/Domain/Dice/DiceExpression.cs @@ -0,0 +1,231 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace DiscordBot.Domain.Dice; + +/// A single NdM dice term, for example 3d6. +public sealed record DiceTerm(int Count, int Sides); + +/// +/// A parsed dice expression made of one or more equally-sided dice terms plus a net flat modifier, +/// for example 2d6+4 or 1d20+1d4-1. +/// +public sealed class DiceExpression +{ + public const int MinSides = 2; + public const int MaxSides = 1000; + public const int MaxDicePerTerm = 10; + public const int MaxTerms = 10; + public const int MaxModifier = 1000; + + private readonly DiceTerm[] _terms; + + private DiceExpression(DiceTerm[] terms, int modifier) + { + _terms = terms; + Modifier = modifier; + } + + public IReadOnlyList Terms => _terms; + public int Modifier { get; } + public int DiceCount => _terms.Sum(term => term.Count); + public bool HasModifier => Modifier != 0; + + /// A single dice term with no modifier, the shape that keeps the original reply wording. + public bool IsSimple => _terms.Length == 1 && !HasModifier; + + public bool IsSingleDie => DiceCount == 1; + + /// Normalised form of the expression, for example 2d6+4 or 1d20. + public override string ToString() + { + var builder = new StringBuilder(); + foreach (var term in _terms) + { + if (builder.Length > 0) + builder.Append('+'); + builder.Append(term.Count).Append('d').Append(term.Sides); + } + + if (Modifier > 0) + builder.Append('+').Append(Modifier); + else if (Modifier < 0) + builder.Append(Modifier); + + return builder.ToString(); + } + + /// Rolls every term and applies the flat modifier. + public DiceExpressionResult Roll(Random random) + { + var dice = new int[_terms.Length][]; + + for (var t = 0; t < _terms.Length; t++) + { + var term = _terms[t]; + var faces = new int[term.Count]; + for (var i = 0; i < term.Count; i++) + faces[i] = random.Next(1, term.Sides + 1); + + dice[t] = faces; + } + + return new DiceExpressionResult(this, dice); + } + + public static bool TryParse(string? text, [NotNullWhen(true)] out DiceExpression? expression) => + TryParse(text, out expression, out _); + + /// + /// Parses dice notation. Dice terms are NdM, dM or a bare count of sides; a bare number + /// beside other terms is a flat modifier, so 2d6+4 is two d6 plus four. + /// + public static bool TryParse(string? text, [NotNullWhen(true)] out DiceExpression? expression, out string error) + { + expression = null; + error = string.Empty; + + var input = new string((text ?? string.Empty).Where(c => !char.IsWhiteSpace(c)).ToArray()); + if (input.Length == 0) + { + error = "No dice specified."; + return false; + } + + // Legacy shape: a lone number is the number of sides of a single die, so "20" means 1d20. + if (input.All(char.IsAsciiDigit)) + { + if (!int.TryParse(input, out var sides) || sides < MinSides || sides > MaxSides) + { + error = $"A number of sides must be between {MinSides} and {MaxSides}."; + return false; + } + + expression = new DiceExpression([new DiceTerm(1, sides)], 0); + return true; + } + + var terms = new List(); + var modifier = 0; + var sign = 1; + var lastWasOperator = false; + var index = 0; + + while (index < input.Length) + { + var c = input[index]; + if (c is '+' or '-') + { + if (lastWasOperator) + { + error = "Operators must be separated by dice or a number."; + return false; + } + + sign = c == '-' ? -1 : 1; + lastWasOperator = true; + index++; + continue; + } + + var start = index; + while (index < input.Length && input[index] is not ('+' or '-')) + index++; + + var token = input[start..index]; + if (!TryParseTerm(token, out var term, out var flat, out error)) + return false; + + if (term is not null) + terms.Add(term); + else + modifier += sign * flat; + + sign = 1; + lastWasOperator = false; + } + + if (lastWasOperator) + { + error = "The expression ends with an operator."; + return false; + } + + if (terms.Count == 0) + { + error = "The expression needs at least one dice term, for example 2d6 or d20."; + return false; + } + + if (terms.Count > MaxTerms) + { + error = $"An expression can hold at most {MaxTerms} dice terms."; + return false; + } + + if (Math.Abs((long)modifier) > MaxModifier) + { + error = $"The flat modifier must be within +/-{MaxModifier}."; + return false; + } + + expression = new DiceExpression(terms.ToArray(), modifier); + return true; + } + + private static bool TryParseTerm(string token, out DiceTerm? term, out int flat, out string error) + { + term = null; + flat = 0; + error = string.Empty; + + var separator = token.IndexOfAny(['d', 'D']); + if (separator < 0) + { + if (!int.TryParse(token, out flat)) + { + error = $"'{token}' is not a valid number of sides or modifier."; + return false; + } + + return true; + } + + var countText = token[..separator]; + var sidesText = token[(separator + 1)..]; + + var count = 1; + if (countText.Length > 0 && !int.TryParse(countText, out count)) + { + error = $"'{countText}' is not a valid number of dice."; + return false; + } + + if (sidesText.Length == 0) + { + error = "Expected a number of sides after 'd', for example 2d6."; + return false; + } + + if (!int.TryParse(sidesText, out var sides)) + { + error = $"'{sidesText}' is not a valid number of sides."; + return false; + } + + if (count < 1 || count > MaxDicePerTerm) + { + error = $"Number of dice must be between 1 and {MaxDicePerTerm}."; + return false; + } + + if (sides < MinSides || sides > MaxSides) + { + error = $"Number of sides must be between {MinSides} and {MaxSides}."; + return false; + } + + term = new DiceTerm(count, sides); + return true; + } +} diff --git a/DiscordBot/Domain/Dice/DiceExpressionResult.cs b/DiscordBot/Domain/Dice/DiceExpressionResult.cs new file mode 100644 index 00000000..1f974a50 --- /dev/null +++ b/DiscordBot/Domain/Dice/DiceExpressionResult.cs @@ -0,0 +1,37 @@ +namespace DiscordBot.Domain.Dice; + +/// The individual faces produced by rolling a , plus its total. +public sealed class DiceExpressionResult +{ + /// The expression that was rolled. + /// Faces rolled, one array per term of . + public DiceExpressionResult(DiceExpression expression, int[][] dice) + { + Expression = expression; + Dice = dice; + Total = expression.Modifier + dice.SelectMany(faces => faces).Sum(); + + if (expression.IsSingleDie && dice.Length == 1 && dice[0].Length == 1) + { + var face = dice[0][0]; + if (face == 1 || face == expression.Terms[0].Sides) + { + IsNatural = true; + NaturalFace = face; + } + } + } + + public DiceExpression Expression { get; } + + /// Faces rolled, one array per term of . + public IReadOnlyList Dice { get; } + + public int Total { get; } + + /// True when a single die came up as either 1 or its highest face. + public bool IsNatural { get; } + + /// The face that made true, otherwise 0. + public int NaturalFace { get; } +} diff --git a/DiscordBot/Extensions/StringExtensions.cs b/DiscordBot/Extensions/StringExtensions.cs index fc0e3679..a91c9570 100644 --- a/DiscordBot/Extensions/StringExtensions.cs +++ b/DiscordBot/Extensions/StringExtensions.cs @@ -160,6 +160,19 @@ public static string ToBold(this string text) return $"**{text}**"; } + /// + /// Flattens text onto a single line and caps it at Discord's 100 character select option limit, + /// so it is safe to use as a select menu label or description. + /// + public static string ToSelectOptionText(this string value, int maxLength = 100) + { + var singleLine = string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + return singleLine.Length <= maxLength + ? singleLine + : singleLine[..(maxLength - 1)] + "…"; + } + public static string[] ToBoldArray(this string[] texts) { var bolds = new string[texts.Length]; diff --git a/DiscordBot/Modules/AdminSlashModule.cs b/DiscordBot/Modules/AdminSlashModule.cs index c98fa0f6..3ca3b53f 100644 --- a/DiscordBot/Modules/AdminSlashModule.cs +++ b/DiscordBot/Modules/AdminSlashModule.cs @@ -17,6 +17,8 @@ public class AdminSlashModule : InteractionModuleBase public class BadgeAdminCommands : InteractionModuleBase { private const int BadgeAutocompleteLimit = 25; + private const int BadgeSelectMaxOptions = 25; + private const string RemoveBadgeSelectCustomIdPrefix = "admin_badge_remove_select"; public BadgeService BadgeService { get; set; } = null!; @@ -271,6 +273,137 @@ public async Task RemoveBadge( } } + [UserCommand("Remove Badge")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task RemoveBadgeContext(IUser user) + { + if (user is not SocketGuildUser guildUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + if (guildUser.IsBot) + { + await Context.Interaction.RespondAsync("❌ Cannot remove badges from bots.", ephemeral: true); + return; + } + + await Context.Interaction.DeferAsync(ephemeral: true); + + var userBadges = (await BadgeService.GetUserBadges(guildUser, isAdmin: true)) + .Select(userBadge => userBadge.EnsureBadgeDetails()) + .Where(badge => !string.IsNullOrWhiteSpace(badge.Title)) + .OrderBy(badge => badge.Title, StringComparer.OrdinalIgnoreCase) + .ThenBy(badge => badge.Id) + .ToList(); + + if (userBadges.Count == 0) + { + await Context.Interaction.FollowupAsync($"📭 {guildUser.Mention} has no badges to remove.", ephemeral: true); + return; + } + + var options = userBadges.Take(BadgeSelectMaxOptions).Select(badge => + { + var option = new SelectMenuOptionBuilder() + .WithLabel(badge.Title.ToSelectOptionText()) + .WithValue(badge.Id.ToString()); + + var description = badge.Description.ToSelectOptionText(); + return string.IsNullOrEmpty(description) ? option : option.WithDescription(description); + }).ToList(); + + // The badge list is per-user, so it cannot be presented in a modal: modal select menu + // options are declared as attributes at compile time. A message select menu is built at + // runtime instead, and picking the entries doubles as the confirmation step. + var selectMenu = new SelectMenuBuilder() + .WithCustomId($"{RemoveBadgeSelectCustomIdPrefix}:{Context.User.Id}:{guildUser.Id}") + .WithPlaceholder("Select the badge(s) to remove") + .WithMinValues(1) + .WithMaxValues(options.Count) + .WithOptions(options); + + var components = new ComponentBuilder().WithSelectMenu(selectMenu).Build(); + var truncationNotice = userBadges.Count > BadgeSelectMaxOptions + ? $"\n⚠️ Showing the first {BadgeSelectMaxOptions} of {userBadges.Count} badges — use `/admin badge remove` for the rest." + : string.Empty; + + await Context.Interaction.FollowupAsync( + $"Select which badge(s) to remove from **{guildUser.DisplayName}**:{truncationNotice}", + components: components, + ephemeral: true); + } + + [ComponentInteraction(RemoveBadgeSelectCustomIdPrefix + ":*:*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleRemoveBadgeSelect(string requesterIdRaw, string targetUserIdRaw, string[] selectedBadgeIds) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) + { + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); + return; + } + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId) || Context.Guild?.GetUser(targetUserId) is not { } targetUser) + { + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ User not found."; + msg.Components = new ComponentBuilder().Build(); + }); + return; + } + + var removedBadges = new List(); + var failedBadges = new List(); + + foreach (var selectedBadgeId in selectedBadgeIds) + { + var badge = int.TryParse(selectedBadgeId, out var badgeId) + ? await BadgeService.GetBadge(badgeId) + : null; + + if (badge == null) + { + failedBadges.Add($"`{selectedBadgeId}`"); + continue; + } + + if (await BadgeService.RemoveBadgeFromUser(targetUser, badge)) + { + removedBadges.Add(badge.Title); + } + else + { + failedBadges.Add(badge.Title); + } + } + + var summary = new StringBuilder(); + if (removedBadges.Count > 0) + { + var badgeWord = removedBadges.Count == 1 ? "badge" : "badges"; + summary.AppendLine($"🗑️ Removed {removedBadges.Count} {badgeWord} from **{targetUser.DisplayName}**: {string.Join(", ", removedBadges.Select(title => $"**{title}**"))}"); + } + + if (failedBadges.Count > 0) + { + summary.AppendLine($"⚠️ Could not remove: {string.Join(", ", failedBadges.Select(title => $"**{title}**"))}"); + } + + var result = summary.Length > 0 ? summary.ToString().TrimEnd() : "❌ No badges were removed."; + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = result; + msg.Components = new ComponentBuilder().Build(); + }); + } + [SlashCommand("delete", "Delete an existing badge")] [RequireUserPermission(GuildPermission.Administrator)] public async Task DeleteBadge( @@ -357,6 +490,10 @@ public class CasinoAdminCommands : InteractionModuleBase CheckChannelPermissions() { if (!CasinoService.IsChannelAllowed(Context.Channel.Id)) @@ -382,6 +519,24 @@ public async Task TokenHistoryAdmin( await DisplayAdminTransactionHistory(userId: null, page: 1, targetUser: targetUser, isInitialCall: true); } + [UserCommand("View Casino History")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task ViewCasinoHistoryContext(IUser user) + { + if (!await CheckChannelPermissions()) return; + + if (user is not SocketGuildUser targetUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + await Context.Interaction.DeferAsync(ephemeral: true); + + await DisplayAdminTransactionHistory(userId: null, page: 1, targetUser: targetUser, isInitialCall: true); + } + [SlashCommand("tokens-set", "Set a user's token balance")] [RequireUserPermission(GuildPermission.Administrator)] public async Task SetTokens( @@ -392,6 +547,113 @@ public async Task SetTokens( await Context.Interaction.DeferAsync(ephemeral: true); + await SetTokensAsync(targetUser, amount); + } + + [UserCommand("Set Tokens")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public Task SetTokensContext(IUser user) => ShowTokenAmountModalAsync(user, TokenAmountModeSet); + + [SlashCommand("tokens-add", "Add tokens to a user's balance")] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task AddTokens( + [Summary("user", "User to add tokens to")] SocketGuildUser targetUser, + [Summary("amount", "Amount of tokens to add")] int amount) + { + if (!await CheckChannelPermissions()) return; + + await Context.Interaction.DeferAsync(ephemeral: true); + + await AddTokensAsync(targetUser, amount); + } + + [UserCommand("Add Tokens")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public Task AddTokensContext(IUser user) => ShowTokenAmountModalAsync(user, TokenAmountModeAdd); + + // The amount cannot travel in a context command, so it is collected with a modal. One modal + // serves both actions; the mode is carried in the custom id. + private async Task ShowTokenAmountModalAsync(IUser user, string mode) + { + if (!await CheckChannelPermissions()) return; + + if (user is not SocketGuildUser targetUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + if (targetUser.IsBot) + { + await Context.Interaction.RespondAsync("❌ Cannot change the token balance of bots.", ephemeral: true); + return; + } + + await Context.Interaction.RespondWithModalAsync( + $"{TokenAmountModalCustomIdPrefix}:{mode}:{Context.User.Id}:{targetUser.Id}"); + } + + public class TokenAmountModal : IModal + { + public string Title => "Token Amount"; + + [InputLabel("Amount")] + [ModalTextInput("token_amount", TextInputStyle.Short, placeholder: "e.g. 500", maxLength: 10)] + public string Amount { get; set; } = string.Empty; + } + + [ModalInteraction(TokenAmountModalCustomIdPrefix + ":*:*:*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleTokenAmountModal(string mode, string requesterIdRaw, string targetUserIdRaw, TokenAmountModal modal) + { + if (!await CheckChannelPermissions()) return; + + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) + { + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); + return; + } + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId) || Context.Guild?.GetUser(targetUserId) is not { } targetUser) + { + await Context.Interaction.FollowupAsync("❌ User not found.", ephemeral: true); + return; + } + + if (!TryParseTokenAmount(modal.Amount, out var amount)) + { + await Context.Interaction.FollowupAsync("❌ Enter a whole number using digits only (e.g. 500).", ephemeral: true); + return; + } + + if (mode is not (TokenAmountModeSet or TokenAmountModeAdd)) + { + await Context.Interaction.FollowupAsync("❌ Unknown token action.", ephemeral: true); + return; + } + + if (mode == TokenAmountModeAdd && amount <= 0) + { + await Context.Interaction.FollowupAsync("🚫 Enter an amount greater than 0.", ephemeral: true); + return; + } + + if (mode == TokenAmountModeSet) + { + await SetTokensAsync(targetUser, amount); + } + else + { + await AddTokensAsync(targetUser, amount); + } + } + + private async Task SetTokensAsync(SocketGuildUser targetUser, int amount) + { await CasinoService.SetUserTokens(targetUser.Id.ToString(), amount, Context.User.Id.ToString()); var embed = new EmbedBuilder() @@ -404,16 +666,8 @@ public async Task SetTokens( await LoggingService.LogChannelAndFile($"Admin Token Set: {Context.User.Username} set {targetUser.Username}'s tokens to {amount}"); } - [SlashCommand("tokens-add", "Add tokens to a user's balance")] - [RequireUserPermission(GuildPermission.Administrator)] - public async Task AddTokens( - [Summary("user", "User to add tokens to")] SocketGuildUser targetUser, - [Summary("amount", "Amount of tokens to add")] int amount) + private async Task AddTokensAsync(SocketGuildUser targetUser, int amount) { - if (!await CheckChannelPermissions()) return; - - await Context.Interaction.DeferAsync(ephemeral: true); - await CasinoService.UpdateUserTokens(targetUser.Id.ToString(), amount, TransactionKind.Admin, new Dictionary { ["admin"] = Context.User.Id.ToString(), @@ -430,6 +684,17 @@ public async Task AddTokens( await LoggingService.LogChannelAndFile($"Admin Token Add: {Context.User.Username} added {amount} tokens to {targetUser.Username}"); } + private static bool TryParseTokenAmount(string? raw, out int amount) + { + amount = 0; + if (string.IsNullOrWhiteSpace(raw)) + return false; + + // NumberStyles.None rejects signs and separators, so a negative entry cannot invert the + // meaning of "add" or write a negative balance through "set". + return int.TryParse(raw.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out amount); + } + [SlashCommand("reset", "Reset all casino data - REQUIRES CONFIRMATION")] [RequireUserPermission(GuildPermission.Administrator)] public async Task ResetCasino() @@ -729,6 +994,11 @@ public async Task NavigateAdminHistory(string userId, string pageStr, string req [Group("bday", "Birthday administration commands")] public class BirthdayAdminCommands : InteractionModuleBase { + private const int BirthdayInputMaxLength = 20; + private const string SetBirthdayModalCustomIdPrefix = "admin_bday_set_user"; + private const string RemoveBirthdayConfirmCustomIdPrefix = "admin_bday_del_confirm"; + private const string RemoveBirthdayCancelCustomIdPrefix = "admin_bday_del_cancel"; + public DatabaseService DatabaseService { get; set; } = null!; public ILoggingService LoggingService { get; set; } = null!; @@ -740,6 +1010,64 @@ public async Task SetUserBirthday( { await Context.Interaction.DeferAsync(ephemeral: true); + await SetUserBirthdayAsync(targetUser, date); + } + + // A context command inside a nested [Group] module still registers at the top level, so this + // appears in the member right-click menu as "Set Birthday", not "admin bday set-user". + [UserCommand("Set Birthday")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task SetUserBirthdayContext(IUser user) + { + if (user is not SocketGuildUser targetUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + if (targetUser.IsBot) + { + await Context.Interaction.RespondAsync("🤖 You cannot set a birthday for a bot.", ephemeral: true); + return; + } + + await Context.Interaction.RespondWithModalAsync($"{SetBirthdayModalCustomIdPrefix}:{targetUser.Id}"); + } + + [ModalInteraction(SetBirthdayModalCustomIdPrefix + ":*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleSetUserBirthdayModal(string targetUserIdRaw, SetBirthdayModal modal) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId)) + { + await Context.Interaction.FollowupAsync("❌ Invalid user selection.", ephemeral: true); + return; + } + + var targetUser = Context.Guild?.GetUser(targetUserId); + if (targetUser == null) + { + await Context.Interaction.FollowupAsync("❌ User not found.", ephemeral: true); + return; + } + + await SetUserBirthdayAsync(targetUser, modal.Date); + } + + public class SetBirthdayModal : IModal + { + public string Title => "Set Birthday"; + + [InputLabel("Birthday (DD/MM/YYYY or DD/MM)")] + [ModalTextInput("birthday_date", TextInputStyle.Short, placeholder: "e.g. 15/03/1990 or 15/03", maxLength: BirthdayInputMaxLength)] + public string Date { get; set; } = string.Empty; + } + + private async Task SetUserBirthdayAsync(SocketGuildUser targetUser, string date) + { if (targetUser.IsBot) { await Context.Interaction.FollowupAsync("🤖 You cannot set a birthday for a bot.", ephemeral: true); @@ -783,34 +1111,113 @@ public async Task RemoveUserBirthday( { await Context.Interaction.DeferAsync(ephemeral: true); + await Context.Interaction.FollowupAsync(await RemoveUserBirthdayAsync(targetUser), ephemeral: true); + } + + [UserCommand("Remove Birthday")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task RemoveUserBirthdayContext(IUser user) + { + if (user is not SocketGuildUser targetUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + // A context command is a two-click destructive action, unlike the slash command where + // typing the target already signals intent, so it asks for confirmation first. + var components = new ComponentBuilder() + .WithButton("🗑️ Remove", $"{RemoveBirthdayConfirmCustomIdPrefix}:{Context.User.Id}:{targetUser.Id}", ButtonStyle.Danger) + .WithButton("Cancel", $"{RemoveBirthdayCancelCustomIdPrefix}:{Context.User.Id}", ButtonStyle.Secondary) + .Build(); + + await Context.Interaction.RespondAsync( + $"Remove **{targetUser.DisplayName}**'s birthday?", + components: components, + ephemeral: true); + } + + [ComponentInteraction(RemoveBirthdayConfirmCustomIdPrefix + ":*:*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleRemoveBirthdayConfirm(string requesterIdRaw, string targetUserIdRaw) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) + { + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); + return; + } + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId) || Context.Guild?.GetUser(targetUserId) is not { } targetUser) + { + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ User not found."; + msg.Components = new ComponentBuilder().Build(); + }); + return; + } + + var result = await RemoveUserBirthdayAsync(targetUser); + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = result; + msg.Components = new ComponentBuilder().Build(); + }); + } + + [ComponentInteraction(RemoveBirthdayCancelCustomIdPrefix + ":*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleRemoveBirthdayCancel(string requesterIdRaw) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) + { + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); + return; + } + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ Cancelled — no changes made."; + msg.Components = new ComponentBuilder().Build(); + }); + } + + private async Task RemoveUserBirthdayAsync(SocketGuildUser targetUser) + { try { var user = await DatabaseService.GetOrAddUser(targetUser); if (user == null) { - await Context.Interaction.FollowupAsync("Failed to access user data.", ephemeral: true); - return; + return "Failed to access user data."; } var currentBirthday = await DatabaseService.Query.GetBirthday(user.UserID); if (currentBirthday == null) { - await Context.Interaction.FollowupAsync($"**{targetUser.DisplayName}** doesn't have a birthday set.", ephemeral: true); - return; + return $"**{targetUser.DisplayName}** doesn't have a birthday set."; } await DatabaseService.Query.UpdateBirthday(user.UserID, null); - await Context.Interaction.FollowupAsync($"Removed **{targetUser.DisplayName}**'s birthday.", ephemeral: true); await LoggingService.LogAction( $"[BirthdayAdminDelete] actor={Context.User.Id} target={targetUser.Id}", ExtendedLogSeverity.Info); + + return $"Removed **{targetUser.DisplayName}**'s birthday."; } catch (Exception e) { await LoggingService.LogAction( $"Error removing birthday for target {targetUser.Id} by actor {Context.User.Id}: {e.Message}", ExtendedLogSeverity.Warning); - await Context.Interaction.FollowupAsync("An error occurred while removing the birthday.", ephemeral: true); + + return "An error occurred while removing the birthday."; } } diff --git a/DiscordBot/Modules/BadgeSlashModule.cs b/DiscordBot/Modules/BadgeSlashModule.cs index b7ac0a2d..aef8d5bf 100644 --- a/DiscordBot/Modules/BadgeSlashModule.cs +++ b/DiscordBot/Modules/BadgeSlashModule.cs @@ -3,6 +3,7 @@ using Discord.WebSocket; using DiscordBot.Extensions; using DiscordBot.Services; +using System.Text; using System.Text.RegularExpressions; namespace DiscordBot.Modules; @@ -13,8 +14,10 @@ public class BadgeSlashModule : InteractionModuleBase private const int BadgeListPageSize = 24; private const int EmbedFieldNameMaxLength = 256; private const int EmbedFieldValueMaxLength = 1024; - private const int BadgeIdentifierMaxLength = 100; - private const string AssignBadgeModalCustomIdPrefix = "badge_assign_user"; + + /// Discord caps a select menu at 25 options. + private const int BadgeSelectMaxOptions = 25; + private const string AssignBadgeSelectCustomIdPrefix = "badge_assign_select"; private static readonly IComparer BadgeTitleNaturalComparer = new NaturalBadgeTitleComparer(); #region Dependency Injection @@ -283,83 +286,137 @@ public async Task AssignBadgeContext(IUser user) return; } - await Context.Interaction.RespondWithModalAsync($"{AssignBadgeModalCustomIdPrefix}:{guildUser.Id}"); - } - - [ModalInteraction("badge_assign_user:*", true)] - [RequireUserPermission(GuildPermission.Administrator)] - public async Task HandleAssignBadgeModal(string targetUserIdRaw, AssignBadgeModal modal) - { await Context.Interaction.DeferAsync(ephemeral: true); - if (!ulong.TryParse(targetUserIdRaw, out var targetUserId)) + var assignableBadges = await GetAssignableBadgesAsync(guildUser); + if (assignableBadges.Count == 0) { - await Context.Interaction.FollowupAsync("❌ Invalid user selection.", ephemeral: true); + await Context.Interaction.FollowupAsync($"📭 {guildUser.Mention} already has every badge.", ephemeral: true); return; } - var guildUser = Context.Guild?.GetUser(targetUserId); - if (guildUser == null) - { - await Context.Interaction.FollowupAsync("❌ User not found.", ephemeral: true); - return; - } + var options = assignableBadges.Take(BadgeSelectMaxOptions).Select(badge => + { + var option = new SelectMenuOptionBuilder() + .WithLabel(badge.Title.ToSelectOptionText()) + .WithValue(badge.Id.ToString()); + + var description = badge.Description.ToSelectOptionText(); + return string.IsNullOrEmpty(description) ? option : option.WithDescription(description); + }).ToList(); + + // The assignable badge list is per-user, so it cannot be presented in a modal: modal select menu + // options are declared as attributes at compile time. A message select menu is built at runtime + // instead, and picking the entries doubles as the confirmation step. + var selectMenu = new SelectMenuBuilder() + .WithCustomId($"{AssignBadgeSelectCustomIdPrefix}:{Context.User.Id}:{guildUser.Id}") + .WithPlaceholder("Select the badge(s) to assign") + .WithMinValues(1) + .WithMaxValues(options.Count) + .WithOptions(options); + + var components = new ComponentBuilder().WithSelectMenu(selectMenu).Build(); + var truncationNotice = assignableBadges.Count > BadgeSelectMaxOptions + ? $"\n⚠️ Showing the first {BadgeSelectMaxOptions} of {assignableBadges.Count} badges — use `/admin badge assign` for the rest." + : string.Empty; + + await Context.Interaction.FollowupAsync( + $"Select which badge(s) to assign to **{guildUser.DisplayName}**:{truncationNotice}", + components: components, + ephemeral: true); + } - if (guildUser.IsBot) - { - await Context.Interaction.FollowupAsync("❌ Cannot assign badges to bots.", ephemeral: true); - return; - } + /// Badges the target does not already hold, ordered the way the badge list is. + private async Task> GetAssignableBadgesAsync(SocketGuildUser guildUser) + { + var ownedIds = (await BadgeService.GetUserBadges(guildUser, isAdmin: true)) + .Select(userBadge => userBadge.EnsureBadgeDetails().Id) + .ToHashSet(); + + return (await BadgeService.GetAllBadges(isAdmin: true)) + .Where(badge => !ownedIds.Contains(badge.Id)) + .OrderBy(badge => badge.Title, BadgeTitleNaturalComparer) + .ThenBy(badge => badge.Id) + .ToList(); + } - var badgeIdentifier = modal.BadgeIdentifier?.Trim(); - if (string.IsNullOrWhiteSpace(badgeIdentifier)) + [ComponentInteraction(AssignBadgeSelectCustomIdPrefix + ":*:*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleAssignBadgeSelect(string requesterIdRaw, string targetUserIdRaw, string[] selectedBadgeIds) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) { - await Context.Interaction.FollowupAsync("❌ Badge identifier cannot be empty.", ephemeral: true); + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); return; } - var badge = await ResolveBadgeByIdentifier(badgeIdentifier); - if (badge == null) + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId) || Context.Guild?.GetUser(targetUserId) is not { } targetUser) { - await Context.Interaction.FollowupAsync($"❌ Badge '{badgeIdentifier}' not found.", ephemeral: true); + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ User not found."; + msg.Components = new ComponentBuilder().Build(); + }); return; } var awardedBy = Context.User as SocketGuildUser; if (awardedBy == null) { - await Context.Interaction.FollowupAsync("❌ This command can only be used in a server.", ephemeral: true); + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ This command can only be used in a server."; + msg.Components = new ComponentBuilder().Build(); + }); return; } - var success = await BadgeService.AssignBadgeToUser(guildUser, badge, awardedBy); + var assignedBadges = new List(); + var failedBadges = new List(); - if (success) + foreach (var selectedBadgeId in selectedBadgeIds) { - var embed = new EmbedBuilder() - .WithTitle("🏆 Badge Assigned Successfully") - .WithDescription($"Assigned **{badge.Title}** to **{guildUser.DisplayName}**") - .AddField("Badge Description", badge.Description) - .AddField("Assigned By", Context.User.Mention) - .AddField("Assigned At", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss UTC")) - .WithColor(Color.Gold) - .WithTimestamp(DateTimeOffset.UtcNow) - .Build(); + var badge = int.TryParse(selectedBadgeId, out var badgeId) + ? await BadgeService.GetBadge(badgeId) + : null; - await Context.Interaction.FollowupAsync(embed: embed, ephemeral: true); - return; + if (badge == null) + { + failedBadges.Add($"`{selectedBadgeId}`"); + continue; + } + + if (await BadgeService.AssignBadgeToUser(targetUser, badge, awardedBy)) + { + assignedBadges.Add(badge.Title); + } + else + { + failedBadges.Add(badge.Title); + } } - await Context.Interaction.FollowupAsync("❌ Failed to assign badge. The user may already have this badge.", ephemeral: true); - } + var summary = new StringBuilder(); + if (assignedBadges.Count > 0) + { + var badgeWord = assignedBadges.Count == 1 ? "badge" : "badges"; + summary.AppendLine($"🏆 Assigned {assignedBadges.Count} {badgeWord} to **{targetUser.DisplayName}**: {string.Join(", ", assignedBadges.Select(title => $"**{title}**"))}"); + } - public class AssignBadgeModal : IModal - { - public string Title => "Assign Badge"; + if (failedBadges.Count > 0) + { + summary.AppendLine($"⚠️ Could not assign: {string.Join(", ", failedBadges.Select(title => $"**{title}**"))}"); + } + + var result = summary.Length > 0 ? summary.ToString().TrimEnd() : "❌ No badges were assigned."; - [InputLabel("Badge ID or title")] - [ModalTextInput("badge_identifier", TextInputStyle.Short, placeholder: "e.g. 12 or Helpful Member", maxLength: BadgeIdentifierMaxLength)] - public string BadgeIdentifier { get; set; } = string.Empty; + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = result; + msg.Components = new ComponentBuilder().Build(); + }); } private async Task ShowUserBadgesForTarget(SocketGuildUser user) @@ -466,22 +523,6 @@ private async Task ShowUserBadgesForTarget(SocketGuildUser user) await Context.Interaction.FollowupAsync(embed: embed.Build(), ephemeral: true); } - private async Task ResolveBadgeByIdentifier(string badgeIdentifier) - { - var normalizedIdentifier = badgeIdentifier.Trim(); - - if (int.TryParse(normalizedIdentifier, out var badgeId)) - { - var badgeById = await BadgeService.GetBadge(badgeId); - if (badgeById != null) - { - return badgeById; - } - } - - return await BadgeService.GetBadgeByTitle(normalizedIdentifier); - } - [SlashCommand("leaderboard", "Show the badge leaderboard")] public async Task BadgeLeaderboard( [Summary("group", "Optional group key to filter the leaderboard (example: udcjam)")] string? group = null) diff --git a/DiscordBot/Modules/BirthdaySlashModule.cs b/DiscordBot/Modules/BirthdaySlashModule.cs index c8e2ea4b..8e303a25 100644 --- a/DiscordBot/Modules/BirthdaySlashModule.cs +++ b/DiscordBot/Modules/BirthdaySlashModule.cs @@ -13,15 +13,23 @@ public class BirthdaySlashModule : InteractionModuleBase public DatabaseService DatabaseService { get; set; } public ILoggingService LoggingService { get; set; } - [SlashCommand("show", "Shows the next upcoming birthday date(s)")] - public async Task ShowNextBirthday( - [Summary(description: "Number of upcoming birthday dates to show (min 1, max 10)")] + [SlashCommand("show", "Shows upcoming birthdays, or a specific user's birthday")] + public async Task ShowBirthday( + [Summary(description: "Number of upcoming birthday dates to show (min 1, max 10). Ignored when a user is specified.")] [MinValue(1)] [MaxValue(10)] - int count = 3) + int count = 3, + [Summary(description: "Show this user's birthday instead of the upcoming birthdays")] + IUser? user = null) { await Context.Interaction.DeferAsync(); + if (user != null) + { + await ShowUserBirthdayAsync(user); + return; + } + try { var upcomingBirthdayGroups = await GetUpcomingBirthdayGroups(count); @@ -73,12 +81,16 @@ public async Task ShowNextBirthday( } } - [SlashCommand("user", "Shows a specific user's birthday")] - public async Task ShowUserBirthday( - [Summary(description: "The user whose birthday you want to see")] IUser user) + [UserCommand("View Birthday")] + public async Task ViewUserBirthdayContext(IUser user) { - await Context.Interaction.DeferAsync(); + await Context.Interaction.DeferAsync(ephemeral: true); + + await ShowUserBirthdayAsync(user); + } + private async Task ShowUserBirthdayAsync(IUser user) + { try { var searchUser = await DatabaseService.GetOrAddUser(user as SocketGuildUser); diff --git a/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs index 7ff84cc8..0cb7b2ba 100644 --- a/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs +++ b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Discord.Interactions; using Discord.WebSocket; using DiscordBot.Domain; @@ -77,6 +78,8 @@ public async Task CheckTokens() await Context.Interaction.FollowupAsync(embed: embed, ephemeral: true); } + private const string GiftTokensModalCustomIdPrefix = "casino_gift_tokens"; + [SlashCommand("gift", "Gift tokens to another user")] public async Task GiftTokens( [Summary("user", "User to gift tokens to")] SocketGuildUser targetUser, @@ -86,6 +89,71 @@ public async Task GiftTokens( await Context.Interaction.DeferAsync(ephemeral: true); + await GiftTokensAsync(targetUser, amount); + } + + // Context commands always register at the top level, even inside a nested group, + // so this lives here to reuse the gift validation helper. + [UserCommand("Gift Tokens")] + public async Task GiftTokensContext(SocketGuildUser targetUser) + { + if (!await CheckChannelPermissions()) return; + + if (targetUser.IsBot) + { + await Context.Interaction.RespondAsync("🤖 You cannot gift tokens to bots.", ephemeral: true); + return; + } + + if (targetUser.Id == Context.User.Id) + { + await Context.Interaction.RespondAsync("🚫 You cannot gift tokens to yourself.", ephemeral: true); + return; + } + + await Context.Interaction.RespondWithModalAsync($"{GiftTokensModalCustomIdPrefix}:{targetUser.Id}"); + } + + public class GiftTokensModal : IModal + { + public string Title => "Gift Tokens"; + + [InputLabel("Amount")] + [ModalTextInput("gift_amount", TextInputStyle.Short, placeholder: "e.g. 100", maxLength: 10)] + public string Amount { get; set; } = string.Empty; + } + + [ModalInteraction("casino_gift_tokens:*", true)] + public async Task HandleGiftTokensModal(string targetUserIdRaw, GiftTokensModal modal) + { + if (!await CheckChannelPermissions()) return; + + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId)) + { + await Context.Interaction.FollowupAsync("❌ Invalid user selection.", ephemeral: true); + return; + } + + var targetUser = Context.Guild?.GetUser(targetUserId); + if (targetUser == null) + { + await Context.Interaction.FollowupAsync("❌ User not found.", ephemeral: true); + return; + } + + if (!TryParseGiftAmount(modal.Amount, out var amount)) + { + await Context.Interaction.FollowupAsync("❌ Enter a whole number of tokens greater than 0.", ephemeral: true); + return; + } + + await GiftTokensAsync(targetUser, amount); + } + + private async Task GiftTokensAsync(SocketGuildUser targetUser, int amount) + { if (targetUser.IsBot) { await Context.Interaction.FollowupAsync("🤖 You cannot gift tokens to bots.", ephemeral: true); @@ -98,7 +166,7 @@ public async Task GiftTokens( return; } - if (amount == 0) + if (amount <= 0) { await Context.Interaction.FollowupAsync("🚫 You must gift at least 1 token.", ephemeral: true); return; @@ -124,6 +192,17 @@ public async Task GiftTokens( } } + private static bool TryParseGiftAmount(string? raw, out int amount) + { + amount = 0; + if (string.IsNullOrWhiteSpace(raw)) + return false; + + // NumberStyles.None rejects signs and separators, so a negative entry + // can never reverse the transfer direction in TransferTokens. + return int.TryParse(raw.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out amount) && amount > 0; + } + [SlashCommand("leaderboard", "View the top token holders")] public async Task TokenLeaderboard( [Summary("user", "User to show rank for (optional)")] SocketGuildUser? guildUser = null diff --git a/DiscordBot/Modules/Fun/DuelSlashModule.cs b/DiscordBot/Modules/Fun/DuelSlashModule.cs index b6c84516..1997a056 100644 --- a/DiscordBot/Modules/Fun/DuelSlashModule.cs +++ b/DiscordBot/Modules/Fun/DuelSlashModule.cs @@ -15,6 +15,18 @@ public async Task Duel( [Choice("Normal", "normal")] [Choice("Mute", "mute")] string type = "normal") + { + await StartDuel(opponent, type); + } + + [UserCommand("Duel")] + public async Task DuelContext(IUser opponent) + { + // The context menu has no room for the duel type, so always start a non-mute duel. + await StartDuel(opponent, "normal"); + } + + private async Task StartDuel(IUser opponent, string type) { if (opponent.Id == Context.User.Id) { diff --git a/DiscordBot/Modules/Fun/FunModule.cs b/DiscordBot/Modules/Fun/FunModule.cs deleted file mode 100644 index 8cb7d239..00000000 --- a/DiscordBot/Modules/Fun/FunModule.cs +++ /dev/null @@ -1,214 +0,0 @@ -using System.Text; -using Discord.Commands; -using DiscordBot.Services; -using DiscordBot.Settings; -using DiscordBot.Data; - -namespace DiscordBot.Modules.Fun; - -[Group("UserModule"), Alias("")] -public class FunModule : ModuleBase -{ - public ILoggingService LoggingService { get; set; } = null!; - public BotSettings Settings { get; set; } = null!; - - private readonly Random _random = new(); - private static FuzzTable _slapBridges = new(); - private static FuzzTable _slapObjects = new(); - private static FuzzTable _slapFails = new(); - - [Command("Slap"), Priority(21)] - [Summary("Slap the specified user(s). Syntax : !slap @user1 [@user2 @user3...]")] - public async Task SlapUser(params IUser[] users) - { - try - { - _slapObjects.Unique = true; - if (_slapObjects.Count == 0) - { - _slapObjects.Load(Settings.FunCommands.SlapObjectsTable!); - await LoggingService.LogChannelAndFile($"Loaded {_slapObjects.Count} slap object entries."); - } - } - catch (Exception) - { - await LoggingService.LogChannelAndFile($"Error while loading '{Settings.FunCommands.SlapObjectsTable}'.", - ExtendedLogSeverity.LowWarning); - return; - } - - var uname = Context.User.GetUserPreferredName(); - - if (users == null || users.Length == 0) - { - await Context.Channel.SendMessageAsync( - $"**{uname}** slaps away an invisible pest."); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - return; - } - - // Legacy easter egg: these specific users always end up slapping themselves. - if (Context.User.Id == 162189038965489664 || - Context.User.Id == 178201477280563200) - { - await Context.Channel.SendMessageAsync( - $"**{uname}** slaps themself."); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - return; - } - - if (_slapBridges.Count == 0) - { - _slapBridges.Add("(around|about) (a bit|by surprise|suddenly) with (a large|a huge|an oversized|their own private|a suspiciously convenient) "); - _slapBridges.Add("(over the head|from behind|across their shoulders) with (a large|a huge|an oversized|their own private|a suspiciously convenient) "); - } - - if (_slapObjects.Count == 0) - _slapObjects.Add(Settings.FunCommands.SlapChoices); - if (_slapObjects.Count == 0) - _slapObjects.Add("fish|mallet"); - - if (_slapFails.Count == 0) - _slapFails.Add(Settings.FunCommands.SlapFails); - if (_slapFails.Count == 0) - _slapFails.Add("hurting themselves"); - - var sb = new StringBuilder(); - var mentions = users.ToMentionArray().ToCommaList(); - - bool fail = (_random.Next(1, 100) < 5); - if (fail) - { - sb.Append($"**{uname}** tries to slap {mentions} "); - sb.Append(_slapBridges.Pick(true)); - sb.Append(_slapObjects.Pick(true)); - sb.Append(", but misses and ends up "); - sb.Append(_slapFails.Pick(true)); - sb.Append("."); - } - else - { - sb.Append($"**{uname}** slaps {mentions} "); - sb.Append(_slapBridges.Pick(true)); - sb.Append(_slapObjects.Pick(true)); - sb.Append("."); - } - - await Context.Channel.SendMessageAsync(sb.ToString()); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - } - - [Command("CoinFlip"), Priority(22)] - [Summary("Flip a coin and see the result.")] - [Alias("flipcoin")] - public async Task CoinFlip() - { - var coin = new[] { "Heads", "Tails" }; - - var uname = Context.User.GetUserPreferredName(); - await ReplyAsync($"**{uname}** flipped a coin and got **{coin[_random.Next() % 2]}**!"); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - } - - // needs enough for highest allowed !roll dice count - static string[] ordinals = new[] - { "no", "a", "a pair of", "three", "four", "five", "six", "seven", "eight", "nine", - "ten", "eleven", "twelve", "13", "14", "15", "16", "17", "18", "19", - }; - - [Command("Roll"), Priority(23)] - [Summary("Roll one or more equal dice. Syntax: !roll [dice] {in D&D format or just a number of sides}")] - public async Task RollDice(string? dice = null) - { - if (dice == null) - dice = "1d6"; - await RollDice(dice, 0); - } - - [Command("Roll"), Priority(23)] - [Summary("Roll one or more equal dice. Syntax: !roll [dice] [needed] {dice in D&D format or just a number of sides}")] - public async Task RollDice(string dice, int needed) - { - int sides = 0; - int count = 1; - if (!TryParseDice(dice, out sides, out count)) - { - await ReplyAsync( - "Invalid dice specified. " + - "Please choose a number of sides between 1 and 1000, " + - "or use D&D format, eg. 3d6 to give the total of three six-sided dice together.") - .DeleteAfterSeconds(seconds: 10)!; - await Context.Message.DeleteAsync(); - return; - } - - var uname = Context.User.GetUserPreferredName(); - var rolls = new List(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 (int.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 (!int.TryParse(parts[0], out count)) - count = 1; - if (!int.TryParse(parts[1], out sides)) - sides = 6; - - if (count < 1 || count > 10) - return false; - if (sides < 2 || sides > 1000) - return false; - - return true; - } -} diff --git a/DiscordBot/Modules/Fun/FunRollFormatter.cs b/DiscordBot/Modules/Fun/FunRollFormatter.cs new file mode 100644 index 00000000..eea9b7db --- /dev/null +++ b/DiscordBot/Modules/Fun/FunRollFormatter.cs @@ -0,0 +1,57 @@ +using DiscordBot.Domain.Dice; + +namespace DiscordBot.Modules.Fun; + +/// Builds the reply text for a dice roll. +public static class FunRollFormatter +{ + /// Words the reply uses for each number of dice, indexed by dice count up to . + private static readonly string[] DiceCountWords = + [ + "no", "a", "a pair of", "three", "four", "five", + "six", "seven", "eight", "nine", "ten", + ]; + + public static string Format(string userName, DiceExpressionResult result) + { + var expression = result.Expression; + + if (expression.IsSimple) + { + var term = expression.Terms[0]; + + if (term.Count == 1) + { + // Keeps the wording the original text command used for a plain roll. + return result.IsNatural + ? $"**{userName}** rolled a D{term.Sides} and got a natural **{result.Total}**!" + : $"**{userName}** rolled a D{term.Sides} and got **{result.Total}**!"; + } + + var faces = result.Dice[0].Select(face => face.ToString()).ToArray(); + return $"**{userName}** rolled {DiceCountWords[term.Count]} D{term.Sides} showing {faces.ToCommaList()} " + + $"for a total of **{result.Total}**!"; + } + + // Expressions holding several terms or a flat modifier spell out every die so the total is checkable. + var breakdown = new List(expression.Terms.Count); + for (var i = 0; i < expression.Terms.Count; i++) + { + var dice = $"[{string.Join(", ", result.Dice[i])}]"; + if (result.IsNatural) + dice += " (natural)"; + + breakdown.Add(dice); + } + + var modifier = expression.Modifier switch + { + > 0 => $" + {expression.Modifier}", + < 0 => $" - {Math.Abs(expression.Modifier)}", + _ => string.Empty, + }; + + return $"**{userName}** rolled {expression} showing {string.Join(" + ", breakdown)}{modifier} " + + $"for a total of **{result.Total}**!"; + } +} diff --git a/DiscordBot/Modules/Fun/FunSlashModule.cs b/DiscordBot/Modules/Fun/FunSlashModule.cs new file mode 100644 index 00000000..bf0180bd --- /dev/null +++ b/DiscordBot/Modules/Fun/FunSlashModule.cs @@ -0,0 +1,167 @@ +using System.Text; +using Discord.Interactions; +using DiscordBot.Data; +using DiscordBot.Domain.Dice; +using DiscordBot.Settings; + +namespace DiscordBot.Modules.Fun; + +public class FunSlashModule : InteractionModuleBase +{ + private const string DefaultDice = "1d6"; + private const int SlapFailChancePercent = 5; + + private static readonly string[] CoinSides = ["Heads", "Tails"]; + + /// Legacy easter egg: these users always end up slapping themselves. + private static readonly ulong[] SelfSlapUserIds = [162189038965489664, 178201477280563200]; + + private static readonly FuzzTable SlapBridges = new(); + private static readonly FuzzTable SlapObjects = new(); + private static readonly FuzzTable SlapFails = new(); + + // Modules are instantiated per invocation, so this must be static to survive across calls. + private static bool _slapObjectsLoaded; + + private readonly Random _random = new(); + + public ILoggingService LoggingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; + + [SlashCommand("coinflip", "Flip a coin and see the result.")] + public async Task CoinFlip() + { + var userName = Context.User.GetUserPreferredName(); + + await Context.Interaction.RespondAsync( + $"**{userName}** flipped a coin and got **{CoinSides[_random.Next(CoinSides.Length)]}**!"); + } + + [SlashCommand("roll", "Roll dice, for example 2d6, d20 or 2d6+4")] + public async Task Roll( + [Summary("dice", "Dice to roll, for example 2d6, d20 or 2d6+4 (default: 1d6)")] string? dice = null, + [Summary("needed", "Optional total the roll needs to match or beat")] int? needed = null) + { + if (!DiceExpression.TryParse(dice ?? DefaultDice, out var expression, out var error)) + { + await Context.Interaction.RespondAsync($"❌ {error}", ephemeral: true); + return; + } + + var result = expression.Roll(_random); + var message = FunRollFormatter.Format(Context.User.GetUserPreferredName(), result); + + var target = needed ?? 0; + if (target < 1) + message = " :game_die: " + message; + else + message = $"{(result.Total >= target ? " :white_check_mark: " : " :x: ")}{message} [Needed: {target}]"; + + await Context.Interaction.RespondAsync(message); + } + + [SlashCommand("slap", "Slap one or more users with a random object")] + public async Task Slap( + [Summary("target1", "First user to slap")] IUser? target1 = null, + [Summary("target2", "Second user to slap")] IUser? target2 = null, + [Summary("target3", "Third user to slap")] IUser? target3 = null, + [Summary("target4", "Fourth user to slap")] IUser? target4 = null, + [Summary("target5", "Fifth user to slap")] IUser? target5 = null) + { + IUser?[] selected = [target1, target2, target3, target4, target5]; + + await SlapUsersAsync(selected.Where(user => user is not null).Select(user => user!).ToArray()); + } + + private async Task SlapUsersAsync(IUser[] targets) + { + if (!await TryLoadSlapObjectsAsync()) + { + await Context.Interaction.RespondAsync("❌ Could not slap anyone right now.", ephemeral: true); + return; + } + + var userName = Context.User.GetUserPreferredName(); + + if (targets.Length == 0) + { + await Context.Interaction.RespondAsync($"**{userName}** slaps away an invisible pest."); + return; + } + + if (SelfSlapUserIds.Contains(Context.User.Id)) + { + await Context.Interaction.RespondAsync($"**{userName}** slaps themself."); + return; + } + + EnsureSlapTables(); + + var mentions = targets.ToMentionArray().ToCommaList(); + var builder = new StringBuilder(); + + if (_random.Next(1, 100) < SlapFailChancePercent) + { + builder.Append($"**{userName}** tries to slap {mentions} "); + builder.Append(SlapBridges.Pick(true)); + builder.Append(SlapObjects.Pick(true)); + builder.Append(", but misses and ends up "); + builder.Append(SlapFails.Pick(true)); + builder.Append('.'); + } + else + { + builder.Append($"**{userName}** slaps {mentions} "); + builder.Append(SlapBridges.Pick(true)); + builder.Append(SlapObjects.Pick(true)); + builder.Append('.'); + } + + await Context.Interaction.RespondAsync(builder.ToString()); + } + + private async Task TryLoadSlapObjectsAsync() + { + // The table is static configuration, so it is only read once per process: retrying it on every + // invocation would spend Discord API calls inside the three second interaction window. + if (_slapObjectsLoaded) + return true; + + try + { + SlapObjects.Unique = true; + SlapObjects.Load(Settings.FunCommands.SlapObjectsTable!); + _slapObjectsLoaded = true; + + await LoggingService.LogChannelAndFile($"Loaded {SlapObjects.Count} slap object entries."); + return true; + } + catch (Exception) + { + _slapObjectsLoaded = true; + + await LoggingService.LogChannelAndFile($"Error while loading '{Settings.FunCommands.SlapObjectsTable}'.", + ExtendedLogSeverity.LowWarning); + return false; + } + } + + private void EnsureSlapTables() + { + if (SlapBridges.Count == 0) + { + SlapBridges.Add("(around|about) (a bit|by surprise|suddenly) with (a large|a huge|an oversized|their own private|a suspiciously convenient) "); + SlapBridges.Add("(over the head|from behind|across their shoulders) with (a large|a huge|an oversized|their own private|a suspiciously convenient) "); + } + + if (SlapObjects.Count == 0) + SlapObjects.Add(Settings.FunCommands.SlapChoices); + if (SlapObjects.Count == 0) + SlapObjects.Add("fish|mallet"); + + if (SlapFails.Count == 0) + SlapFails.Add(Settings.FunCommands.SlapFails); + if (SlapFails.Count == 0) + SlapFails.Add("hurting themselves"); + } +} diff --git a/DiscordBot/Modules/Profiles/ProfileModule.cs b/DiscordBot/Modules/Profiles/ProfileModule.cs deleted file mode 100644 index c1282e1f..00000000 --- a/DiscordBot/Modules/Profiles/ProfileModule.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Discord.Commands; -using DiscordBot.Services; - -namespace DiscordBot.Modules.Profiles; - -[Group("UserModule"), Alias("")] -public class ProfileModule : ModuleBase -{ - public ProfileCardService ProfileCardService { get; set; } = null!; - public ILoggingService LoggingService { get; set; } = null!; - - [Command("Karma"), Priority(95)] - [Summary("Description of what Karma is.")] - public async Task KarmaDescription(int seconds = 60) - { - var uname = Context.User.GetUserPreferredName(); - await ReplyAsync($"{uname}, Karma is tracked on your !profile which helps indicate how much you've helped others and provides a small increase in EXP gain."); - await (Context.Message?.DeleteAfterSeconds(seconds: seconds) ?? Task.CompletedTask); - } - - [Command("JoinDate"), Priority(91)] - [Summary("Display date you joined the server.")] - public async Task JoinDate() - { - var joinDate = ((IGuildUser)Context.User).JoinedAt; - await ReplyAsync($"{Context.User.Mention} you joined **{joinDate:dddd dd/MM/yyy HH:mm:ss}**"); - await Context.Message.DeleteAsync(); - } - - [Command("Profile"), Priority(2)] - [Summary("Display your profile card.")] - public async Task DisplayProfile() - { - await DisplayProfile(Context.Message.Author); - } - - [Command("Profile"), Priority(2)] - [Summary("Display profile card of mentioned user. Syntax : !profile @user")] - public async Task DisplayProfile(IUser user) - { - try - { - await Context.Message.DeleteAsync(); - - var profileCard = await ProfileCardService.GenerateProfileCard(user); - if (string.IsNullOrEmpty(profileCard)) - { - await (ReplyAsync("Failed to generate profile card.").DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); - return; - } - - var profile = await Context.Channel.SendFileAsync(profileCard); - await (profile?.DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); - } - catch (Exception e) - { - await LoggingService.LogAction($"Error while generating profile card for {user.Username}.\nEx:{e}", - ExtendedLogSeverity.LowWarning); - } - } -} diff --git a/DiscordBot/Modules/Profiles/ProfileSlashModule.cs b/DiscordBot/Modules/Profiles/ProfileSlashModule.cs new file mode 100644 index 00000000..9a7a8d56 --- /dev/null +++ b/DiscordBot/Modules/Profiles/ProfileSlashModule.cs @@ -0,0 +1,73 @@ +using Discord.Interactions; +using Discord.WebSocket; + +namespace DiscordBot.Modules.Profiles; + +public class ProfileSlashModule : InteractionModuleBase +{ + public ProfileCardService ProfileCardService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; + + [SlashCommand("profile", "Display your profile card, or another user's")] + public async Task DisplayProfile( + [Summary("user", "The user whose profile card you want to see (defaults to you)")] IUser? user = null) + { + await Context.Interaction.DeferAsync(); + + await SendProfileCardAsync(user ?? Context.User); + } + + [UserCommand("View Profile")] + public async Task ViewProfileContext(IUser user) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + await SendProfileCardAsync(user); + } + + [SlashCommand("join-date", "Show when you, or another member, joined the server")] + public async Task JoinDate( + [Summary("user", "The user whose join date you want to see (defaults to you)")] IUser? user = null) + { + var target = user ?? Context.User; + if (target is not SocketGuildUser guildUser || guildUser.JoinedAt is not { } joinedAt) + { + await Context.Interaction.RespondAsync("❌ Could not determine the join date for that user.", + ephemeral: true); + return; + } + + await Context.Interaction.RespondAsync($"{guildUser.Mention} joined **{joinedAt:dddd dd/MM/yyyy HH:mm:ss}**"); + } + + [SlashCommand("karma", "Explain what karma is")] + public async Task Karma() + { + await Context.Interaction.RespondAsync( + "Karma is tracked on your `/profile` which helps indicate how much you've helped others " + + "and provides a small increase in EXP gain."); + } + + private async Task SendProfileCardAsync(IUser user) + { + try + { + var profileCardPath = await ProfileCardService.GenerateProfileCard(user); + if (string.IsNullOrEmpty(profileCardPath)) + { + var failure = await Context.Interaction.FollowupAsync("❌ Failed to generate the profile card."); + await (failure.DeleteAfterTime(seconds: 10) ?? Task.CompletedTask); + return; + } + + // Matches the legacy !profile behaviour, which cleared the card after 3 minutes to limit clutter. + var card = await Context.Interaction.FollowupWithFileAsync(profileCardPath); + await (card.DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); + } + catch (Exception e) + { + await LoggingService.LogAction($"Error while generating profile card for {user.Username}.\nEx:{e}", + ExtendedLogSeverity.LowWarning); + } + } +} diff --git a/DiscordBot/Modules/Profiles/RankEmbedFormatter.cs b/DiscordBot/Modules/Profiles/RankEmbedFormatter.cs new file mode 100644 index 00000000..29e29074 --- /dev/null +++ b/DiscordBot/Modules/Profiles/RankEmbedFormatter.cs @@ -0,0 +1,33 @@ +using System.Text; + +namespace DiscordBot.Modules.Profiles; + +/// Builds the monospace body of a leaderboard embed. +public static class RankEmbedFormatter +{ + /// Name padding uses an en quad so it lines up inside the embed's monospace block. + private const char PaddingCharacter = '\u2000'; + + /// + /// Formats one line per row, padding rank and name so the values line up. Returns an empty string for + /// no rows, since the rank width is derived from the count with a logarithm. + /// + public static string BuildDescription(IReadOnlyList<(string Name, int Value)> rows, string labelName) + { + if (rows.Count == 0) + return string.Empty; + + var rankWidth = (int)Math.Floor(Math.Log10(rows.Count)) + 1; + var nameWidth = rows.Max(row => row.Name.Length); + var builder = new StringBuilder(); + + for (var i = 0; i < rows.Count; i++) + { + var rank = (i + 1).ToString().PadLeft(rankWidth); + var name = rows[i].Name.PadRight(nameWidth, PaddingCharacter); + builder.Append($"`{rank}.` **`{name}`** `{labelName}: {rows[i].Value}`\n"); + } + + return builder.ToString(); + } +} diff --git a/DiscordBot/Modules/Profiles/RankModule.cs b/DiscordBot/Modules/Profiles/RankModule.cs deleted file mode 100644 index b4bfa2e9..00000000 --- a/DiscordBot/Modules/Profiles/RankModule.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Discord.Commands; -using DiscordBot.Services; - -namespace DiscordBot.Modules.Profiles; - -[Group("UserModule"), Alias("")] -public class RankModule : ModuleBase -{ - public DatabaseService DatabaseService { get; set; } = null!; - public ILoggingService LoggingService { get; set; } = null!; - - [Command("Top"), Priority(6)] - [Summary("Display top 10 users by level.")] - [Alias("toplevel", "ranking")] - public async Task TopLevel() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopLevel(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.Level)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Level"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - [Command("TopKarma"), Priority(5)] - [Summary("Display top 10 users by karma.")] - [Alias("karmarank", "rankingkarma", "topk")] - public async Task TopKarma() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopKarma(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.Karma)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Karma"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - [Command("TopKarmaWeekly"), Priority(5)] - [Summary("Display weekly top 10 users by karma.")] - [Alias("karmarankweekly", "rankingkarmaweekly", "topkw")] - public async Task TopKarmaWeekly() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopKarmaWeekly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaWeekly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Weekly Karma"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - [Command("TopKarmaMonthly"), Priority(5)] - [Summary("Display monthly top 10 users by karma.")] - [Alias("karmarankmonthly", "rankingkarmamonthly", "topkm")] - public async Task TopKarmaMonthly() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopKarmaMonthly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaMonthly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Monthly Karma"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - [Command("TopKarmaYearly"), Priority(5)] - [Summary("Display yearly top 10 users by karma.")] - [Alias("karmaranktearly", "rankingkarmayearly", "topky")] - public async Task TopKarmaYearly() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopKarmaYearly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaYearly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Yearly Karma"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - private async Task GenerateRankEmbedFromList(List<(ulong userID, int value)> data, string labelName) - { - var embedBuilder = new EmbedBuilder - { - Title = $"Top 10 Users by {labelName}", - Footer = new EmbedFooterBuilder - { - Text = $"The best of the best, by {labelName}." - } - }; - - try - { - var maxUsernameLength = data - .Select(async x => await Context.Guild.GetUserAsync(x.userID)) - .Select(x => x.Result) - .Max(x => (x?.Username ?? "Unknown User").Length); - - var str = ""; - for (var i = 0; i < data.Count; i++) - { - var user = await Context.Guild.GetUserAsync(data[i].userID); - var username = user?.Username ?? "Unknown User"; - int rankPadding = (int)Math.Floor(Math.Log10(data.Count)); - - str += - $"`{(i + 1).ToString().PadLeft(rankPadding + 1)}.` **`{username.PadRight(maxUsernameLength, '\u2000')}`** `{labelName}: {data[i].value}`\n"; - } - - embedBuilder.Description = str; - } - catch (Exception e) - { - await LoggingService.LogChannelAndFile($"Failed to generate top 10 embed.\n{e}", ExtendedLogSeverity.LowWarning); - embedBuilder.Description = "Failed to generate top 10 embed."; - } - - return embedBuilder.Build(); - } -} diff --git a/DiscordBot/Modules/Profiles/RankSlashModule.cs b/DiscordBot/Modules/Profiles/RankSlashModule.cs new file mode 100644 index 00000000..8c1c7929 --- /dev/null +++ b/DiscordBot/Modules/Profiles/RankSlashModule.cs @@ -0,0 +1,127 @@ +using Discord.Interactions; +using DiscordBot.Services; + +namespace DiscordBot.Modules.Profiles; + +public class RankSlashModule : InteractionModuleBase +{ + private const int TopCount = 10; + private const string UnknownUser = "Unknown User"; + + private const string IntervalAllTime = "all"; + private const string IntervalWeek = "week"; + private const string IntervalMonth = "month"; + private const string IntervalYear = "year"; + + public DatabaseService DatabaseService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; + + [SlashCommand("top", "Show the top 10 users by level")] + public async Task TopLevel() + { + var query = DatabaseService.Query; + if (query == null) + { + await Context.Interaction.RespondAsync("❌ The leaderboard is unavailable right now.", ephemeral: true); + return; + } + + await SendRankingAsync(async () => + { + var users = await query.GetTopLevel(TopCount); + return ("Level", users.Select(user => (ulong.Parse(user.UserID), user.Level)).ToList()); + }); + } + + [SlashCommand("top-karma", "Show the top 10 users by karma")] + public async Task TopKarma( + [Summary("interval", "Karma interval to rank (default: all time)")] + [Choice("All time", IntervalAllTime)] + [Choice("Week", IntervalWeek)] + [Choice("Month", IntervalMonth)] + [Choice("Year", IntervalYear)] + string interval = IntervalAllTime) + { + var query = DatabaseService.Query; + if (query == null) + { + await Context.Interaction.RespondAsync("❌ The leaderboard is unavailable right now.", ephemeral: true); + return; + } + + await SendRankingAsync(() => FetchKarmaRankingAsync(query, interval)); + } + + private static async Task<(string Label, List<(ulong UserId, int Value)> Rows)> FetchKarmaRankingAsync( + IServerUserRepo query, string interval) + { + switch (interval) + { + case IntervalWeek: + { + var users = await query.GetTopKarmaWeekly(TopCount); + return ("Weekly Karma", users.Select(user => (ulong.Parse(user.UserID), user.KarmaWeekly)).ToList()); + } + case IntervalMonth: + { + var users = await query.GetTopKarmaMonthly(TopCount); + return ("Monthly Karma", users.Select(user => (ulong.Parse(user.UserID), user.KarmaMonthly)).ToList()); + } + case IntervalYear: + { + var users = await query.GetTopKarmaYearly(TopCount); + return ("Yearly Karma", users.Select(user => (ulong.Parse(user.UserID), user.KarmaYearly)).ToList()); + } + default: + { + var users = await query.GetTopKarma(TopCount); + return ("Karma", users.Select(user => (ulong.Parse(user.UserID), user.Karma)).ToList()); + } + } + } + + private async Task SendRankingAsync(Func Rows)>> fetch) + { + Embed embed; + try + { + var (label, rows) = await fetch(); + embed = await BuildRankEmbedAsync(rows, label); + } + catch (Exception e) + { + // Without this the interaction would never be answered and Discord would show a timeout. + await LoggingService.LogChannelAndFile($"Failed to generate a leaderboard embed.\n{e}", + ExtendedLogSeverity.LowWarning); + await Context.Interaction.RespondAsync("❌ Could not build the leaderboard right now.", ephemeral: true); + return; + } + + // Neither RespondAsync overload hands back the message, so the original response is fetched to + // schedule the same one minute cleanup the text command used. + await Context.Interaction.RespondAsync(embed: embed); + var message = await Context.Interaction.GetOriginalResponseAsync(); + await (message.DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); + } + + private async Task BuildRankEmbedAsync(IReadOnlyList<(ulong UserId, int Value)> rows, string labelName) + { + var formatted = new List<(string Name, int Value)>(rows.Count); + foreach (var (userId, value) in rows) + { + // SocketGuild only exposes the asynchronous lookup through IGuild. + var user = await ((IGuild)Context.Guild).GetUserAsync(userId); + formatted.Add((user?.Username ?? UnknownUser, value)); + } + + return new EmbedBuilder + { + Title = $"Top {TopCount} Users by {labelName}", + Description = RankEmbedFormatter.BuildDescription(formatted, labelName), + Footer = new EmbedFooterBuilder + { + Text = $"The best of the best, by {labelName}." + } + }.Build(); + } +} diff --git a/DiscordBot/Modules/Server/ServerSlashModule.cs b/DiscordBot/Modules/Server/ServerSlashModule.cs index 00087e50..92c73217 100644 --- a/DiscordBot/Modules/Server/ServerSlashModule.cs +++ b/DiscordBot/Modules/Server/ServerSlashModule.cs @@ -111,4 +111,13 @@ public async Task ReturnInvite() { await Context.Interaction.RespondAsync(text: BotSettings.Invite, ephemeral: true); } + + [SlashCommand("members", "Current member count")] + public async Task MemberCount() + { + var members = await Context.Guild.GetUsersAsync(); + + await Context.Interaction.RespondAsync( + $"We currently have {members.Count - 1} members. Let's keep on growing as the strong community we are :muscle:"); + } } diff --git a/DiscordBot/Modules/Utils/ConvertModule.cs b/DiscordBot/Modules/Utils/ConvertModule.cs deleted file mode 100644 index 29544dca..00000000 --- a/DiscordBot/Modules/Utils/ConvertModule.cs +++ /dev/null @@ -1,102 +0,0 @@ -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 : !ctof 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); - bool toValid = await CurrencyService.IsCurrency(to); - - 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/ConvertSlashModule.cs b/DiscordBot/Modules/Utils/ConvertSlashModule.cs new file mode 100644 index 00000000..96ecfadc --- /dev/null +++ b/DiscordBot/Modules/Utils/ConvertSlashModule.cs @@ -0,0 +1,70 @@ +using Discord.Interactions; +using DiscordBot.Utils; + +namespace DiscordBot.Modules.Utils; + +public class ConvertSlashModule : InteractionModuleBase +{ + private const string DefaultCurrency = "usd"; + private const float ConversionError = -1; + + public CurrencyService CurrencyService { get; set; } = null!; + + [SlashCommand("ftoc", "Convert a temperature from fahrenheit to celsius")] + public async Task FahrenheitToCelsius( + [Summary("fahrenheit", "Temperature in degrees fahrenheit")] double fahrenheit) + { + var celsius = MathUtility.FahrenheitToCelsius((float)fahrenheit); + if (!float.IsFinite(celsius)) + { + await Context.Interaction.RespondAsync("❌ That temperature is out of range.", ephemeral: true); + return; + } + + await Context.Interaction.RespondAsync($"{fahrenheit}°F is {celsius}°C."); + } + + [SlashCommand("ctof", "Convert a temperature from celsius to fahrenheit")] + public async Task CelsiusToFahrenheit( + [Summary("celsius", "Temperature in degrees celsius")] double celsius) + { + var fahrenheit = MathUtility.CelsiusToFahrenheit((float)celsius); + if (!float.IsFinite(fahrenheit)) + { + await Context.Interaction.RespondAsync("❌ That temperature is out of range.", ephemeral: true); + return; + } + + await Context.Interaction.RespondAsync($"{celsius}°C is {fahrenheit}°F."); + } + + [SlashCommand("curr", "Convert an amount from one currency to another")] + public async Task ConvertCurrency( + [Summary("from", "Currency code to convert from, for example USD")] + [Autocomplete(typeof(CurrencyAutocompleteHandler))] string from, + [Summary("amount", "Amount to convert (default: 1)")] double amount = 1, + [Summary("to", "Currency code to convert to (default: USD)")] + [Autocomplete(typeof(CurrencyAutocompleteHandler))] string to = DefaultCurrency) + { + from = from.Trim().ToLowerInvariant(); + to = to.Trim().ToLowerInvariant(); + + if (!await CurrencyService.IsCurrency(from) || !await CurrencyService.IsCurrency(to)) + { + await Context.Interaction.RespondAsync("❌ One of the currencies provided is invalid.", ephemeral: true); + return; + } + + var rate = await CurrencyService.GetConversion(to, from); + if (Math.Abs(rate - ConversionError) < 0.01) + { + await Context.Interaction.RespondAsync("❌ An error occured while converting the currency, the API may be down!", + ephemeral: true); + return; + } + + var totalAmount = Math.Round(amount * rate, 2); + await Context.Interaction.RespondAsync( + $"**{amount} {from.ToUpperInvariant()}** = **{totalAmount} {to.ToUpperInvariant()}**"); + } +} diff --git a/DiscordBot/Modules/Utils/CurrencyAutocompleteHandler.cs b/DiscordBot/Modules/Utils/CurrencyAutocompleteHandler.cs new file mode 100644 index 00000000..832bbc6c --- /dev/null +++ b/DiscordBot/Modules/Utils/CurrencyAutocompleteHandler.cs @@ -0,0 +1,47 @@ +using Discord.Interactions; + +namespace DiscordBot.Modules.Utils; + +/// Suggests currency codes for the options of . +public class CurrencyAutocompleteHandler : AutocompleteHandler +{ + /// Discord rejects an autocomplete response holding more than 25 choices. + private const int MaxSuggestions = 25; + + public CurrencyService CurrencyService { get; set; } = null!; + + public override async Task GenerateSuggestionsAsync( + IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, + IParameterInfo parameter, IServiceProvider services) + { + var input = autocompleteInteraction.Data.Current.Value as string ?? string.Empty; + var currencies = await CurrencyService.GetCurrenciesAsync(); + + return AutocompletionResult.FromSuccess(BuildSuggestions(currencies, input)); + } + + /// + /// Matches currencies whose code starts with , or whose name contains it, so + /// that both sek and krona find the Swedish krona. + /// + public static IReadOnlyList BuildSuggestions( + IReadOnlyDictionary currencies, string? input) + { + var query = input?.Trim() ?? string.Empty; + + var codeMatches = new List(); + var nameMatches = new List(); + + foreach (var (code, name) in currencies.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + var result = new AutocompleteResult($"{code.ToUpperInvariant()} - {name}", code); + + if (code.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + codeMatches.Add(result); + else if (name.Contains(query, StringComparison.OrdinalIgnoreCase)) + nameMatches.Add(result); + } + + return codeMatches.Concat(nameMatches).Take(MaxSuggestions).ToList(); + } +} diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 915020b4..b3bf6eb0 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -58,7 +58,12 @@ private async Task MainAsync() // so we need to make sure we only initialize commands and such for the bot once if it manages to re-establish connection if (Interlocked.CompareExchange(ref _isInitialized, 1, 0) != 0) return Task.CompletedTask; - _interactionService = new InteractionService(_client); + // Slash command option autocomplete relies on EnableAutocompleteHandlers, which defaults to + // true. It is set explicitly so a library default change cannot silently disable it. + _interactionService = new InteractionService(_client, new InteractionServiceConfig + { + EnableAutocompleteHandlers = true + }); _commandService = new CommandService(new CommandServiceConfig { CaseSensitiveCommands = false, diff --git a/DiscordBot/Services/Utils/CurrencyService.cs b/DiscordBot/Services/Utils/CurrencyService.cs index 770fffe1..1f752f0d 100644 --- a/DiscordBot/Services/Utils/CurrencyService.cs +++ b/DiscordBot/Services/Utils/CurrencyService.cs @@ -24,6 +24,7 @@ private class Currency #endregion // Configuration private readonly Dictionary _currencies = new(); + private readonly SemaphoreSlim _buildLock = new(1, 1); private static readonly string ApiUrl = $"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{TargetDate}/v{ApiVersion}/"; @@ -54,19 +55,17 @@ public async Task GetConversion(string toCurrency, string fromCurrency = #region Public Methods - public async Task GetCurrencyName(string currency) + /// Every supported currency, keyed by lowercase code, with its display name. + public async Task> GetCurrenciesAsync() { - currency = currency.ToLower(); - if (!await IsCurrency(currency)) - return string.Empty; - return _currencies[currency].Name; + await EnsureCurrencyListAsync(); + return _currencies.ToDictionary(pair => pair.Key, pair => pair.Value.Name); } // Checks if a provided currency is valid, it also checks is we have a list of currencies to check against and rebuilds it if not. (If the API was down when bot started) public async Task IsCurrency(string currency) { - if (_currencies.Count <= 1) - await BuildCurrencyList(); + await EnsureCurrencyListAsync(); return _currencies.ContainsKey(currency); } @@ -74,6 +73,27 @@ public async Task IsCurrency(string currency) #region Private Methods + /// + /// Builds the currency list when it is missing. Autocomplete and command handling can call this + /// concurrently, so the build runs under a lock and is only entered once. + /// + private async Task EnsureCurrencyListAsync() + { + if (_currencies.Count > 1) + return; + + await _buildLock.WaitAsync(); + try + { + if (_currencies.Count <= 1) + await BuildCurrencyList(); + } + finally + { + _buildLock.Release(); + } + } + private async Task BuildCurrencyList() { var url = ApiUrl + ValidCurrenciesEndpoint; @@ -84,11 +104,12 @@ private async Task BuildCurrencyList() // Json is weird format of `Code: Name` each in dependant ie; {"1inch":"1inch Network","aave":"Aave"} foreach (var currency in currencies) { - _currencies.Add(currency.Key, new Currency + // Assigned rather than added so a rebuild can never throw on an existing key. + _currencies[currency.Key] = new Currency { Name = currency.Value!.ToString(), Short = currency.Key - }); + }; } LoggingService.LogToConsole($"[{ServiceName}] Built currency list with {_currencies.Count} currencies.", ExtendedLogSeverity.Positive); diff --git a/docs/codebase.md b/docs/codebase.md index 7ce084f6..d788573f 100644 --- a/docs/codebase.md +++ b/docs/codebase.md @@ -42,8 +42,9 @@ DiscordBot/ ├── Domain/ # Domain models and game logic │ ├── ProfileData.cs │ ├── RectangleD.cs -│ └── Casino/ # Casino game abstractions and implementations -│ └── Games/ # Blackjack, Poker, RPS game logic +│ ├── Casino/ # Casino game abstractions and implementations +│ │ └── Games/ # Blackjack, Poker, RPS game logic +│ └── Dice/ # Dice notation parsing and rolling │ ├── Extensions/ # Extension methods and repository helpers │ ├── CasinoRepository.cs # Casino DB queries @@ -54,8 +55,9 @@ DiscordBot/ │ ├── Modules/ # Discord command handlers (text + slash) │ ├── Profiles/ # User profile, rank & birthday commands -│ │ ├── ProfileModule.cs -│ │ ├── RankModule.cs +│ │ ├── ProfileSlashModule.cs +│ │ ├── RankSlashModule.cs +│ │ ├── RankEmbedFormatter.cs │ │ └── BirthdayModule.cs │ ├── Server/ # Server management, embeds, quotes, reminders, tickets │ │ ├── ServerModule.cs / ServerSlashModule.cs @@ -65,12 +67,14 @@ DiscordBot/ │ │ ├── QuoteModule.cs │ │ └── ReminderModule.cs │ ├── Fun/ # Entertainment & games -│ │ ├── FunModule.cs +│ │ ├── FunSlashModule.cs +│ │ ├── FunRollFormatter.cs │ │ ├── DuelSlashModule.cs │ │ └── Casino/ # Casino slash commands │ ├── Utils/ # Search, conversion, flights, weather │ │ ├── SearchModule.cs -│ │ ├── ConvertModule.cs +│ │ ├── ConvertSlashModule.cs +│ │ ├── CurrencyAutocompleteHandler.cs │ │ ├── AirportModule.cs │ │ └── Weather/ # Weather commands │ └── Code/ # Coding tips, Unity help @@ -138,10 +142,10 @@ DiscordBot/ | Domain | Modules | Services | |--------|---------|----------| -| **Profiles** | ProfileModule, RankModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | +| **Profiles** | ProfileSlashModule, RankSlashModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | | **Server** | ServerModule, ServerSlashModule, TicketModule, RulesModule, EmbedModule, QuoteModule, ReminderModule | ServerService, WelcomeService, AuditLogService, EveryoneScoldService, EmbedParsingService, ReminderService, RecruitService | -| **Fun** | FunModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | -| **Utils** | SearchModule, ConvertModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | +| **Fun** | FunSlashModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | +| **Utils** | SearchModule, ConvertSlashModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | | **Code** | CodeTipModule, TipModule, Unity/UnityHelp/ | CodeCheckService, Tips/, Unity/ (feeds, docs, UnityHelp/) | ### Testing diff --git a/docs/features.md b/docs/features.md index afdb0929..8116ac25 100644 --- a/docs/features.md +++ b/docs/features.md @@ -4,7 +4,7 @@ | Feature | Description | Module(s) | Service(s) | Criticality | | --------- | ------------- | ----------- | ------------ | ------------- | -| **User Profiles** | XP/level system, karma tracking, profile cards with customizable skins | `ProfileModule`, `RankModule` | `XpService`, `KarmaService`, `ProfileCardService`, `UserExtendedService` | Core | +| **User Profiles** | XP/level system, karma tracking, profile cards with customizable skins (`/profile [user]` or the **View Profile** user context-menu command), join dates (`/join-date [user]`), karma explainer (`/karma`), and level/karma leaderboards (`/top`, `/top-karma [interval]`) | `ProfileSlashModule`, `RankSlashModule` | `ProfileCardService`, `XpService`, `KarmaService`, `UserExtendedService` | Core | | **Command Handling** | Text command + slash command routing, history tracking, prefix config | — | `CommandHandlingService` | Core | | **Logging** | Multi-destination logging (console, file, Discord channel) with severity levels | — | `LoggingService` | Core | | **Database** | PostgreSQL connection pooling, user/casino repositories | — | `DatabaseService` | Core | @@ -13,21 +13,21 @@ | **Welcome** | New-member welcome messages | — | `WelcomeService` | Core | | **Code Assistance** | Code-block formatting checks/reminders and code tips | `CodeTipModule` | `CodeCheckService` | Feature | | **Tips** | Searchable tip database with image support, keyword lookups | `TipModule` | `TipService` | Feature | -| **Casino** | Token economy, Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | -| **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, `Assign Badge`) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | -| **Duels** | Player-vs-player duels with configurable stakes | `DuelSlashModule` | `DuelService` | Feature | -| **Fun** | Slap, coin flip, dice rolls (including D&D format) | `FunModule` | — | Feature | +| **Casino** | Token economy, token gifting (`/casino tokens gift` or the **Gift Tokens** user context-menu command, which prompts for the amount), Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) with user context-menu counterparts (**View Casino History**, and **Set Tokens** / **Add Tokens**, which prompt for the amount) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | +| **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, plus `Assign Badge` and `Remove Badge`, which both pick one or more of the target's badges from a multi-select menu) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | +| **Duels** | Player-vs-player duels with configurable stakes, available as `/duel` or the **Duel** user context-menu command (non-mute only) | `DuelSlashModule` | `DuelService` | Feature | +| **Fun** | Slap (`/slap` with up to 5 targets), coin flip (`/coinflip`), and dice rolls (`/roll`, including additive D&D notation such as `2d6+4` and `1d20+1d4-1`) | `FunSlashModule` | — | Feature | | **Search** | Documentation, manual, and wiki lookups | `SearchModule` | `SearchService` | Feature | | **Weather** | Temperature, conditions, air quality, local time via OpenWeatherMap | `WeatherModule` | `WeatherService` | Feature | -| **Conversion** | Currency, temperature, and unit conversion | `ConvertModule` | `CurrencyService` | Feature | +| **Conversion** | Temperature (`/ftoc`, `/ctof`) and currency conversion (`/curr`, with code autocomplete) | `ConvertSlashModule` | `CurrencyService` | Feature | | **Flight Data** | Airport and flight lookups | `AirportModule` | `AirportService` | Feature | | **Reminders** | Persistent scheduled reminders with natural time parsing | `ReminderModule` | `ReminderService` | Feature | | **Quotes** | Quote a message by ID into the current channel | `QuoteModule` | — | Feature | | **Rules** | Server/global rules and channel listings | `RulesModule` | — | Feature | -| **Server Utilities** | Help, ping, member count, and server info | `ServerModule`, `ServerSlashModule` | `ServerService` | Feature | +| **Server Utilities** | Help, ping, member count (`/members`), and server info | `ServerModule`, `ServerSlashModule` | `ServerService` | Feature | | **Tickets** | Private complaint/support ticket channels | `TicketModule` | — | Feature | | **Embed Builder** | Generate embeds from messages or hastebin URLs | `EmbedModule` | `EmbedParsingService` | Feature | -| **Birthday Announcements** | Scheduled birthday notifications (configurable interval), plus birthday command management (`/bday set`, `/bday del`, admin-only `/admin bday set-user`, `/admin bday del-user`, `/admin bday list`) | `BirthdayModule`, `BirthdaySlashModule`, `AdminSlashModule` | `BirthdayAnnouncementService` | Feature | +| **Birthday Announcements** | Scheduled birthday notifications (configurable interval), plus birthday command management (`/bday show [count] [user]` lists upcoming birthdays or a specific member's birthday, `/bday set`, `/bday del`, the **View Birthday**, **Set Birthday**, and **Remove Birthday** user context-menu commands — removal asks for confirmation via buttons, admin-only `/admin bday set-user`, `/admin bday del-user`, `/admin bday list`) | `BirthdayModule`, `BirthdaySlashModule`, `AdminSlashModule` | `BirthdayAnnouncementService` | Feature | | **Recruitment** | Configurable recruitment workflow (toggleable) | — | `RecruitService` | Feature | | **Release Feeds** | RSS feed parsing and Unity release-notes tracking | — | `FeedService`, `ReleaseNotesParser` | Feature | | **@everyone Scold** | Warns users who use `@everyone`/`@here` without permission | — | `EveryoneScoldService` | Feature | diff --git a/docs/plans/done/assign-badge-select-menu.md b/docs/plans/done/assign-badge-select-menu.md new file mode 100644 index 00000000..c0acb39f --- /dev/null +++ b/docs/plans/done/assign-badge-select-menu.md @@ -0,0 +1,38 @@ +# Assign Badge Select Menu + +Replace the **Assign Badge** modal with the select menu that **Remove Badge** already uses. + +## Goal + +The Assign Badge user context command currently opens `AssignBadgeModal`, where the administrator types a +badge ID or title. Remove Badge instead shows a runtime-built select menu of the target's badges. This +change makes Assign Badge use the same interaction. + +## Decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| Control | Message select menu, not a modal select | Modal select options are declared at compile time, so a runtime badge list cannot be shown in one. Remove Badge documents the same constraint. | +| Listed badges | Only badges the target does not already have | Owning a badge makes assignment fail, so offering it would only produce an error. | +| Selection count | Multiple | Matches Remove Badge, so both commands behave the same way. | +| Ordering | Natural title order, then id | Same ordering as the badge list and the Remove Badge options. | +| Overflow | First 25 options plus a notice | Discord caps select menus at 25 options. The notice points at `/admin badge assign`, mirroring how Remove Badge points at `/admin badge remove`. | +| Text sanitizing | Shared helper | `AdminSlashModule.SanitizeSelectText` becomes `StringExtensions.ToSelectOptionText` so both handlers use one implementation instead of two copies. | + +## Notes + +- `AssignBadgeModal` is deleted along with `ResolveBadgeByIdentifier` and `BadgeIdentifierMaxLength`, which + only existed to resolve free text typed into the modal. +- The confirmation embed for a single assignment is replaced by a summary line listing assigned and failed + badges, matching the Remove Badge handler. +- The custom id carries the requester and target ids, and the handler rejects anyone but the original + requester. + +## Subtasks + +- [x] Add `ToSelectOptionText` to `StringExtensions` and use it from `AdminSlashModule`. +- [x] Rewrite `AssignBadgeContext` to build and send a select menu. +- [x] Add the component handler for the assign select menu. +- [x] Delete `AssignBadgeModal`, `ResolveBadgeByIdentifier` and `BadgeIdentifierMaxLength`. +- [x] Build and run the test suite. +- [x] Update `docs/features.md` if the badge feature description mentions the modal. diff --git a/docs/plans/done/convert-slash-commands.md b/docs/plans/done/convert-slash-commands.md new file mode 100644 index 00000000..a90b384b --- /dev/null +++ b/docs/plans/done/convert-slash-commands.md @@ -0,0 +1,48 @@ +# Conversion Slash Commands + +Replace the conversion text commands with slash commands and delete the rest of the module. + +## Goal + +- `/ftoc`, `/ctof` and `/curr` replace `!ftoc`, `!ctof` and `!curr` (both `!currency` overloads). +- The remaining commands in the module (`!translate` and `!currencyname`) are deleted outright. +- `/curr` autocompletes currency codes. + +## Decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| Command name | `/curr` | Explicitly requested over `/currency`. | +| `!currencyname` / `!currname` | Deleted, not ported | Requested. `CurrencyService.GetCurrencyName` therefore loses its only caller and is removed, superseded by the autocomplete accessor. | +| `!translate` | Deleted, not ported | Requested. It read either a message id or raw text, so it was never a good slash command. | +| Self-mention | Dropped | Slash replies are already attributed to the invoker by Discord's UI. | +| Legacy mention guard | Dropped | `HasAnyPingableMention` guarded against `@` spam in a text command; slash options are typed strings, so it cannot trigger. | +| Option order for `/curr` | `from` (required), then `amount`, then `to` | Discord rejects a required option that follows an optional one, so the required currency must come first. | +| Autocomplete matching | Code prefix or name substring | Lets `krona` find `SEK` as well as `sek` finding it. | + +## Notes + +- `CurrencyService` caches its currency list lazily and rebuilds it when the API was down at startup. + Autocomplete can call that path concurrently, so the lazy build is now serialised behind a semaphore + with a double-checked guard, and the dictionary is filled with the indexer so a rebuild cannot throw + a duplicate-key exception. +- `Program.cs` sets `EnableAutocompleteHandlers` explicitly. It defaults to true, but the option is now a + hard dependency of `/curr`, so the default is not relied upon. +- Only the suggestion building is unit tested; the service's network path stays untested, matching how it + was before this change. +- Conversion replies become public (matching the legacy text command) and errors are ephemeral. +- The `_currencies.Count <= 1` rebuild threshold is carried over from the original `IsCurrency`. It is kept + rather than tightened to `Count == 0` because the API returns hundreds of currencies, so the case it + would help with cannot occur, and keeping it avoids changing unrelated behaviour. +- `_buildLock` is not disposed. The service is a process-lifetime singleton, so disposal would never run + before exit. + +## Subtasks + +- [x] Add a currency accessor to `CurrencyService` and make its lazy build thread safe. +- [x] Add `CurrencyAutocompleteHandler` with testable suggestion building. +- [x] Add `ConvertSlashModule` with `/ftoc`, `/ctof` and `/curr`. +- [x] Delete `Modules/Utils/ConvertModule.cs`. +- [x] Add unit tests for suggestion building. +- [x] Build and run the test suite. +- [x] Update `docs/features.md` and `docs/codebase.md`. diff --git a/docs/plans/done/fun-slash-commands.md b/docs/plans/done/fun-slash-commands.md new file mode 100644 index 00000000..8dce280e --- /dev/null +++ b/docs/plans/done/fun-slash-commands.md @@ -0,0 +1,57 @@ +# Fun Slash Commands + +Convert the remaining "fun" text commands to slash commands and drop the prefix versions. + +## Goal + +- `/coinflip`, `/roll`, `/slap` replace `!coinflip`, `!roll`, `!slap` and `!d20`. +- `/roll` gains flat additive modifiers so expressions like `2d6+4` work. +- No prefix version of these commands survives. + +## Decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| `!d20` | Removed, not ported | `/roll 1d20` covers it; `D20` was only a thin wrapper around `RollDice("1d20", needed)`. | +| Modifier scope | Additive terms only | Terms are `NdM` dice or flat constants joined by `+`/`-`. No keep/drop, exploding, reroll or success-counting. | +| `/slap` targets | Up to 5 optional `user` options | Discord has no varargs; 5 is the agreed arity. | +| `/slap` context menu | Not added | Explicitly out of scope. | +| Roll display | Every die is shown | No keep/drop is implemented, so there is nothing to mark as dropped; per-term dice and the flat modifier are both displayed. | +| Legacy bare number | Preserved | A lone bare number stays "sides of one die" (`20` -> `1d20`). A bare number alongside dice terms is a flat constant (`2d6+4`). | +| Roll reply wording | Lives in `FunRollFormatter` | Keeps `DiceCountWords` and the message text out of the module and under test. | + +## Parsing rules + +- Whitespace is stripped before parsing, so `2d6 + 4` is accepted. +- Term forms: `NdM`, `dM`, `N` (flat constant). +- Bounds: 1-10 dice per term, 2-1000 sides, at most 10 terms, flat modifier within +/-1000. +- The expression must contain at least one dice term, so `4+4` is rejected rather than silently treating both as constants. +- Defaults mirror the legacy parser: `d20` is 1d20, `2d6` is two d6. + +## Subtasks + +- [x] Add `Domain/Dice/DiceExpression.cs` parser plus roll result type. +- [x] Add `DiscordBot.Tests/Domain/Dice/DiceExpressionTests.cs` covering parsing and evaluation. +- [x] Add `Modules/Fun/FunSlashModule.cs` with `/coinflip`, `/roll` and `/slap`. +- [x] Delete `Modules/Fun/FunModule.cs`. +- [x] Build and run the test suite. +- [x] Update `docs/features.md` and `docs/codebase.md`. +- [x] Extract roll reply formatting to `FunRollFormatter` and cover it with `FunRollFormatterTests`. + +## Review follow-ups + +- Roll reply for an expression with a modifier marks the die rather than the total, so a natural 20 on + `1d20+5` reads `showing [20] (natural) + 5 for a total of **25**!`. +- `/slap` reads its `FuzzTable` at most once per process. Modules are instantiated per invocation, so the + guard is `static`; otherwise every slap would repeat the load and spend a `LogChannelAndFile` Discord call + inside the three second interaction window. +- `/slap` options are named `target1` through `target5` for consistency in the Discord picker. + +## Notes + +- Slap tables stay lazy-loaded static `FuzzTable` instances in the module, preserving the existing + `Settings.FunCommands` config (`SlapObjectsTable`, `SlapChoices`, `SlapFails`). +- `!slap`'s two hardcoded self-slap user IDs are preserved as-is. +- Errors are ephemeral replies; successful rolls and slaps stay public to match legacy behaviour. +- Legacy replies carried the emoji derived from the `needed` target (`:game_die:`, `:white_check_mark:`, `:x:`); + that decoration stays in the module rather than in `FunRollFormatter`. diff --git a/docs/plans/done/rank-profile-server-slash-commands.md b/docs/plans/done/rank-profile-server-slash-commands.md new file mode 100644 index 00000000..20f38ca2 --- /dev/null +++ b/docs/plans/done/rank-profile-server-slash-commands.md @@ -0,0 +1,55 @@ +# Rank, Profile And Server Slash Commands + +Convert the remaining account and leaderboard text commands to slash commands. + +## Goal + +- `/members` replaces `!members`. +- `/join-date [user]` replaces `!join-date`, gaining an optional target. +- `/karma` replaces `!karma`. +- `/top` replaces `!top`. +- `/top-karma [interval]` replaces `!topk`, `!topkw`, `!topkm` and `!topky`. + +## Decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| `/karma` | Static explainer | `!karma` only ever printed an explanation, so the conversion is faithful. The stale `!profile` reference in the text is updated to `/profile`. | +| `/join-date` target | Optional `user` | Requested. Mirrors the existing `/profile [user]`. | +| `/top-karma` interval | `Choice` option defaulting to all time | Collapses four commands into one. Values match the legacy aliases: `week`, `month`, `year`, plus omitted meaning all time. | +| Leaderboard retry behaviour | Errors answered ephemerally | The legacy code returned without replying when the database query was unavailable, which would leave a slash command hanging. | +| Leaderboard lifetime | Still deleted after one minute | Preserves legacy behaviour. | + +## Notes + +- `RankModule` and `ProfileModule` both lose all of their commands, so both files are deleted. `ProfileModule` + was already reduced to `!karma`, `!join-date` and the copy of `!profile` that `ProfileSlashModule` replaced. +- Row formatting moves to `RankEmbedFormatter` so the padding rules are unit tested. The legacy width + calculation was `floor(log10(count)) + 1`, which is correct for aligning ranks 1 through 10, and is kept. +- The legacy embed builder blocked on async work inside a LINQ `Select` via `.Result`. The rewrite awaits + the username lookups directly. +- `/top` keeps a fixed 10 rows, matching the legacy commands. + +## Deliberate differences from the text commands + +- `/karma` drops the legacy `{preferred name}, ` prefix. Slash replies are already attributed to the invoker, + which is the same reason the conversion commands dropped their self-mention. +- Both `!karma` and `!join-date` deleted `Context.Message`, which in `Discord.Net.Commands` is the message + that *triggered* the command, not the bot's reply: `ServerModule.DisplayHelp` still relies on this when it + ends with `Context.Message.DeleteAsync()`. Slash commands have no invoking message, so there is nothing + left to delete and no reply deletion has been lost. The reply to `!karma` was always kept. +- `/join-date` addresses the target in the third person, because the option can now name someone other than + the invoker. +- `/join-date` uses `yyyy` instead of the legacy `yyy`. Both render a four digit year; `yyyy` is the + correct format specifier. + +## Subtasks + +- [x] Add `RankEmbedFormatter` with testable row formatting. +- [x] Add `RankSlashModule` with `/top` and `/top-karma [interval]`. +- [x] Add `/join-date` and `/karma` to `ProfileSlashModule`. +- [x] Add `/members` to `ServerSlashModule`. +- [x] Delete `RankModule.cs` and `ProfileModule.cs`. +- [x] Add unit tests for `RankEmbedFormatter`. +- [x] Build and run the test suite. +- [x] Update `docs/features.md` and `docs/codebase.md`.