diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ba610bc3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*.cs] +# Unused private members +dotnet_diagnostic.IDE0051.severity = warning +dotnet_diagnostic.IDE0052.severity = warning + +# Discord.Net discovers command handlers via reflection — they appear unused to static analysis +[DiscordBot/Modules/**.cs] +dotnet_diagnostic.IDE0051.severity = none diff --git a/.github/workflows/Dotnet.yml b/.github/workflows/ci.yml similarity index 58% rename from .github/workflows/Dotnet.yml rename to .github/workflows/ci.yml index 45d91c75..be48ce22 100644 --- a/.github/workflows/Dotnet.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: .NET Build +name: .NET Build & Test on: pull_request: @@ -7,6 +7,10 @@ on: permissions: contents: read +concurrency: + group: dotnet-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: build: name: Build & Test @@ -15,13 +19,16 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET Core + - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - - name: Install dependencies + - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal diff --git a/DiscordBot.Tests/DiscordBot.Tests.csproj b/DiscordBot.Tests/DiscordBot.Tests.csproj new file mode 100644 index 00000000..be444c4d --- /dev/null +++ b/DiscordBot.Tests/DiscordBot.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/DiscordBot.Tests/Domain/Casino/BlackjackHelperTests.cs b/DiscordBot.Tests/Domain/Casino/BlackjackHelperTests.cs new file mode 100644 index 00000000..0895c578 --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/BlackjackHelperTests.cs @@ -0,0 +1,126 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class BlackjackHelperTests +{ + private static Card C(int value, CardSuit suit = CardSuit.Hearts) => new(value, suit); + + [Fact] + public void CalculateHandValue_SimpleHand() + { + var cards = new List { C(5), C(6) }; + Assert.Equal(11, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_FaceCards_Worth10() + { + var cards = new List { C(11), C(12) }; + Assert.Equal(20, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_AceAs11() + { + var cards = new List { C(1), C(5) }; + Assert.Equal(16, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_AceReducedTo1WhenBusting() + { + var cards = new List { C(1), C(10), C(5) }; + Assert.Equal(16, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_TwoAces() + { + var cards = new List { C(1), C(1) }; + Assert.Equal(12, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_TwoAcesAndNine_Makes21() + { + var cards = new List { C(1), C(1), C(9) }; + Assert.Equal(21, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_EmptyHand_ReturnsZero() + { + Assert.Equal(0, BlackjackHelper.CalculateHandValue([])); + } + + [Fact] + public void IsBlackjack_AceAndKing_True() + { + var cards = new List { C(1), C(13) }; + Assert.True(BlackjackHelper.IsBlackjack(cards)); + } + + [Fact] + public void IsBlackjack_AceAndTen_True() + { + var cards = new List { C(1), C(10) }; + Assert.True(BlackjackHelper.IsBlackjack(cards)); + } + + [Fact] + public void IsBlackjack_ThreeCardsTotaling21_False() + { + var cards = new List { C(7), C(7), C(7) }; + Assert.False(BlackjackHelper.IsBlackjack(cards)); + } + + [Fact] + public void IsBlackjack_TwoCardsNot21_False() + { + var cards = new List { C(10), C(9) }; + Assert.False(BlackjackHelper.IsBlackjack(cards)); + } + + [Fact] + public void IsBusted_Over21_True() + { + var cards = new List { C(10), C(10), C(5) }; + Assert.True(BlackjackHelper.IsBusted(cards)); + } + + [Fact] + public void IsBusted_Exactly21_False() + { + var cards = new List { C(10), C(10), C(1) }; + Assert.False(BlackjackHelper.IsBusted(cards)); + } + + [Fact] + public void IsBusted_Under21_False() + { + var cards = new List { C(5), C(6) }; + Assert.False(BlackjackHelper.IsBusted(cards)); + } + + [Fact] + public void IsSoft17_AceAnd6_True() + { + var cards = new List { C(1), C(6) }; + Assert.True(BlackjackHelper.IsSoft17(cards)); + } + + [Fact] + public void IsSoft17_TenAnd7_Hard17_False() + { + var cards = new List { C(10), C(7) }; + Assert.False(BlackjackHelper.IsSoft17(cards)); + } + + [Fact] + public void IsSoft17_Not17_False() + { + var cards = new List { C(1), C(5) }; + Assert.False(BlackjackHelper.IsSoft17(cards)); + } +} diff --git a/DiscordBot.Tests/Domain/Casino/CardAndDeckTests.cs b/DiscordBot.Tests/Domain/Casino/CardAndDeckTests.cs new file mode 100644 index 00000000..6159f2fd --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/CardAndDeckTests.cs @@ -0,0 +1,168 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class CardAndDeckTests +{ + [Theory] + [InlineData(1, CardSuit.Hearts, "A♥️")] + [InlineData(11, CardSuit.Spades, "J♠️")] + [InlineData(12, CardSuit.Diamonds, "Q♦️")] + [InlineData(13, CardSuit.Clubs, "K♣️")] + [InlineData(10, CardSuit.Hearts, "10♥️")] + [InlineData(5, CardSuit.Diamonds, "5♦️")] + public void Card_GetDisplayName(int value, CardSuit suit, string expected) + { + var card = new Card(value, suit); + Assert.Equal(expected, card.GetDisplayName()); + } + + [Fact] + public void Card_Joker_DisplayName() + { + var card = new Card(0, CardSuit.Joker); + Assert.Equal("🃏", card.GetDisplayName()); + } + + [Fact] + public void Card_Equals_SameValueAndSuit() + { + var a = new Card(5, CardSuit.Hearts); + var b = new Card(5, CardSuit.Hearts); + Assert.Equal(a, b); + } + + [Fact] + public void Card_NotEquals_DifferentSuit() + { + var a = new Card(5, CardSuit.Hearts); + var b = new Card(5, CardSuit.Spades); + Assert.NotEqual(a, b); + } + + [Fact] + public void Card_CompareTo_ByValue() + { + var low = new Card(3, CardSuit.Hearts); + var high = new Card(10, CardSuit.Hearts); + Assert.True(low.CompareTo(high) < 0); + } + + [Fact] + public void Card_GetHashCode_EqualCards() + { + var a = new Card(7, CardSuit.Clubs); + var b = new Card(7, CardSuit.Clubs); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void Deck_StandardDeck_Has52Cards() + { + var deck = new Deck(); + Assert.Equal(52, deck.CardsRemaining); + } + + [Fact] + public void Deck_WithJokers_Has54Cards() + { + var deck = new Deck(jokerCount: 2); + Assert.Equal(54, deck.CardsRemaining); + } + + [Fact] + public void Deck_DoubleDeck_Has104Cards() + { + var deck = new Deck(times: 2); + Assert.Equal(104, deck.CardsRemaining); + } + + [Fact] + public void Deck_DrawCard_ReducesCount() + { + var deck = new Deck(); + deck.DrawCard(); + Assert.Equal(51, deck.CardsRemaining); + } + + [Fact] + public void Deck_DrawCard_EmptyDeck_ReturnsNull() + { + var deck = new Deck(new List()); + Assert.Null(deck.DrawCard()); + } + + [Fact] + public void Deck_DrawCards_ReturnsExactCount() + { + var deck = new Deck(); + var cards = deck.DrawCards(5); + Assert.Equal(5, cards.Count); + Assert.Equal(47, deck.CardsRemaining); + } + + [Fact] + public void Deck_DrawCards_MoreThanRemaining_ReturnsAvailable() + { + var deck = new Deck(new List { new(1, CardSuit.Hearts), new(2, CardSuit.Hearts) }); + var cards = deck.DrawCards(5); + Assert.Equal(2, cards.Count); + Assert.True(deck.IsEmpty); + } + + [Fact] + public void Deck_Shuffle_CountUnchanged() + { + var deck = new Deck(); + deck.Shuffle(); + Assert.Equal(52, deck.CardsRemaining); + } + + [Fact] + public void Deck_Reset_RestoresAllCards() + { + var deck = new Deck(); + deck.DrawCards(10); + Assert.Equal(42, deck.CardsRemaining); + deck.Reset(); + Assert.Equal(52, deck.CardsRemaining); + } + + [Fact] + public void Deck_IsEmpty_ForEmptyDeck() + { + var deck = new Deck(new List()); + Assert.True(deck.IsEmpty); + } + + [Fact] + public void Deck_IsEmpty_FalseForNewDeck() + { + var deck = new Deck(); + Assert.False(deck.IsEmpty); + } + + [Fact] + public void Deck_PeekTop_DoesNotRemoveCards() + { + var deck = new Deck(); + var peeked = deck.PeekTop(3); + Assert.Equal(3, peeked.Count); + Assert.Equal(52, deck.CardsRemaining); + } + + [Fact] + public void Deck_StandardDeck_ContainsAllSuitsAndValues() + { + var deck = new Deck(); + var allCards = deck.DrawCards(52); + var suits = new[] { CardSuit.Hearts, CardSuit.Diamonds, CardSuit.Clubs, CardSuit.Spades }; + foreach (var suit in suits) + { + for (int value = 1; value <= 13; value++) + { + Assert.Contains(allCards, c => c.Value == value && c.Suit == suit); + } + } + } +} diff --git a/DiscordBot.Tests/Domain/Casino/PokerHelperTests.cs b/DiscordBot.Tests/Domain/Casino/PokerHelperTests.cs new file mode 100644 index 00000000..6cf6da7f --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/PokerHelperTests.cs @@ -0,0 +1,146 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class PokerHelperTests +{ + private static Card C(int value, CardSuit suit = CardSuit.Hearts) => new(value, suit); + + [Fact] + public void EvaluateHand_RoyalFlush() + { + var cards = new List { C(1, CardSuit.Spades), C(13, CardSuit.Spades), C(12, CardSuit.Spades), C(11, CardSuit.Spades), C(10, CardSuit.Spades) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.RoyalFlush, hand.Rank); + } + + [Fact] + public void EvaluateHand_StraightFlush() + { + var cards = new List { C(9, CardSuit.Hearts), C(8, CardSuit.Hearts), C(7, CardSuit.Hearts), C(6, CardSuit.Hearts), C(5, CardSuit.Hearts) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.StraightFlush, hand.Rank); + } + + [Fact] + public void EvaluateHand_WheelStraightFlush() + { + var cards = new List { C(1, CardSuit.Clubs), C(2, CardSuit.Clubs), C(3, CardSuit.Clubs), C(4, CardSuit.Clubs), C(5, CardSuit.Clubs) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.StraightFlush, hand.Rank); + Assert.Contains("Wheel", hand.Description); + } + + [Fact] + public void EvaluateHand_FourOfAKind() + { + var cards = new List { C(7), C(7, CardSuit.Diamonds), C(7, CardSuit.Clubs), C(7, CardSuit.Spades), C(2) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.FourOfAKind, hand.Rank); + } + + [Fact] + public void EvaluateHand_FullHouse() + { + var cards = new List { C(10), C(10, CardSuit.Diamonds), C(10, CardSuit.Clubs), C(4), C(4, CardSuit.Spades) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.FullHouse, hand.Rank); + } + + [Fact] + public void EvaluateHand_Flush() + { + var cards = new List { C(2, CardSuit.Diamonds), C(5, CardSuit.Diamonds), C(8, CardSuit.Diamonds), C(10, CardSuit.Diamonds), C(13, CardSuit.Diamonds) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.Flush, hand.Rank); + } + + [Fact] + public void EvaluateHand_Straight() + { + var cards = new List { C(9, CardSuit.Hearts), C(8, CardSuit.Diamonds), C(7, CardSuit.Clubs), C(6, CardSuit.Spades), C(5, CardSuit.Hearts) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.Straight, hand.Rank); + } + + [Fact] + public void EvaluateHand_WheelStraight() + { + var cards = new List { C(1, CardSuit.Hearts), C(2, CardSuit.Diamonds), C(3, CardSuit.Clubs), C(4, CardSuit.Spades), C(5, CardSuit.Hearts) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.Straight, hand.Rank); + Assert.Contains("Wheel", hand.Description); + } + + [Fact] + public void EvaluateHand_ThreeOfAKind() + { + var cards = new List { C(9), C(9, CardSuit.Diamonds), C(9, CardSuit.Clubs), C(3), C(7, CardSuit.Spades) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.ThreeOfAKind, hand.Rank); + } + + [Fact] + public void EvaluateHand_TwoPair() + { + var cards = new List { C(8), C(8, CardSuit.Diamonds), C(5, CardSuit.Clubs), C(5, CardSuit.Spades), C(2) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.TwoPair, hand.Rank); + } + + [Fact] + public void EvaluateHand_OnePair() + { + var cards = new List { C(11), C(11, CardSuit.Diamonds), C(3, CardSuit.Clubs), C(7, CardSuit.Spades), C(9) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.OnePair, hand.Rank); + } + + [Fact] + public void EvaluateHand_HighCard() + { + var cards = new List { C(1), C(10, CardSuit.Diamonds), C(7, CardSuit.Clubs), C(4, CardSuit.Spades), C(2) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.HighCard, hand.Rank); + } + + [Fact] + public void EvaluateHand_WrongCardCount_Throws() + { + var cards = new List { C(1), C(2), C(3), C(4) }; + Assert.Throws(() => PokerHelper.EvaluateHand(cards)); + } + + [Fact] + public void CompareHands_HigherRankWins() + { + var flush = PokerHelper.EvaluateHand([C(2, CardSuit.Hearts), C(5, CardSuit.Hearts), C(8, CardSuit.Hearts), C(10, CardSuit.Hearts), C(13, CardSuit.Hearts)]); + var pair = PokerHelper.EvaluateHand([C(3), C(3, CardSuit.Diamonds), C(7, CardSuit.Clubs), C(9, CardSuit.Spades), C(11)]); + Assert.True(PokerHelper.CompareHands(flush, pair) > 0); + } + + [Fact] + public void CompareHands_KickerBreaksTie() + { + var pairHigh = PokerHelper.EvaluateHand([C(10), C(10, CardSuit.Diamonds), C(1, CardSuit.Clubs), C(7, CardSuit.Spades), C(3)]); + var pairLow = PokerHelper.EvaluateHand([C(10, CardSuit.Clubs), C(10, CardSuit.Spades), C(9), C(7, CardSuit.Diamonds), C(3, CardSuit.Diamonds)]); + Assert.True(PokerHelper.CompareHands(pairHigh, pairLow) > 0); + } + + [Fact] + public void CompareHands_IdenticalHands_ReturnsZero() + { + var hand1 = PokerHelper.EvaluateHand([C(1), C(13, CardSuit.Diamonds), C(10, CardSuit.Clubs), C(7, CardSuit.Spades), C(4)]); + var hand2 = PokerHelper.EvaluateHand([C(1, CardSuit.Diamonds), C(13, CardSuit.Clubs), C(10, CardSuit.Spades), C(7), C(4, CardSuit.Diamonds)]); + Assert.Equal(0, PokerHelper.CompareHands(hand1, hand2)); + } + + [Fact] + public void CompareHands_NullHandling() + { + var hand = PokerHelper.EvaluateHand([C(1), C(13, CardSuit.Diamonds), C(10, CardSuit.Clubs), C(7, CardSuit.Spades), C(4)]); + Assert.True(PokerHelper.CompareHands(hand, null!) > 0); + Assert.True(PokerHelper.CompareHands(null!, hand) < 0); + Assert.Equal(0, PokerHelper.CompareHands(null!, null!)); + } +} diff --git a/DiscordBot.Tests/Domain/Casino/RockPaperScissorsTests.cs b/DiscordBot.Tests/Domain/Casino/RockPaperScissorsTests.cs new file mode 100644 index 00000000..fcd66705 --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/RockPaperScissorsTests.cs @@ -0,0 +1,97 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class RockPaperScissorsTests +{ + private static (RockPaperScissors game, GamePlayer p1, GamePlayer p2) CreateGame() + { + var game = new RockPaperScissors(); + var p1 = new GamePlayer { Bet = 100 }; + var p2 = new GamePlayer { Bet = 100 }; + game.StartGame([p1, p2]); + return (game, p1, p2); + } + + [Theory] + [InlineData(RockPaperScissorsPlayerAction.Rock, RockPaperScissorsPlayerAction.Scissors, GamePlayerResult.Won)] + [InlineData(RockPaperScissorsPlayerAction.Paper, RockPaperScissorsPlayerAction.Rock, GamePlayerResult.Won)] + [InlineData(RockPaperScissorsPlayerAction.Scissors, RockPaperScissorsPlayerAction.Paper, GamePlayerResult.Won)] + [InlineData(RockPaperScissorsPlayerAction.Scissors, RockPaperScissorsPlayerAction.Rock, GamePlayerResult.Lost)] + [InlineData(RockPaperScissorsPlayerAction.Rock, RockPaperScissorsPlayerAction.Paper, GamePlayerResult.Lost)] + [InlineData(RockPaperScissorsPlayerAction.Paper, RockPaperScissorsPlayerAction.Scissors, GamePlayerResult.Lost)] + [InlineData(RockPaperScissorsPlayerAction.Rock, RockPaperScissorsPlayerAction.Rock, GamePlayerResult.Tie)] + [InlineData(RockPaperScissorsPlayerAction.Paper, RockPaperScissorsPlayerAction.Paper, GamePlayerResult.Tie)] + [InlineData(RockPaperScissorsPlayerAction.Scissors, RockPaperScissorsPlayerAction.Scissors, GamePlayerResult.Tie)] + public void AllCombinations_CorrectResult(RockPaperScissorsPlayerAction p1Choice, RockPaperScissorsPlayerAction p2Choice, GamePlayerResult expectedP1Result) + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, p1Choice); + game.DoPlayerAction(p2, p2Choice); + Assert.Equal(expectedP1Result, game.GetPlayerGameResult(p1)); + } + + [Fact] + public void NoChoice_ReturnsNoResult() + { + var (game, p1, _) = CreateGame(); + Assert.Equal(GamePlayerResult.NoResult, game.GetPlayerGameResult(p1)); + } + + [Fact] + public void Payout_WinnerGainsTotalPot() + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + game.DoPlayerAction(p2, RockPaperScissorsPlayerAction.Scissors); + var results = game.EndGame(); + var p1Payout = results.First(r => r.player == p1).payout; + Assert.Equal(100, p1Payout); // wins 200 total pot - 100 bet = 100 net + } + + [Fact] + public void Payout_LoserLosesBet() + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + game.DoPlayerAction(p2, RockPaperScissorsPlayerAction.Scissors); + var results = game.EndGame(); + var p2Payout = results.First(r => r.player == p2).payout; + Assert.Equal(-100, p2Payout); + } + + [Fact] + public void Payout_TieIsZero() + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + game.DoPlayerAction(p2, RockPaperScissorsPlayerAction.Rock); + var results = game.EndGame(); + Assert.All(results, r => Assert.Equal(0, r.payout)); + } + + [Fact] + public void DoPlayerAction_AlreadyChosen_Throws() + { + var (game, p1, _) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + Assert.Throws(() => game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Paper)); + } + + [Fact] + public void ShouldFinish_BothChosen_True() + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + game.DoPlayerAction(p2, RockPaperScissorsPlayerAction.Scissors); + Assert.True(game.ShouldFinish()); + } + + [Fact] + public void ShouldFinish_OneChosen_False() + { + var (game, p1, _) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + Assert.False(game.ShouldFinish()); + } +} diff --git a/DiscordBot.Tests/Domain/Casino/TokenTransactionTests.cs b/DiscordBot.Tests/Domain/Casino/TokenTransactionTests.cs new file mode 100644 index 00000000..2e2d43b0 --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/TokenTransactionTests.cs @@ -0,0 +1,94 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class TokenTransactionTests +{ + private static TokenTransaction Create() => new() { UserID = "123" }; + + [Theory] + [InlineData("TokenInitialisation", TransactionKind.TokenInitialisation)] + [InlineData("DailyReward", TransactionKind.DailyReward)] + [InlineData("Gift", TransactionKind.Gift)] + [InlineData("Game", TransactionKind.Game)] + [InlineData("Admin", TransactionKind.Admin)] + public void Kind_Get_ValidString_ReturnsCorrectEnum(string type, TransactionKind expected) + { + var tx = Create(); + tx.TransactionType = type; + Assert.Equal(expected, tx.Kind); + } + + [Fact] + public void Kind_Get_InvalidString_FallsBackToAdmin() + { + var tx = Create(); + tx.TransactionType = "SomethingInvalid"; + Assert.Equal(TransactionKind.Admin, tx.Kind); + } + + [Fact] + public void Kind_Set_UpdatesTransactionType() + { + var tx = Create(); + tx.Kind = TransactionKind.Gift; + Assert.Equal("Gift", tx.TransactionType); + } + + [Fact] + public void Description_Set_ValidJson_ParsedToDict() + { + var tx = Create(); + tx.Description = """{"game":"blackjack","result":"win"}"""; + Assert.NotNull(tx.Details); + Assert.Equal("blackjack", tx.Details!["game"]); + Assert.Equal("win", tx.Details["result"]); + } + + [Fact] + public void Description_Set_PlainText_FallbackDict() + { + var tx = Create(); + tx.Description = "some plain text"; + Assert.NotNull(tx.Details); + Assert.Equal("some plain text", tx.Details!["text"]); + } + + [Fact] + public void Description_Set_NullOrEmpty_EmptyDict() + { + var tx = Create(); + tx.Description = null; + Assert.NotNull(tx.Details); + Assert.Empty(tx.Details!); + + tx.Description = ""; + Assert.Empty(tx.Details!); + } + + [Fact] + public void Description_Get_SerializesToJson() + { + var tx = Create(); + tx.Details = new Dictionary { ["key"] = "val" }; + var json = tx.Description; + Assert.Contains("\"key\"", json); + Assert.Contains("\"val\"", json); + } + + [Fact] + public void Description_Get_EmptyDetails_ReturnsNull() + { + var tx = Create(); + tx.Details = new Dictionary(); + Assert.Null(tx.Description); + } + + [Fact] + public void Description_Get_NullDetails_ReturnsNull() + { + var tx = Create(); + tx.Details = null; + Assert.Null(tx.Description); + } +} diff --git a/DiscordBot.Tests/Extensions/DateExtensionsTests.cs b/DiscordBot.Tests/Extensions/DateExtensionsTests.cs new file mode 100644 index 00000000..b5a42a70 --- /dev/null +++ b/DiscordBot.Tests/Extensions/DateExtensionsTests.cs @@ -0,0 +1,27 @@ +using DiscordBot.Extensions; + +namespace DiscordBot.Tests.Extensions; + +public class DateExtensionsTests +{ + [Fact] + public void ToUnixTimestamp_Epoch_ReturnsZero() + { + var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(0, epoch.ToUnixTimestamp()); + } + + [Fact] + public void ToUnixTimestamp_KnownDate_ReturnsExpected() + { + var date = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(946684800, date.ToUnixTimestamp()); + } + + [Fact] + public void ToUnixTimestamp_ReturnsPositiveForRecentDate() + { + var result = DateTime.UtcNow.ToUnixTimestamp(); + Assert.True(result > 0); + } +} diff --git a/DiscordBot.Tests/Extensions/StringExtensionsTests.cs b/DiscordBot.Tests/Extensions/StringExtensionsTests.cs new file mode 100644 index 00000000..1db11f26 --- /dev/null +++ b/DiscordBot.Tests/Extensions/StringExtensionsTests.cs @@ -0,0 +1,139 @@ +using DiscordBot.Extensions; + +namespace DiscordBot.Tests.Extensions; + +public class StringExtensionsTests +{ + [Theory] + [InlineData(null, 5, null)] + [InlineData("", 5, "")] + [InlineData("abc", 5, "abc")] + [InlineData("abcde", 5, "abcde")] + [InlineData("abcdef", 5, "abcde")] + public void Truncate_ReturnsExpected(string? input, int max, string? expected) + { + Assert.Equal(expected, input!.Truncate(max)); + } + + [Fact] + public void CalculateLevenshteinDistance_IdenticalStrings_ReturnsZero() + { + Assert.Equal(0, "kitten".CalculateLevenshteinDistance("kitten")); + } + + [Fact] + public void CalculateLevenshteinDistance_EmptySource_ReturnsTargetLength() + { + Assert.Equal(5, "".CalculateLevenshteinDistance("hello")); + } + + [Fact] + public void CalculateLevenshteinDistance_EmptyTarget_ReturnsSourceLength() + { + Assert.Equal(5, "hello".CalculateLevenshteinDistance("")); + } + + [Theory] + [InlineData("kitten", "sitting", 3)] + [InlineData("saturday", "sunday", 3)] + public void CalculateLevenshteinDistance_KnownPairs(string a, string b, int expected) + { + Assert.Equal(expected, a.CalculateLevenshteinDistance(b)); + } + + [Fact] + public void MessageSplit_ShortString_ReturnsSingleElement() + { + var result = "hello\nworld".MessageSplit(100); + Assert.Single(result); + } + + [Fact] + public void MessageSplit_LongString_SplitsAtNewlines() + { + var input = string.Join("\n", Enumerable.Range(1, 50).Select(i => new string('x', 50))); + var result = input.MessageSplit(200); + Assert.True(result.Count > 1); + } + + [Fact] + public void EscapeDiscordMarkup_EscapesSpecialChars() + { + var result = "hello *world* ~test~ __under__ `code`".EscapeDiscordMarkup(); + Assert.Contains(@"\*", result); + Assert.Contains(@"\~", result); + Assert.Contains(@"\_", result); + Assert.Contains(@"\`", result); + } + + [Fact] + public void EscapeDiscordMarkup_NoSpecialChars_Unchanged() + { + Assert.Equal("hello world", "hello world".EscapeDiscordMarkup()); + } + + [Theory] + [InlineData("HELLO WORLD!", true)] + [InlineData("ABC 123", false)] + [InlineData("Hello", false)] + [InlineData("ALL CAPS!!!", true)] + public void IsAllCaps_ReturnsExpected(string input, bool expected) + { + Assert.Equal(expected, input.IsAllCaps()); + } + + [Theory] + [InlineData("hello", "Hello")] + [InlineData("", "")] + [InlineData("A", "A")] + [InlineData("abc", "Abc")] + public void ToCapitalizeFirstLetter_ReturnsExpected(string input, string expected) + { + Assert.Equal(expected, input.ToCapitalizeFirstLetter()); + } + + [Fact] + public void ToCapitalizeFirstLetter_Null_ReturnsEmpty() + { + Assert.Equal(string.Empty, ((string)null!).ToCapitalizeFirstLetter()); + } + + [Fact] + public void ToCommaList_SingleItem() + { + Assert.Equal("apples", new[] { "apples" }.ToCommaList()); + } + + [Fact] + public void ToCommaList_TwoItems() + { + Assert.Equal("apples and bananas", new[] { "apples", "bananas" }.ToCommaList()); + } + + [Fact] + public void ToCommaList_ThreeItems() + { + Assert.Equal("apples, bananas, and cherries", new[] { "apples", "bananas", "cherries" }.ToCommaList()); + } + + [Fact] + public void ToCommaList_CustomConjunction() + { + Assert.Equal("apples or bananas", new[] { "apples", "bananas" }.ToCommaList("or")); + } + + [Fact] + public void GetSha256_KnownInput_ProducesConsistentHash() + { + var hash1 = "test".GetSha256(); + var hash2 = "test".GetSha256(); + Assert.Equal(hash1, hash2); + Assert.Equal(64, hash1.Length); + } + + [Fact] + public void GetSha256_DifferentInputs_ProduceDifferentHashes() + { + Assert.NotEqual("abc".GetSha256(), "def".GetSha256()); + } +} diff --git a/DiscordBot.Tests/GlobalUsings.cs b/DiscordBot.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/DiscordBot.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs new file mode 100644 index 00000000..daa92f26 --- /dev/null +++ b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs @@ -0,0 +1,129 @@ +using DiscordBot.Settings; + +namespace DiscordBot.Tests.Settings; + +public class BotSettingsValidationTests +{ + private static BotSettings CreateValid() => new() + { + Token = "test-token", + GuildId = 123456, + Prefix = '!', + DbConnectionString = "Host=localhost;Database=test", + ServerRootPath = "/data", + Channels = new ChannelSettings + { + General = new ChannelInfo { Id = 1 }, + Introduction = new ChannelInfo { Id = 2 }, + BotAnnouncement = new ChannelInfo { Id = 3 }, + BotCommands = new ChannelInfo { Id = 4 }, + UnityNews = new ChannelInfo { Id = 5 }, + UnityReleases = new ChannelInfo { Id = 6 }, + Rules = new ChannelInfo { Id = 7 }, + Recruitment = new ChannelInfo { Id = 8 }, + GenericHelp = new ChannelInfo { Id = 9 }, + BirthdayAnnouncement = new ChannelInfo { Id = 10 }, + Meme = new ChannelInfo { Id = 11 }, + } + }; + + [Fact] + public void ValidSettings_NoErrors() + { + var (errors, _) = CreateValid().Validate(); + Assert.Empty(errors); + } + + [Fact] + public void MissingToken_Error() + { + var settings = CreateValid(); + settings.Token = ""; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("Token")); + } + + [Fact] + public void MissingGuildId_Error() + { + var settings = CreateValid(); + settings.GuildId = 0; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("GuildId")); + } + + [Fact] + public void MissingPrefix_Error() + { + var settings = CreateValid(); + settings.Prefix = '\0'; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("Prefix")); + } + + [Fact] + public void MissingDbConnectionString_Error() + { + var settings = CreateValid(); + settings.DbConnectionString = ""; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("DbConnectionString")); + } + + [Fact] + public void EmptyServerRootPath_Warning() + { + var settings = CreateValid(); + settings.ServerRootPath = ""; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("ServerRootPath")); + } + + [Fact] + public void MissingChannel_Warning() + { + var settings = CreateValid(); + settings.Channels.General = new ChannelInfo { Id = 0 }; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("General")); + } + + [Fact] + public void NegativeCasinoStartingTokens_Error() + { + var settings = CreateValid(); + settings.Casino = new CasinoSettings { Enabled = true, StartingTokens = -1 }; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("StartingTokens")); + } + + [Fact] + public void RecruitmentEnabled_MissingTags_Warnings() + { + var settings = CreateValid(); + settings.Recruitment = new RecruitmentSettings { Enabled = true }; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("TagLookingToHire")); + Assert.Contains(warnings, w => w.Contains("TagLookingForWork")); + Assert.Contains(warnings, w => w.Contains("TagUnpaidCollab")); + Assert.Contains(warnings, w => w.Contains("TagPositionFilled")); + } + + [Fact] + public void RecruitmentDisabled_NoTagWarnings() + { + var settings = CreateValid(); + settings.Recruitment = new RecruitmentSettings { Enabled = false }; + var (_, warnings) = settings.Validate(); + Assert.DoesNotContain(warnings, w => w.Contains("TagLooking")); + } + + [Fact] + public void UnityHelpEnabled_MissingTagResolved_Warning() + { + var settings = CreateValid(); + settings.UnityHelp = new UnityHelpSettings { BabySitterEnabled = true, TagResolved = "" }; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("TagResolved")); + } +} diff --git a/DiscordBot.Tests/Settings/UserSettingsValidationTests.cs b/DiscordBot.Tests/Settings/UserSettingsValidationTests.cs new file mode 100644 index 00000000..6245c9a6 --- /dev/null +++ b/DiscordBot.Tests/Settings/UserSettingsValidationTests.cs @@ -0,0 +1,45 @@ +using DiscordBot.Settings; + +namespace DiscordBot.Tests.Settings; + +public class UserSettingsValidationTests +{ + [Fact] + public void DefaultSettings_NoWarnings() + { + var settings = new UserSettings(); + Assert.Empty(settings.Validate()); + } + + [Fact] + public void XpMinGreaterThanMax_Warning() + { + var settings = new UserSettings { XpMinPerMessage = 50, XpMaxPerMessage = 10 }; + var warnings = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("XpMinPerMessage")); + } + + [Fact] + public void CooldownMinGreaterThanMax_Warning() + { + var settings = new UserSettings { XpMinCooldown = 200, XpMaxCooldown = 60 }; + var warnings = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("XpMinCooldown")); + } + + [Fact] + public void ThanksCooldownZeroOrNegative_Warning() + { + var settings = new UserSettings { ThanksCooldown = 0 }; + var warnings = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("ThanksCooldown")); + } + + [Fact] + public void EmptyThanksList_Warning() + { + var settings = new UserSettings { Thanks = [] }; + var warnings = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("Thanks list is empty")); + } +} diff --git a/DiscordBot.Tests/Utils/MathUtilityTests.cs b/DiscordBot.Tests/Utils/MathUtilityTests.cs new file mode 100644 index 00000000..20eddf7b --- /dev/null +++ b/DiscordBot.Tests/Utils/MathUtilityTests.cs @@ -0,0 +1,34 @@ +using DiscordBot.Utils; + +namespace DiscordBot.Tests.Utils; + +public class MathUtilityTests +{ + [Theory] + [InlineData(0f, 32f)] + [InlineData(100f, 212f)] + [InlineData(-40f, -40f)] + [InlineData(37f, 98.6f)] + public void CelsiusToFahrenheit_KnownValues(float celsius, float expectedF) + { + Assert.Equal(expectedF, MathUtility.CelsiusToFahrenheit(celsius), 1); + } + + [Theory] + [InlineData(32f, 0f)] + [InlineData(212f, 100f)] + [InlineData(-40f, -40f)] + public void FahrenheitToCelsius_KnownValues(float fahrenheit, float expectedC) + { + Assert.Equal(expectedC, MathUtility.FahrenheitToCelsius(fahrenheit), 0); + } + + [Fact] + public void RoundTrip_CelsiusToFahrenheitAndBack() + { + var original = 25f; + var fahrenheit = MathUtility.CelsiusToFahrenheit(original); + var backToCelsius = MathUtility.FahrenheitToCelsius(fahrenheit); + Assert.Equal(original, backToCelsius, 0); + } +} diff --git a/DiscordBot.Tests/Utils/StringUtilTests.cs b/DiscordBot.Tests/Utils/StringUtilTests.cs new file mode 100644 index 00000000..782458e5 --- /dev/null +++ b/DiscordBot.Tests/Utils/StringUtilTests.cs @@ -0,0 +1,58 @@ +using DiscordBot.Utils; + +namespace DiscordBot.Tests.Utils; + +public class StringUtilTests +{ + [Theory] + [InlineData("$100", true)] + [InlineData("100$", true)] + [InlineData("USD 50", true)] + [InlineData("€200", true)] + [InlineData("50 GBP", true)] + [InlineData("100 euros", true)] + [InlineData("£30", true)] + [InlineData("hello world", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void ContainsCurrencySymbol_ReturnsExpected(string? input, bool expected) + { + Assert.Equal(expected, input!.ContainsCurrencySymbol()); + } + + [Theory] + [InlineData("This is a rev-share project", true)] + [InlineData("Looking for revshare", true)] + [InlineData("Doing rev share work", true)] + [InlineData("Revenue sharing model", false)] + [InlineData("hello world", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void ContainsRevShare_ReturnsExpected(string? input, bool expected) + { + Assert.Equal(expected, input!.ContainsRevShare()); + } + + [Fact] + public void SanitizeEveryoneHereMentions_SanitizesEveryone() + { + var result = "Hey @everyone check this out".SanitizeEveryoneHereMentions(); + Assert.Contains("@\u200beveryone", result); + Assert.Equal("Hey @\u200beveryone check this out", result); + } + + [Fact] + public void SanitizeEveryoneHereMentions_SanitizesHere() + { + var result = "Hey @here check this out".SanitizeEveryoneHereMentions(); + Assert.Contains("@\u200bhere", result); + Assert.Equal("Hey @\u200bhere check this out", result); + } + + [Fact] + public void SanitizeEveryoneHereMentions_NoMentions_Unchanged() + { + var input = "Hello world"; + Assert.Equal(input, input.SanitizeEveryoneHereMentions()); + } +} diff --git a/DiscordBot.sln b/DiscordBot.sln index 27eccdb4..cb6106d3 100644 --- a/DiscordBot.sln +++ b/DiscordBot.sln @@ -5,20 +5,42 @@ VisualStudioVersion = 15.0.27421.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordBot", "DiscordBot\DiscordBot.csproj", "{D021BBDF-02DC-4938-B035-75D7EDBDBAC2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordBot.Tests", "DiscordBot.Tests\DiscordBot.Tests.csproj", "{7271E3E3-989C-423E-9954-2401FCA8A230}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|x64.ActiveCfg = Debug|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|x64.Build.0 = Debug|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|x86.ActiveCfg = Debug|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|x86.Build.0 = Debug|Any CPU {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|Any CPU.ActiveCfg = Release|Any CPU {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|Any CPU.Build.0 = Release|Any CPU - {8C06F0F6-2B30-4931-B93E-AE005F92C676}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8C06F0F6-2B30-4931-B93E-AE005F92C676}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8C06F0F6-2B30-4931-B93E-AE005F92C676}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8C06F0F6-2B30-4931-B93E-AE005F92C676}.Release|Any CPU.Build.0 = Release|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|x64.ActiveCfg = Release|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|x64.Build.0 = Release|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|x86.ActiveCfg = Release|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|x86.Build.0 = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|x64.ActiveCfg = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|x64.Build.0 = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|x86.ActiveCfg = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|x86.Build.0 = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|Any CPU.Build.0 = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|x64.ActiveCfg = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|x64.Build.0 = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|x86.ActiveCfg = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DiscordBot/Attributes/BotCommandChannelAttribute.cs b/DiscordBot/Attributes/BotCommandChannelAttribute.cs index d8eada5d..654f0fc9 100644 --- a/DiscordBot/Attributes/BotCommandChannelAttribute.cs +++ b/DiscordBot/Attributes/BotCommandChannelAttribute.cs @@ -11,12 +11,12 @@ public override async Task CheckPermissionsAsync(ICommandCon { var settings = services.GetRequiredService(); - if (context.Channel.Id == settings.BotCommandsChannel.Id) + if (context.Channel.Id == settings.Channels.BotCommands.Id) { return await Task.FromResult(PreconditionResult.FromSuccess()); } - Task task = context.Message.DeleteAfterSeconds(seconds: 10); - return await Task.FromResult(PreconditionResult.FromError($"This command can only be used in <#{settings.BotCommandsChannel.Id.ToString()}>.")); + _ = context.Message.DeleteAfterSeconds(seconds: 10); + return await Task.FromResult(PreconditionResult.FromError($"This command can only be used in <#{settings.Channels.BotCommands.Id.ToString()}>.")); } } \ No newline at end of file diff --git a/DiscordBot/Attributes/RoleAttributes.cs b/DiscordBot/Attributes/RoleAttributes.cs index d4e337ce..01b33b2b 100644 --- a/DiscordBot/Attributes/RoleAttributes.cs +++ b/DiscordBot/Attributes/RoleAttributes.cs @@ -10,7 +10,8 @@ public class RequireAdminAttribute : PreconditionAttribute { public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) { - var user = (SocketGuildUser)context.Message.Author; + if (context.Message.Author is not SocketGuildUser user) + return Task.FromResult(PreconditionResult.FromError("This command can only be used in a server.")); if (user.Roles.Any(x => x.Permissions.Administrator)) return Task.FromResult(PreconditionResult.FromSuccess()); return Task.FromResult(PreconditionResult.FromError(user + " attempted to use admin only command!")); @@ -22,10 +23,12 @@ public class RequireModeratorAttribute : PreconditionAttribute { public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) { - var user = (SocketGuildUser)context.Message.Author; + if (context.Message.Author is not SocketGuildUser user) + return Task.FromResult(PreconditionResult.FromError("This command can only be used in a server.")); + var settings = services.GetRequiredService(); - if (user.Roles.Any(x => x.Id == settings.ModeratorRoleId)) return Task.FromResult(PreconditionResult.FromSuccess()); + if (user.Roles.Any(x => x.Id == settings.Roles.Moderator)) return Task.FromResult(PreconditionResult.FromSuccess()); return Task.FromResult(PreconditionResult.FromError(user + " attempted to use a moderator command!")); } } \ No newline at end of file diff --git a/DiscordBot/Data/FuzzTable.cs b/DiscordBot/Data/FuzzTable.cs index 6d423672..b463fb9b 100644 --- a/DiscordBot/Data/FuzzTable.cs +++ b/DiscordBot/Data/FuzzTable.cs @@ -20,90 +20,90 @@ namespace DiscordBot.Data; public class FuzzTable { private static Random random = new(); - private static Regex parenContents = null; - private static TimeSpan timeout = new(10*10000/*x10nanoseconds*/); + private static Regex? parenContents = null; + private static TimeSpan timeout = new(10 * 10000/*x10nanoseconds*/); - private List choices = new(); - private Queue recent = new(); + private List choices = new(); + private Queue recent = new(); - public void Clear() - { - choices.Clear(); - recent.Clear(); - } + public void Clear() + { + choices.Clear(); + recent.Clear(); + } - public int Count => choices.Count + recent.Count; + public int Count => choices.Count + recent.Count; - // Add a string as a valid choice from which to pick. - // Note that empty strings or whitespace can be added manually as valid choices. - // Duplicate choices are also allowed for weighting. - // - public void Add(string choice) - { - choices.Add(choice); - } + // Add a string as a valid choice from which to pick. + // Note that empty strings or whitespace can be added manually as valid choices. + // Duplicate choices are also allowed for weighting. + // + public void Add(string choice) + { + choices.Add(choice); + } - // Add a collection of choice strings all at once. - // - public void Add(IEnumerable stream) - { - if (stream == null) - return; - foreach (var choice in stream) - Add(choice); - } + // Add a collection of choice strings all at once. + // + public void Add(IEnumerable stream) + { + if (stream == null) + return; + foreach (var choice in stream) + Add(choice); + } - // Load a file of string choices. - // Lines starting with a '#' character are ignored, as are blank lines. - // Each remaining line of the file is trimmed of leading and trailing whitespace. - // Each line is added as a new choice, and duplicates are allowed for weighting. - // If the file is missing, nothing is done, but any other exception is thrown. - // - public void Load(string filename) - { - if (!File.Exists(filename)) - return; - foreach (string line in File.ReadLines(filename)) - { - string choice = line.Trim(); - if (choice.Length == 0 || choice.StartsWith('#')) - continue; - Add(choice); - } - } + // Load a file of string choices. + // Lines starting with a '#' character are ignored, as are blank lines. + // Each remaining line of the file is trimmed of leading and trailing whitespace. + // Each line is added as a new choice, and duplicates are allowed for weighting. + // If the file is missing, nothing is done, but any other exception is thrown. + // + public void Load(string filename) + { + if (!File.Exists(filename)) + return; + foreach (string line in File.ReadLines(filename)) + { + string choice = line.Trim(); + if (choice.Length == 0 || choice.StartsWith('#')) + continue; + Add(choice); + } + } - // Pick one of the active choices. - // This choice is transferred to the MRU so it's not picked again too soon. - // If the evaluate flag is given, further Evaluate() it as a fuzz string. - // Returns the chosen results, or the empty string if no choices available. - // - public string Pick(bool evaluate=false) - { - Recycle(); - if (choices.Count == 0) - return ""; + // Pick one of the active choices. + // This choice is transferred to the MRU so it's not picked again too soon. + // If the evaluate flag is given, further Evaluate() it as a fuzz string. + // Returns the chosen results, or the empty string if no choices available. + // + public string Pick(bool evaluate = false) + { + Recycle(); + if (choices.Count == 0) + return ""; int pick = random.Next(0, choices.Count); - string chosen = choices[pick]; - choices.RemoveAt(pick); - recent.Enqueue(chosen); - if (evaluate) - return Evaluate(chosen); - return chosen; - } + string chosen = choices[pick]; + choices.RemoveAt(pick); + recent.Enqueue(chosen); + if (evaluate) + return Evaluate(chosen); + return chosen; + } + + // When the MRU gets too long, return the oldest MRU choice(s) back + // to the active list of choices. + // + private void Recycle() + { + // Caps the MRU at half of total choices. + while (recent.Count > choices.Count) + { + string choice = recent.Dequeue(); + choices.Add(choice); + } + } - // When the MRU gets too long, return the oldest MRU choice(s) back - // to the active list of choices. - // - private void Recycle() - { - // Caps the MRU at half of total choices. - while (recent.Count > choices.Count) - { - string choice = recent.Dequeue(); - choices.Add(choice); - } - } - // Evaluate a single fuzz string. // Replace any parenthetical phrase with one of its choices at random. // Allows for nesting of choices. There's currently no way to escape @@ -111,31 +111,31 @@ private void Recycle() // Returns one permutation from all choice alternatives given. // There is no MRU of individual permutations given. // - public static string Evaluate(string fuzz) + public static string Evaluate(string fuzz) { if (string.IsNullOrEmpty(fuzz)) return ""; - if (parenContents == null) - parenContents = - new(@"\( ( [^(]*? ) \)", - RegexOptions.IgnorePatternWhitespace | - RegexOptions.Compiled, - timeout); - string before = null; - while (fuzz != before) - { - before = fuzz; + if (parenContents == null) + parenContents = + new(@"\( ( [^(]*? ) \)", + RegexOptions.IgnorePatternWhitespace | + RegexOptions.Compiled, + timeout); + string? before = null; + while (fuzz != before) + { + before = fuzz; try { - fuzz = parenContents.Replace(fuzz, - (m) => PickAlternate(m.Groups[1].ToString())); + fuzz = parenContents.Replace(fuzz, + (m) => PickAlternate(m.Groups[1].ToString())); } catch (RegexMatchTimeoutException) { break; } - } - return fuzz; + } + return fuzz; } private static string PickAlternate(string fuzz) diff --git a/DiscordBot/Data/UnityAPI.cs b/DiscordBot/Data/UnityAPI.cs index dfbd3010..3c41ea8f 100644 --- a/DiscordBot/Data/UnityAPI.cs +++ b/DiscordBot/Data/UnityAPI.cs @@ -2,49 +2,49 @@ namespace DiscordBot.Data; public class Rating { - public object Count { get; set; } + public object Count { get; set; } = null!; public int Average { get; set; } } public class Kategory { - public string Slug { get; set; } - public string Name { get; set; } - public string Id { get; set; } + public string Slug { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; } public class Category { - public string TreeId { get; set; } - public string LabelEnglish { get; set; } - public string Label { get; set; } - public string Id { get; set; } - public string Multiple { get; set; } + public string TreeId { get; set; } = string.Empty; + public string LabelEnglish { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public string Multiple { get; set; } = string.Empty; } public class Publisher { - public string LabelEnglish { get; set; } - public string Url { get; set; } - public string Slug { get; set; } - public string Label { get; set; } - public string Id { get; set; } - public string SupportEmail { get; set; } - public object SupportUrl { get; set; } + public string LabelEnglish { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public string SupportEmail { get; set; } = string.Empty; + public object SupportUrl { get; set; } = null!; } public class Link { - public string Type { get; set; } - public string Id { get; set; } + public string Type { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; } public class List { - public string Slug { get; set; } - public string SlugV2 { get; set; } - public string Name { get; set; } - public object Overlay { get; set; } + public string Slug { get; set; } = string.Empty; + public string SlugV2 { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public object Overlay { get; set; } = null!; } public class Flags @@ -53,113 +53,113 @@ public class Flags public class Image { - public string Link { get; set; } - public string Width { get; set; } - public string Name { get; set; } - public string Type { get; set; } - public string Height { get; set; } - public string Thumb { get; set; } + public string Link { get; set; } = string.Empty; + public string Width { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Height { get; set; } = string.Empty; + public string Thumb { get; set; } = string.Empty; } public class Keyimage { - public string Small { get; set; } - public string Big { get; set; } - public object SmallLegacy { get; set; } - public object Facebook { get; set; } - public object BigLegacy { get; set; } - public string Icon { get; set; } - public string Icon75 { get; set; } - public string Icon25 { get; set; } + public string Small { get; set; } = string.Empty; + public string Big { get; set; } = string.Empty; + public object SmallLegacy { get; set; } = null!; + public object Facebook { get; set; } = null!; + public object BigLegacy { get; set; } = null!; + public string Icon { get; set; } = string.Empty; + public string Icon75 { get; set; } = string.Empty; + public string Icon25 { get; set; } = string.Empty; } public class Daily { - public string Icon { get; set; } - public Rating Rating { get; set; } + public string Icon { get; set; } = string.Empty; + public Rating Rating { get; set; } = null!; public int Remaining { get; set; } - public Kategory Kategory { get; set; } - public string PackageVersionId { get; set; } - public string Slug { get; set; } - public Category Category { get; set; } - public string Hotness { get; set; } - public string Id { get; set; } - public Publisher Publisher { get; set; } - public List List { get; set; } - public Link Link { get; set; } - public Flags Flags { get; set; } - public Keyimage Keyimage { get; set; } - public string Description { get; set; } - public string TitleEnglish { get; set; } - public string Title { get; set; } + public Kategory Kategory { get; set; } = null!; + public string PackageVersionId { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public Category Category { get; set; } = null!; + public string Hotness { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public Publisher Publisher { get; set; } = null!; + public List List { get; set; } = []; + public Link Link { get; set; } = null!; + public Flags Flags { get; set; } = null!; + public Keyimage Keyimage { get; set; } = null!; + public string Description { get; set; } = string.Empty; + public string TitleEnglish { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; } public class Content { - public string Pubdate { get; set; } - public string MinUnityVersion { get; set; } - public Rating Rating { get; set; } - public Kategory Kategory { get; set; } - public List UnityVersions { get; set; } - public string Url { get; set; } - public string PackageVersionId { get; set; } - public string Slug { get; set; } - public Category Category { get; set; } - public string Id { get; set; } - public Publisher Publisher { get; set; } - public string Sizetext { get; set; } - public List List { get; set; } - public Link Link { get; set; } - public List Images { get; set; } - public Flags Flags { get; set; } - public string Version { get; set; } - public string FirstPublishedAt { get; set; } - public Keyimage Keyimage { get; set; } + public string Pubdate { get; set; } = string.Empty; + public string MinUnityVersion { get; set; } = string.Empty; + public Rating Rating { get; set; } = null!; + public Kategory Kategory { get; set; } = null!; + public List UnityVersions { get; set; } = []; + public string Url { get; set; } = string.Empty; + public string PackageVersionId { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public Category Category { get; set; } = null!; + public string Id { get; set; } = string.Empty; + public Publisher Publisher { get; set; } = null!; + public string Sizetext { get; set; } = string.Empty; + public List List { get; set; } = []; + public Link Link { get; set; } = null!; + public List Images { get; set; } = []; + public Flags Flags { get; set; } = null!; + public string Version { get; set; } = string.Empty; + public string FirstPublishedAt { get; set; } = string.Empty; + public Keyimage Keyimage { get; set; } = null!; public int License { get; set; } - public string Description { get; set; } - public List Upgrades { get; set; } - public string Publishnotes { get; set; } - public string Title { get; set; } - public string ShortUrl { get; set; } - public List Upgradables { get; set; } + public string Description { get; set; } = string.Empty; + public List Upgrades { get; set; } = []; + public string Publishnotes { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string ShortUrl { get; set; } = string.Empty; + public List Upgradables { get; set; } = []; } public class DailyObject { - public string Banner { get; set; } - public string Feed { get; set; } - public string Status { get; set; } + public string Banner { get; set; } = string.Empty; + public string Feed { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; public int DaysLeft { get; set; } public int Total { get; set; } - public Daily Daily { get; set; } + public Daily Daily { get; set; } = null!; public int Remaining { get; set; } - public string Badge { get; set; } - public string Title { get; set; } + public string Badge { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; public bool Countdown { get; set; } - public List Results { get; set; } + public List Results { get; set; } = []; } public class PackageObject { - public Content Content { get; set; } + public Content Content { get; set; } = null!; } public class PriceObject { - public string Vat { get; set; } - public string PriceExvat { get; set; } - public string Price { get; set; } + public string Vat { get; set; } = string.Empty; + public string PriceExvat { get; set; } = string.Empty; + public string Price { get; set; } = string.Empty; public bool IsFree { get; set; } } public class Result { - public string Category { get; set; } - public string Title { get; set; } - public string Publisher { get; set; } + public string Category { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Publisher { get; set; } = string.Empty; } public class PackageHeadObject { - public Result Result { get; set; } + public Result Result { get; set; } = null!; } \ No newline at end of file diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 8d7e9c26..7cd3ea3a 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -4,16 +4,17 @@ net8.0 12 enable + true - + - + - - + + diff --git a/DiscordBot/Domain/Casino/AIAction.cs b/DiscordBot/Domain/Casino/AIAction.cs index 461b23ff..33e14e3b 100644 --- a/DiscordBot/Domain/Casino/AIAction.cs +++ b/DiscordBot/Domain/Casino/AIAction.cs @@ -1,4 +1,4 @@ public class AIAction { - public Func Execute { get; set; } + public Func Execute { get; set; } = null!; } \ No newline at end of file diff --git a/DiscordBot/Domain/Casino/Discord/RockPaperScissorsDiscordGameSession.cs b/DiscordBot/Domain/Casino/Discord/RockPaperScissorsDiscordGameSession.cs index 91900739..0882d2d7 100644 --- a/DiscordBot/Domain/Casino/Discord/RockPaperScissorsDiscordGameSession.cs +++ b/DiscordBot/Domain/Casino/Discord/RockPaperScissorsDiscordGameSession.cs @@ -7,7 +7,7 @@ public RockPaperScissorsDiscordGameSession(RockPaperScissors game, int maxSeats, : base(game, maxSeats, client, user, guild) { } - private string GetCurrentPlayerName() + private new string GetCurrentPlayerName() { if (Game.CurrentPlayer == null) return "All players have chosen"; return GetPlayerName((DiscordGamePlayer)Game.CurrentPlayer); @@ -26,7 +26,7 @@ private string GenerateGameDescription() if (Game.State == GameState.Finished) { var choice = Game.GameData[p].Choice; - playerHand = $"{RockPaperScissors.GetChoiceEmoji(choice.Value)} {choice.Value}"; + playerHand = $"{RockPaperScissors.GetChoiceEmoji(choice!.Value)} {choice!.Value}"; } description += GeneratePlayerHandDescription(p, playerHand, ""); } diff --git a/DiscordBot/Domain/DocEntry.cs b/DiscordBot/Domain/DocEntry.cs new file mode 100644 index 00000000..de6f3c02 --- /dev/null +++ b/DiscordBot/Domain/DocEntry.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json; + +namespace DiscordBot.Domain; + +[JsonConverter(typeof(DocEntryJsonConverter))] +public record DocEntry(string PageName, string Title); + +internal class DocEntryJsonConverter : JsonConverter +{ + public override DocEntry? ReadJson(JsonReader reader, Type objectType, DocEntry? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.StartArray) + { + var arr = serializer.Deserialize(reader); + if (arr is { Length: >= 2 }) + return new DocEntry(arr[0], arr[1]); + return null; + } + + if (reader.TokenType == JsonToken.StartObject) + { + var obj = Newtonsoft.Json.Linq.JObject.Load(reader); + return new DocEntry( + obj.Value("PageName") ?? "", + obj.Value("Title") ?? ""); + } + + return null; + } + + public override void WriteJson(JsonWriter writer, DocEntry? value, JsonSerializer serializer) + { + if (value == null) { writer.WriteNull(); return; } + writer.WriteStartArray(); + writer.WriteValue(value.PageName); + writer.WriteValue(value.Title); + writer.WriteEndArray(); + } +} diff --git a/DiscordBot/Domain/ProfileData.cs b/DiscordBot/Domain/ProfileData.cs index 2942a5fc..2d2472d8 100644 --- a/DiscordBot/Domain/ProfileData.cs +++ b/DiscordBot/Domain/ProfileData.cs @@ -5,8 +5,8 @@ namespace DiscordBot.Domain; public class ProfileData { public ulong UserId { get; set; } - public string Nickname { get; set; } - public string Username { get; set; } + public string Nickname { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; public long XpTotal { get; set; } public long XpRank { get; set; } public long KarmaRank { get; set; } @@ -18,5 +18,5 @@ public class ProfileData public int MaxXpShown { get; set; } public float XpPercentage { get; set; } public Color MainRoleColor { get; set; } - public MagickImage Picture { get; set; } + public MagickImage Picture { get; set; } = null!; } \ No newline at end of file diff --git a/DiscordBot/Extensions/ChannelExtensions.cs b/DiscordBot/Extensions/ChannelExtensions.cs index 74229afc..29db1132 100644 --- a/DiscordBot/Extensions/ChannelExtensions.cs +++ b/DiscordBot/Extensions/ChannelExtensions.cs @@ -12,14 +12,14 @@ public static bool IsThreadInForumChannel(this IMessageChannel channel) return false; return true; } - + public static bool IsThreadInChannel(this IMessageChannel channel, ulong channelId) { if (!channel.IsThreadInForumChannel()) return false; return ((SocketThreadChannel)channel).ParentChannel.Id == channelId; } - + public static bool IsPinned(this IThreadChannel channel) { return channel.Flags.HasFlag(ChannelFlags.Pinned); diff --git a/DiscordBot/Extensions/ContextExtension.cs b/DiscordBot/Extensions/ContextExtension.cs index 4f25d1c9..e624bd1a 100644 --- a/DiscordBot/Extensions/ContextExtension.cs +++ b/DiscordBot/Extensions/ContextExtension.cs @@ -12,7 +12,7 @@ public static bool HasRoleOrEveryoneMention(this ICommandContext context) { return context.Message.MentionedRoleIds.Count != 0 || context.Message.MentionedEveryone; } - + /// /// True if the context includes a RoleID, UserID or Mentions Everyone (Should include @here, unsure) /// @@ -21,7 +21,7 @@ public static bool HasAnyPingableMention(this ICommandContext context) { return context.Message.MentionedUserIds.Count > 0 || context.HasRoleOrEveryoneMention(); } - + /// /// True if the Context contains a message that is a reply and only mentions the user that sent the message. /// ie; the message is a reply to the user but doesn't contain any other mentions. @@ -34,7 +34,7 @@ public static bool IsOnlyReplyingToAuthor(this ICommandContext context) return false; return context.Message.MentionedUserIds.First() == context.Message.ReferencedMessage.Author.Id; } - + /// /// Returns true if the Context has a reference to another message. /// ie; the message is a reply to another message. diff --git a/DiscordBot/Extensions/EmbedBuilderExtension.cs b/DiscordBot/Extensions/EmbedBuilderExtension.cs index 25296cc2..127fe16c 100644 --- a/DiscordBot/Extensions/EmbedBuilderExtension.cs +++ b/DiscordBot/Extensions/EmbedBuilderExtension.cs @@ -2,30 +2,30 @@ namespace DiscordBot.Extensions; public static class EmbedBuilderExtension { - + public static EmbedBuilder FooterRequestedBy(this EmbedBuilder builder, IUser requestor) { builder.WithFooter( - $"Requested by {requestor.GetUserPreferredName()}", + $"Requested by {requestor.GetUserPreferredName()}", requestor.GetAvatarUrl()); return builder; } - + public static EmbedBuilder FooterQuoteBy(this EmbedBuilder builder, IUser requestor, IChannel channel) { builder.WithFooter( - $"Quoted by {requestor.GetUserPreferredName()}, • From channel #{channel.Name}", + $"Quoted by {requestor.GetUserPreferredName()}, • From channel #{channel.Name}", requestor.GetAvatarUrl()); return builder; } - + public static EmbedBuilder FooterInChannel(this EmbedBuilder builder, IChannel channel) { builder.WithFooter( $"In channel #{channel.Name}", null); return builder; } - + public static EmbedBuilder AddAuthor(this EmbedBuilder builder, IUser user, bool includeAvatar = true) { builder.WithAuthor( @@ -33,7 +33,7 @@ public static EmbedBuilder AddAuthor(this EmbedBuilder builder, IUser user, bool includeAvatar ? user.GetAvatarUrl() : null); return builder; } - + public static EmbedBuilder AddAuthorWithAction(this EmbedBuilder builder, IUser user, string action, bool includeAvatar = true) { builder.WithAuthor( @@ -41,5 +41,5 @@ public static EmbedBuilder AddAuthorWithAction(this EmbedBuilder builder, IUser includeAvatar ? user.GetAvatarUrl() : null); return builder; } - + } \ No newline at end of file diff --git a/DiscordBot/Extensions/InternetExtensions.cs b/DiscordBot/Extensions/InternetExtensions.cs index 84b93099..8abf7d34 100644 --- a/DiscordBot/Extensions/InternetExtensions.cs +++ b/DiscordBot/Extensions/InternetExtensions.cs @@ -1,24 +1,17 @@ -using System.IO; using System.Net; +using System.Net.Http; namespace DiscordBot.Extensions; public static class InternetExtensions { - /// - /// Loads a webpage and returns the contents as a string, Return an empty string on failure. - /// + private static readonly HttpClient _httpClient = new(); + public static async Task GetHttpContents(string uri) { try { - var request = (HttpWebRequest)WebRequest.Create(uri); - request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; - - using var response = (HttpWebResponse)await request.GetResponseAsync(); - await using var stream = response.GetResponseStream(); - using var reader = new StreamReader(stream); - return await reader.ReadToEndAsync(); + return await _httpClient.GetStringAsync(uri); } catch (Exception e) { diff --git a/DiscordBot/Extensions/MessageExtensions.cs b/DiscordBot/Extensions/MessageExtensions.cs index fc039d1e..1f342059 100644 --- a/DiscordBot/Extensions/MessageExtensions.cs +++ b/DiscordBot/Extensions/MessageExtensions.cs @@ -1,12 +1,8 @@ -using System.Text.RegularExpressions; - namespace DiscordBot.Extensions; public static class MessageExtensions { - private const string InviteLinkPattern = @"(https?:\/\/)?(www\.)?(discord\.gg\/[a-zA-Z0-9]+)"; - - public static async Task TrySendMessage(this IDMChannel channel, string message = "", Embed embed = null) + public static async Task TrySendMessage(this IDMChannel channel, string message = "", Embed? embed = null) { try { @@ -18,7 +14,7 @@ public static async Task TrySendMessage(this IDMChannel channel, string me } return true; } - + /// /// Returns true if the message includes any RoleID's, UserID's or Mentions Everyone /// @@ -26,29 +22,4 @@ public static bool HasAnyPingableMention(this IUserMessage message) { return message.MentionedUserIds.Count > 0 || message.MentionedRoleIds.Count > 0 || message.MentionedEveryone; } - - /// - /// Returns true if the message contains any discord invite links, ie; discord.gg/invite - /// - public static bool ContainsInviteLink(this IUserMessage message) - { - return Regex.IsMatch(message.Content, InviteLinkPattern, RegexOptions.IgnoreCase); - } - - /// - /// Returns true if the message contains any discord invite links, ie; discord.gg/invite - /// - public static bool ContainsInviteLink(this string message) - { - return Regex.IsMatch(message, InviteLinkPattern, RegexOptions.IgnoreCase); - } - - /// - /// Returns true if the message contains any discord invite links, ie; discord.gg/invite - /// - public static bool ContainsInviteLink(this IMessage message) - { - return Regex.IsMatch(message.Content, InviteLinkPattern, RegexOptions.IgnoreCase); - } - } \ No newline at end of file diff --git a/DiscordBot/Extensions/StringExtensions.cs b/DiscordBot/Extensions/StringExtensions.cs index 1f0ef267..fc0e3679 100644 --- a/DiscordBot/Extensions/StringExtensions.cs +++ b/DiscordBot/Extensions/StringExtensions.cs @@ -103,22 +103,15 @@ public static int CalculateLevenshteinDistance(this string source1, string sourc public static string GetSha256(this string input) { - var hash = new SHA256CryptoServiceProvider(); - // Convert the input string to a byte array and compute the hash. - var data = hash.ComputeHash(Encoding.UTF8.GetBytes(input)); + var data = SHA256.HashData(Encoding.UTF8.GetBytes(input)); - // Create a new Stringbuilder to collect the bytes - // and create a string. var sb = new StringBuilder(); - // Loop through each byte of the hashed data - // and format each one as a hexadecimal string. for (var i = 0; i < data.Length; i++) sb.Append(data[i].ToString("x2")); - // Return the hexadecimal string. return sb.ToString(); } - + /// /// Returns true if the string contains only upper case characters, including spaces and all punctuation ie; "I NEED HELP!?!?!?!#$?!" will return true /// @@ -126,7 +119,7 @@ public static bool IsAllCaps(this string str) { return Regex.IsMatch(str, @"^[A-Z\s\p{P}]+$"); } - + public static string ToCapitalizeFirstLetter(this string str) { if (string.IsNullOrEmpty(str)) @@ -142,31 +135,31 @@ public static string ToCapitalizeFirstLetter(this string str) /// /// array or list of element phrases to be listed /// final conjunction; defaults to "and" if not given - public static string ToCommaList(this string[] nouns, string conj=null) - { - if (conj == null) - conj = "and"; - var sb = new StringBuilder(); - for (int i = 0; i < nouns.Length; i++) - { - if (i > 0) - { - if (nouns.Length > 2) - sb.Append(','); - sb.Append(' '); - if (i == nouns.Length-1) - sb.Append(conj).Append(' '); - } - sb.Append(nouns[i]); - } - return sb.ToString(); - } + public static string ToCommaList(this string[] nouns, string? conj = null) + { + if (conj == null) + conj = "and"; + var sb = new StringBuilder(); + for (int i = 0; i < nouns.Length; i++) + { + if (i > 0) + { + if (nouns.Length > 2) + sb.Append(','); + sb.Append(' '); + if (i == nouns.Length - 1) + sb.Append(conj).Append(' '); + } + sb.Append(nouns[i]); + } + return sb.ToString(); + } public static string ToBold(this string text) { return $"**{text}**"; } - + public static string[] ToBoldArray(this string[] texts) { var bolds = new string[texts.Length]; diff --git a/DiscordBot/Extensions/TaskExtensions.cs b/DiscordBot/Extensions/TaskExtensions.cs index 66fffa30..a6a088d8 100644 --- a/DiscordBot/Extensions/TaskExtensions.cs +++ b/DiscordBot/Extensions/TaskExtensions.cs @@ -1,24 +1,60 @@ namespace DiscordBot.Extensions; +public static class EventGuard +{ + public static Func Guarded(Func handler, string name) => + async arg => + { + try { await handler(arg); } + catch (Exception e) { LoggingService.LogToConsole($"[{name}] Unhandled exception: {e}", LogSeverity.Error); } + }; + + public static Func Guarded(Func handler, string name) => + async (a1, a2) => + { + try { await handler(a1, a2); } + catch (Exception e) { LoggingService.LogToConsole($"[{name}] Unhandled exception: {e}", LogSeverity.Error); } + }; + + public static Func Guarded(Func handler, string name) => + async (a1, a2, a3) => + { + try { await handler(a1, a2, a3); } + catch (Exception e) { LoggingService.LogToConsole($"[{name}] Unhandled exception: {e}", LogSeverity.Error); } + }; +} + public static class TaskExtensions { - public static Task DeleteAfterTime(this IDeletable message, int seconds = 0, int minutes = 0, int hours = 0, int days = 0) => message?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds)); - public static Task DeleteAfterSeconds(this IDeletable message, double seconds) => message?.DeleteAfterTimeSpan(TimeSpan.FromSeconds(seconds)); + public static void SafeFireAndForget(this Task task, string? caller = null) + { + task.ContinueWith(t => + { + if (t.Exception is not { } ex) return; + if (ex.InnerException is OperationCanceledException) return; + + var prefix = caller != null ? $"[{caller}] " : ""; + LoggingService.LogToConsole($"{prefix}Fire-and-forget exception: {ex}", LogSeverity.Error); + }, TaskContinuationOptions.OnlyOnFaulted); + } + + public static Task? DeleteAfterTime(this IDeletable message, int seconds = 0, int minutes = 0, int hours = 0, int days = 0) => message?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds)); + public static Task? DeleteAfterSeconds(this IDeletable message, double seconds) => message?.DeleteAfterTimeSpan(TimeSpan.FromSeconds(seconds)); public static Task DeleteAfterTimeSpan(this IDeletable message, TimeSpan timeSpan) { return Task.Delay(timeSpan).ContinueWith(async _ => { - if (message != null) await message?.DeleteAsync(); + if (message != null) await message.DeleteAsync(); }); } - public static Task DeleteAfterTime(this Task task, int seconds = 0, int minutes = 0, int hours = 0, int days = 0, bool awaitDeletion = false) where T : IDeletable => task?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds), awaitDeletion); - public static Task DeleteAfterSeconds(this Task task, double seconds, bool awaitDeletion = false) where T : IDeletable => task?.DeleteAfterTimeSpan(TimeSpan.FromSeconds(seconds), awaitDeletion); + public static Task? DeleteAfterTime(this Task task, int seconds = 0, int minutes = 0, int hours = 0, int days = 0, bool awaitDeletion = false) where T : IDeletable => task?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds), awaitDeletion); + public static Task? DeleteAfterSeconds(this Task task, double seconds, bool awaitDeletion = false) where T : IDeletable => task?.DeleteAfterTimeSpan(TimeSpan.FromSeconds(seconds), awaitDeletion); public static Task DeleteAfterTimeSpan(this Task task, TimeSpan timeSpan, bool awaitDeletion = false) where T : IDeletable { - var deletion = Task.Run(async () => await (await task)?.DeleteAfterTimeSpan(timeSpan)); + var deletion = Task.Run(async () => await ((await task)?.DeleteAfterTimeSpan(timeSpan) ?? Task.CompletedTask)); return awaitDeletion ? deletion : task; } diff --git a/DiscordBot/Extensions/UserDBRepository.cs b/DiscordBot/Extensions/UserDBRepository.cs index ce932b21..9443e3d3 100644 --- a/DiscordBot/Extensions/UserDBRepository.cs +++ b/DiscordBot/Extensions/UserDBRepository.cs @@ -5,7 +5,7 @@ namespace DiscordBot.Extensions; public class ServerUser { // ReSharper disable once InconsistentNaming - public string UserID { get; set; } + public string UserID { get; set; } = string.Empty; public int Karma { get; set; } public int KarmaWeekly { get; set; } public int KarmaMonthly { get; set; } @@ -79,7 +79,7 @@ public interface IServerUserRepo [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Level} = @level WHERE {UserProps.UserID} = @userId")] Task UpdateLevel(string userId, int level); [Sql($"UPDATE {UserProps.TableName} SET {UserProps.DefaultCity} = @city WHERE {UserProps.UserID} = @userId")] - Task UpdateDefaultCity(string userId, string city); + Task UpdateDefaultCity(string userId, string? city); #endregion // Update Values diff --git a/DiscordBot/Extensions/UserExtensions.cs b/DiscordBot/Extensions/UserExtensions.cs index 76ed2e0b..2c641ed0 100644 --- a/DiscordBot/Extensions/UserExtensions.cs +++ b/DiscordBot/Extensions/UserExtensions.cs @@ -8,8 +8,8 @@ public static bool IsUserBotOrWebhook(this IUser user) { return user.IsBot || user.IsWebhook; } - - public static bool HasRoleGroup(this IUser user, SocketRole role) + + public static bool HasRoleGroup(this IUser user, SocketRole role) { return HasRoleGroup(user, role.Id); } @@ -27,7 +27,7 @@ public static string GetUserPreferredName(this IUser user) var guildUser = user as SocketGuildUser; return guildUser?.DisplayName ?? user.Username; } - + public static string GetPreferredAndUsername(this IUser user) { var guildUser = user as SocketGuildUser; diff --git a/DiscordBot/GlobalUsings.cs b/DiscordBot/GlobalUsings.cs index 35eb99f9..f21d7d9f 100644 --- a/DiscordBot/GlobalUsings.cs +++ b/DiscordBot/GlobalUsings.cs @@ -11,4 +11,27 @@ // Our code global using DiscordBot.Extensions; -global using DiscordBot.Services.Logging; \ No newline at end of file +global using DiscordBot.Services.Logging; + +// Module sub-namespaces +global using DiscordBot.Modules.Profiles; +global using DiscordBot.Modules.Server; +global using DiscordBot.Modules.Fun; +global using DiscordBot.Modules.Fun.Casino; +global using DiscordBot.Modules.Utils; +global using DiscordBot.Modules.Utils.Weather; +global using DiscordBot.Modules.Code; +global using DiscordBot.Modules.Code.Unity.UnityHelp; + +// Service sub-namespaces +global using DiscordBot.Services.Profiles; +global using DiscordBot.Services.Server; +global using DiscordBot.Services.Fun; +global using DiscordBot.Services.Fun.Casino; +global using DiscordBot.Services.Utils; +global using DiscordBot.Services.Utils.Weather; +global using DiscordBot.Services.Code; +global using DiscordBot.Services.Code.Tips; +global using DiscordBot.Services.Code.Tips.Components; +global using DiscordBot.Services.Code.Unity; +global using DiscordBot.Services.Code.Unity.UnityHelp; \ No newline at end of file diff --git a/DiscordBot/Modules/Code/CodeTipModule.cs b/DiscordBot/Modules/Code/CodeTipModule.cs new file mode 100644 index 00000000..9bb2593c --- /dev/null +++ b/DiscordBot/Modules/Code/CodeTipModule.cs @@ -0,0 +1,35 @@ +using Discord.Commands; +using DiscordBot.Services; + +namespace DiscordBot.Modules.Code; + +[Group("UserModule"), Alias("")] +public class CodeTipModule : ModuleBase +{ + public CodeCheckService CodeCheckService { get; set; } = null!; + + [Command("CodeTip"), Priority(20)] + [Summary("Show code formatting example. Syntax: !codetip userToPing(optional)")] + [Alias("codetips")] + public async Task CodeTip(IUser? user = null) + { + var message = user != null ? user.Mention + ", " : ""; + message += "When posting code, format it like so:" + Environment.NewLine; + message += CodeCheckService.CodeFormattingExample; + await Context.Message.DeleteAsync(); + await ReplyAsync(message).DeleteAfterSeconds(seconds: 60)!; + } + + [Command("DisableCodeTips"), Priority(91)] + [Summary("Stops code formatting reminders.")] + public async Task DisableCodeTips() + { + await Context.Message.DeleteAsync(); + if (!CodeCheckService.CodeReminderCooldown.IsPermanent(Context.User.Id)) + { + CodeCheckService.CodeReminderCooldown.SetPermanent(Context.User.Id, true); + var uname = Context.User.GetUserPreferredName(); + await ReplyAsync($"{uname}, you will no longer be reminded about correct code formatting.").DeleteAfterTime(20)!; + } + } +} diff --git a/DiscordBot/Modules/Code/TipModule.cs b/DiscordBot/Modules/Code/TipModule.cs new file mode 100644 index 00000000..cd9068c0 --- /dev/null +++ b/DiscordBot/Modules/Code/TipModule.cs @@ -0,0 +1,249 @@ +using System.IO; +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Services; +using DiscordBot.Settings; + +// ReSharper disable all UnusedMember.Local +namespace DiscordBot.Modules.Code; + +public class TipModule : ModuleBase +{ + #region Dependency Injection + + public CommandHandlingService CommandHandlingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; + public TipService TipService { get; set; } = null!; + + #endregion + + private bool IsAuthorized(IUser user) + { + if (user.HasRoleGroup(Settings.Roles.Moderator)) + return true; + if (user.HasRoleGroup(Settings.Roles.TipsUser)) + return true; + + return false; + } + + [Command("Tip")] + [Summary("Find and provide pre-authored tips (images or text) by their keywords.")] + /* removing [RequireModerator] for custom check */ + public async Task Tip(params string[] keywords) + { + var user = Context.Message.Author; + if (!IsAuthorized(user)) + return; + + var terms = string.Join(",", keywords); + var tips = TipService.GetTips(terms); + if (tips.Count == 0) + { + await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5)!; + return; + } + + foreach (var tip in tips) + tip.Requests++; + + var isAnyTextTips = tips.Any(tip => !string.IsNullOrEmpty(tip.Content)); + var builder = new EmbedBuilder(); + if (isAnyTextTips) + { + // Loop through tips in order, have dot point list of the .Content property in an embed + builder + .WithTitle("Tip List") + .WithDescription("Here are the tips for your keywords:"); + foreach (var tip in tips) + { + builder.AddField(tip.Keywords.Count == 1 ? tip.Keywords[0] : "Multiple Keywords", tip.Content); + } + } + + var attachments = tips + .Where(tip => tip.ImagePaths != null && tip.ImagePaths.Any()) + .SelectMany(tip => tip.ImagePaths) + .Select(imagePath => new FileAttachment(TipService.GetTipPath(imagePath))) + .ToList(); + + if (attachments.Count > 0) + { + if (isAnyTextTips) + { + await Context.Channel.SendFilesAsync(attachments, embed: builder.Build()); + } + else + { + await Context.Channel.SendFilesAsync(attachments); + } + } + else + { + await ReplyAsync(embed: builder.Build()); + } + + var ids = string.Join(" ", tips.Select(t => t.Id.ToString()).ToArray()); + await ReplyAsync($"-# Tip ID {ids}"); + await Context.Message.DeleteAsync(); + await TipService.CommitTipDatabase(); + } + + [Command("AddTip")] + [Summary("Add a tip to the database.")] + [RequireModerator] + public async Task AddTip(string keywords, string content = "") + { + await TipService.AddTip(Context.Message, keywords, content); + } + + [Command("RemoveTip")] + [Summary("Remove a tip from the database.")] + [RequireModerator] + public async Task RemoveTip(ulong tipId) + { + Tip? tip = TipService.GetTip(tipId); + if (tip == null) + { + await Context.Channel.SendMessageAsync("No such tip found to be removed.").DeleteAfterSeconds(5)!; + return; + } + + await TipService.RemoveTip(Context.Message, tip); + } + + [Command("ReplaceTip")] + [Summary("Replace image content of an existing tip in the database.")] + [RequireModerator] + public async Task ReplaceTip(ulong tipId, string content = "") + { + Tip? tip = TipService.GetTip(tipId); + if (tip == null) + { + await Context.Channel.SendMessageAsync("No such tip found to be replaced.").DeleteAfterSeconds(5)!; + return; + } + + await TipService.ReplaceTip(Context.Message, tip, content); + } + + [Command("ReloadTips")] + [Summary("Reload the database of tips.")] + [RequireModerator] + public async Task ReloadTipDatabase() + { + // rare usage, but in case someone with a shell decides + // to edit the json for debugging/expansion reasons... + await TipService.ReloadTipDatabase(); + await ReplyAsync("Tip index reloaded."); + } + + [Command("ListTips")] + [Summary("List available tips by their keywords.")] + /* removing [RequireModerator] for custom check */ + public async Task ListTips(params string[] keywords) + { + var user = Context.Message.Author; + if (!IsAuthorized(user)) + return; + + int floodCount = 20; + + List? tips = null; + if (keywords?.Length > 0) + { + var terms = string.Join(",", keywords); + tips = TipService.GetTips(terms); + if (tips.Count == 0) + { + await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5)!; + return; + } + if (tips.Count >= floodCount) + { + await ReplyAsync($"Total of {tips.Count} tips found for the keywords provided; refine your search.").DeleteAfterSeconds(5)!; + return; + } + } + else + { + tips = TipService.GetAllTips().OrderBy(t => t.Id).ToList(); + if (tips.Count >= floodCount) + { + var terms = new HashSet(); + foreach (var tip in tips) + foreach (var term in tip.Keywords) + terms.Add(term); + await ReplyAsync($"Total of {tips.Count} tips found, add one or more keywords to narrow the search."); + var termList = new List(); + foreach (var tip in terms.OrderBy(k => k)) + termList.Add(tip); + floodCount = 150; + while (termList.Count > 0) + { + int count = termList.Count; + if (count > floodCount) + count = floodCount - 10; + string keywordList = "Keywords: "; + for (int i = 0; i < count; i++) + { + keywordList += $"`{termList[0]}`, "; + termList.RemoveAt(0); + } + keywordList = keywordList.Substring(0, keywordList.Length - 2); + await ReplyAsync(keywordList); + if (termList.Count > 0) + await Task.Delay(500); + } + return; + } + } + + int chunkCount = 10; + int chunkTime = 1500; + bool first = true; + + while (tips.Count > 0) + { + var builder = new EmbedBuilder(); + if (first) + { + builder + .WithTitle("List of Tips") + .WithDescription("Tips available for the following keywords:"); + first = false; + } + + int chunk = 0; + while (tips.Count > 0 && chunk < chunkCount) + { + string keywordlist = string.Join("`, `", tips[0].Keywords.OrderBy(k => k)); + string images = String.Concat( + Enumerable.Repeat(" :frame_photo:", + tips[0].ImagePaths.Count).ToArray()); + builder.AddField($"ID: {tips[0].Id} {images}", $"`{keywordlist}`"); + tips.RemoveAt(0); + chunk++; + } + + await ReplyAsync(embed: builder.Build()); + if (tips.Count > 0) + await Task.Delay(chunkTime); + } + } + + #region CommandList + [Command("TipHelp")] + [Alias("TipsHelp")] + [Summary("Shows available tip database commands.")] + public async Task TipHelp() + { + // NOTE: skips the RequireModerator commands, so nearly an empty list + foreach (var message in CommandHandlingService.GetCommandListMessages("TipModule", true, true, false)) + { + await ReplyAsync(message); + } + } + #endregion + +} diff --git a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/CannedInteractiveModule.cs similarity index 77% rename from DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/CannedInteractiveModule.cs index fe745810..40d9fd71 100644 --- a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/CannedInteractiveModule.cs @@ -1,23 +1,22 @@ using Discord.Commands; using Discord.Interactions; using Discord.WebSocket; -using DiscordBot.Service; using DiscordBot.Services; using DiscordBot.Settings; -using static DiscordBot.Service.CannedResponseService; +using static DiscordBot.Services.Code.Unity.UnityHelp.CannedResponseService; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class CannedInteractiveModule : InteractionModuleBase { #region Dependency Injection - public UnityHelpService HelpService { get; set; } - public BotSettings BotSettings { get; set; } - public CannedResponseService CannedResponseService { get; set; } - + public UnityHelpService HelpService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; + public CannedResponseService CannedResponseService { get; set; } = null!; + #endregion // Dependency Injection - + // Responses are any of the CannedResponseType enum [SlashCommand("faq", "Prepared responses to help answer common questions")] public async Task CannedResponses(CannedHelp type) @@ -26,9 +25,10 @@ public async Task CannedResponses(CannedHelp type) return; var embed = CannedResponseService.GetCannedResponse((CannedResponseType)type); + if (embed == null) return; await Context.Interaction.RespondAsync(string.Empty, embed: embed.Build()); } - + [SlashCommand("resources", "Links to resources to help answer common questions")] public async Task Resources(CannedResources type) { @@ -36,6 +36,7 @@ public async Task Resources(CannedResources type) return; var embed = CannedResponseService.GetCannedResponse((CannedResponseType)type); + if (embed == null) return; await Context.Interaction.RespondAsync(string.Empty, embed: embed.Build()); } } \ No newline at end of file diff --git a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/CannedResponseModule.cs similarity index 93% rename from DiscordBot/Modules/UnityHelp/CannedResponseModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/CannedResponseModule.cs index 6584e64d..35eaf3b8 100644 --- a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/CannedResponseModule.cs @@ -1,132 +1,131 @@ using Discord.Commands; using DiscordBot.Attributes; -using DiscordBot.Service; using DiscordBot.Services; using DiscordBot.Settings; -using static DiscordBot.Service.CannedResponseService; +using static DiscordBot.Services.Code.Unity.UnityHelp.CannedResponseService; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class CannedResponseModule : ModuleBase { #region Dependency Injection - - public UserService UserService { get; set; } - public BotSettings BotSettings { get; set; } - public CannedResponseService CannedResponseService { get; set; } - + + public BotSettings BotSettings { get; set; } = null!; + public CannedResponseService CannedResponseService { get; set; } = null!; + #endregion // Dependency Injection - + // The core command for the canned response module public async Task RespondWithCannedResponse(CannedResponseType type) { if (Context.User.IsUserBotOrWebhook()) return; - + var embed = CannedResponseService.GetCannedResponse(type, Context.User); + if (embed == null) return; await Context.Message.DeleteAsync(); - + await ReplyAsync(string.Empty, false, embed.Build()); } - + [Command("ask"), Alias("dontasktoask", "nohello")] [Summary("When someone asks to ask a question, respond with a link to the 'How to Ask' page.")] public async Task RespondWithHowToAsk() { await RespondWithCannedResponse(CannedResponseType.HowToAsk); } - + [Command("paste")] [Summary("When someone asks how to paste code, respond with a link to the 'How to Paste Code' page.")] public async Task RespondWithHowToPaste() { await RespondWithCannedResponse(CannedResponseType.Paste); } - + [Command("nocode")] [Summary("When someone asks for help with code, but doesn't provide any, respond with a link to the 'No Code Provided' page.")] public async Task RespondWithNoCode() { await RespondWithCannedResponse(CannedResponseType.NoCode); } - + [Command("xy")] [Summary("When someone is asking about their attempted solution rather than their actual problem, respond with a link to the 'XY Problem' page.")] public async Task RespondWithXYProblem() { await RespondWithCannedResponse(CannedResponseType.XYProblem); } - + [Command("biggame"), Alias("scope", "bigscope", "scopecreep")] [Summary("When someone is asking for help with a large project, respond with a link to the 'Game Too Big' page.")] public async Task RespondWithGameToBig() { await RespondWithCannedResponse(CannedResponseType.GameTooBig); } - + [Command("google"), Alias("search", "howtosearch")] [Summary("When someone asks a question that could have been answered by a quick search, respond with a link to the 'How to Google' page.")] public async Task RespondWithHowToGoogle() { await RespondWithCannedResponse(CannedResponseType.HowToGoogle); } - + [Command("debug")] [Summary("When someone asks for help debugging, respond with a link to the 'How to Debug' page.")] public async Task RespondWithHowToDebug() { await RespondWithCannedResponse(CannedResponseType.Debugging); } - + [Command("folder"), Alias("directory", "structure")] [Summary("When someone asks about folder structure, respond with a link to the 'Folder Structure' page.")] public async Task RespondWithFolderStructure() { await RespondWithCannedResponse(CannedResponseType.FolderStructure); } - + [Command("programming")] [Summary("When someone asks for programming resources, respond with a link to the 'Programming Resources' page.")] public async Task RespondWithProgrammingResources() { await RespondWithCannedResponse(CannedResponseType.Programming); } - + [Command("art")] [Summary("When someone asks for art resources, respond with a link to the 'Art Resources' page.")] public async Task RespondWithArtResources() { await RespondWithCannedResponse(CannedResponseType.Art); } - + [Command("3d"), Alias("3dmodeling", "3dassets")] [Summary("When someone asks for 3D modeling resources, respond with a link to the '3D Modeling Resources' page.")] public async Task RespondWith3DModelingResources() { await RespondWithCannedResponse(CannedResponseType.ThreeD); } - + [Command("2d"), Alias("2dmodeling", "2dassets")] [Summary("When someone asks for 2D modeling resources, respond with a link to the '2D Modeling Resources' page.")] public async Task RespondWith2DModelingResources() { await RespondWithCannedResponse(CannedResponseType.TwoD); } - + [Command("audio"), Alias("sound", "music")] [Summary("When someone asks for audio resources, respond with a link to the 'Audio Resources' page.")] public async Task RespondWithAudioResources() { await RespondWithCannedResponse(CannedResponseType.Audio); } - + [Command("design"), Alias("ui", "ux")] [Summary("When someone asks for design resources, respond with a link to the 'Design Resources' page.")] public async Task RespondWithDesignResources() { await RespondWithCannedResponse(CannedResponseType.Design); } - + [Command("delta"), Alias("deltatime", "fixedupdate")] [Summary("When someone asks about delta time, respond with a link to the 'Delta Time' page.")] public async Task RespondWithDeltaTime() diff --git a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/GeneralHelpModule.cs similarity index 82% rename from DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/GeneralHelpModule.cs index d9da9385..ec2bf906 100644 --- a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/GeneralHelpModule.cs @@ -4,24 +4,24 @@ using DiscordBot.Utils; using HtmlAgilityPack; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class GeneralHelpModule : ModuleBase { #region Dependency Injection - - public UserService UserService { get; set; } - public BotSettings BotSettings { get; set; } + + public BotSettings BotSettings { get; set; } = null!; + public IWebClient WebClient { get; set; } = null!; #endregion // Dependency Injection - + [Command("error")] [Summary("Uses a C# error code, or Unity error code and returns a link to appropriate documentation.")] public async Task RespondWithErrorDocumentation(string error) { if (Context.User.IsUserBotOrWebhook()) return; - + // If we're dealing with C# error if (error.StartsWith("CS")) { @@ -31,14 +31,14 @@ public async Task RespondWithErrorDocumentation(string error) "https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/", "https://docs.microsoft.com/en-us/dotnet/csharp/misc/" }; - - HtmlDocument errorPage = null; + + HtmlDocument? errorPage = null; string usedUrl = string.Empty; - + foreach (var url in urls) { - errorPage = await WebUtil.GetHtmlDocument($"{url}{error}"); - if (errorPage.DocumentNode.InnerHtml.Contains("Page not found")) + errorPage = await WebClient.GetHtmlDocument($"{url}{error}"); + if (errorPage == null || errorPage.DocumentNode.InnerHtml.Contains("Page not found")) { continue; } @@ -82,7 +82,7 @@ await respondFailure( private async Task respondFailure(string errorMessage) { - await ReplyAsync(errorMessage).DeleteAfterSeconds(30); - await Context.Message.DeleteAfterSeconds(30); + await (ReplyAsync(errorMessage).DeleteAfterSeconds(30) ?? Task.CompletedTask); + await (Context.Message?.DeleteAfterSeconds(30) ?? Task.CompletedTask); } } \ No newline at end of file diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpInteractiveModule.cs similarity index 78% rename from DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpInteractiveModule.cs index 8e24e8bb..d4257899 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpInteractiveModule.cs @@ -3,23 +3,23 @@ using DiscordBot.Settings; using Discord.WebSocket; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class UnityHelpInteractiveModule : InteractionModuleBase { #region Dependency Injection - public UnityHelpService HelpService { get; set; } - public BotSettings BotSettings { get; set; } - + public UnityHelpService HelpService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; + #endregion // Dependency Injection [SlashCommand("resolve-question", "If in unity-help forum channel, resolve the thread")] public async Task ResolveQuestion() { - if (!BotSettings.UnityHelpBabySitterEnabled) + if (!BotSettings.UnityHelp.BabySitterEnabled) return; - + await Context.Interaction.DeferAsync(ephemeral: true); if (!IsValidUser()) @@ -31,12 +31,12 @@ public async Task ResolveQuestion() if (!IsInHelpChannel()) { await Context.Interaction.FollowupAsync( - $"This command can only be used in <#{BotSettings.GenericHelpChannel.Id}> channels", ephemeral: true); + $"This command can only be used in <#{BotSettings.Channels.GenericHelp.Id}> channels", ephemeral: true); return; } var response = - await HelpService.OnUserRequestChannelClose(Context.User, Context.Channel as SocketThreadChannel); + await HelpService.OnUserRequestChannelClose(Context.User, (Context.Channel as SocketThreadChannel)!); await Context.Interaction.FollowupAsync(response, ephemeral: true); } @@ -45,9 +45,9 @@ await Context.Interaction.FollowupAsync( [MessageCommand("Correct Answer")] public async Task MarkResponseAnswer(IMessage targetResponse) { - if (!BotSettings.UnityHelpBabySitterEnabled) + if (!BotSettings.UnityHelp.BabySitterEnabled) return; - + await Context.Interaction.DeferAsync(ephemeral: true); if (!IsValidUser()) { @@ -57,7 +57,7 @@ public async Task MarkResponseAnswer(IMessage targetResponse) if (!IsInHelpChannel()) { await Context.Interaction.FollowupAsync( - $"This command can only be used in <#{BotSettings.GenericHelpChannel.Id}> channels", ephemeral: true); + $"This command can only be used in <#{BotSettings.Channels.GenericHelp.Id}> channels", ephemeral: true); return; } @@ -68,15 +68,15 @@ await Context.Interaction.FollowupAsync( } var response = await HelpService.MarkResponseAsAnswer(Context.User, targetResponse); - await Context.Interaction.FollowupAsync( response, ephemeral: true); + await Context.Interaction.FollowupAsync(response, ephemeral: true); } #endregion // Context Commands - + #region Utility - - private bool IsInHelpChannel() => Context.Channel.IsThreadInChannel(BotSettings.GenericHelpChannel.Id); + + private bool IsInHelpChannel() => Context.Channel.IsThreadInChannel(BotSettings.Channels.GenericHelp.Id); private bool IsValidUser() => !Context.User.IsUserBotOrWebhook(); #endregion // Utility diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpModule.cs similarity index 76% rename from DiscordBot/Modules/UnityHelp/UnityHelpModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpModule.cs index 8e64f22a..ec4ef2da 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpModule.cs @@ -4,15 +4,14 @@ using DiscordBot.Services; using DiscordBot.Settings; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class UnityHelpModule : ModuleBase { #region Dependency Injection - public UnityHelpService HelpService { get; set; } - public UserService UserService { get; set; } - public BotSettings BotSettings { get; set; } + public UnityHelpService HelpService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; #endregion // Dependency Injection @@ -20,21 +19,21 @@ public class UnityHelpModule : ModuleBase [Summary("When a question is answered, use this command to mark it as resolved.")] public async Task ResolveAsync() { - if (!BotSettings.UnityHelpBabySitterEnabled) + if (!BotSettings.UnityHelp.BabySitterEnabled) return; if (!IsValidUser() || !IsInHelpChannel()) await Context.Message.DeleteAsync(); - await HelpService.OnUserRequestChannelClose(Context.User, Context.Channel as SocketThreadChannel); + await HelpService.OnUserRequestChannelClose(Context.User, (Context.Channel as SocketThreadChannel)!); } - + [Command("pending-questions")] [Summary("Moderation only command, announces the number of pending questions in the help channel.")] [RequireModerator, HideFromHelp, IgnoreBots] public async Task PendingQuestionsAsync() { - if (!BotSettings.UnityHelpBabySitterEnabled) + if (!BotSettings.UnityHelp.BabySitterEnabled) { - await ReplyAsync("UnityHelp Service currently disabled.").DeleteAfterSeconds(15); + await ReplyAsync("UnityHelp Service currently disabled.").DeleteAfterSeconds(15)!; return; } var trackedQuestionCount = HelpService.GetTrackedQuestionCount(); @@ -43,7 +42,7 @@ public async Task PendingQuestionsAsync() #region Utility - private bool IsInHelpChannel() => Context.Channel.IsThreadInChannel(BotSettings.GenericHelpChannel.Id); + private bool IsInHelpChannel() => Context.Channel.IsThreadInChannel(BotSettings.Channels.GenericHelp.Id); private bool IsValidUser() => !Context.User.IsUserBotOrWebhook(); #endregion // Utility diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs deleted file mode 100644 index f46bd30a..00000000 --- a/DiscordBot/Modules/EmbedModule.cs +++ /dev/null @@ -1,284 +0,0 @@ -using System.Net; -using System.Text; -using Discord.Commands; -using DiscordBot.Attributes; -using Newtonsoft.Json; - -// ReSharper disable all UnusedMember.Local -namespace DiscordBot.Modules; - -[RequireAdmin] -public class EmbedModule : ModuleBase -{ - -#pragma warning disable 0649 - private class Embed - { - public class Footer - { - public string icon_url; - public string text; - } - - public class Thumbnail - { - public string url; - } - - public class Image - { - public string url; - } - - public class Author - { - public string name; - public string url; - public string icon_url; - } - - public class Field - { - public string name; - public string value; - public bool? inline; - } - - public string title; - public string description; - public string url; - public uint? color; - public DateTimeOffset? timestamp; - public Footer footer; - public Thumbnail thumbnail; - public Image image; - public Author author; - public Field[] fields; - } -#pragma warning restore 0649 - - /// - /// Generate an embed - /// - [RequireAdmin] - [Command("embed"), Summary("Generate an embed.")] - public async Task EmbedCommand(IMessageChannel channel = null, ulong messageId = 0) - { - await Context.Message.DeleteAsync(); - channel ??= Context.Channel; - - if (Context.Message.Attachments.Count < 1) - { - await ReplyAsync($"{Context.User.Mention}, you must provide a JSON file or a JSON url.").DeleteAfterSeconds(5); - return; - } - var attachment = Context.Message.Attachments.ElementAt(0); - var embed = BuildEmbedFromUrl(attachment.Url); - - await SendEmbedToChannel(embed, channel, messageId); - } - - [Command("embed"), Summary("Generate an embed from an URL (hastebin).")] - public async Task EmbedCommand(string url, IMessageChannel channel = null, ulong messageId = 0) - { - await Context.Message.DeleteAsync(); - Discord.Embed builtEmbed = await TryGetEmbedFromUrl(url); - if (builtEmbed != null) - await SendEmbedToChannel(builtEmbed, channel, messageId); - } - - // Checks if the the argument is a url and if the host is supported. If so it will try to return a built embeded object. Returns null if invalid. - private async Task TryGetEmbedFromUrl(string url) - { - Uri uriResult; - bool result = Uri.TryCreate(url, UriKind.Absolute, out uriResult) - && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); - if (!result) - { - await ReplyAsync($"{Context.User.Mention}, the parameter is not a valid URL.").DeleteAfterSeconds(5); - return null; - } - if (!IsValidHost(uriResult.Host)) - { - await ReplyAsync($"{Context.User.Mention}, supported URLs: [https://hastebin.com, https://pastebin.com, https://gdl.space, https://hastepaste.com, http://pastie.org].").DeleteAfterSeconds(5); - return null; - } - string download_url = GetDownUrlFromUri(uriResult); - var builtEmbed = BuildEmbedFromUrl(download_url); - if (builtEmbed.Length == 0) - { - await ReplyAsync("Failed to generate embed from url.").DeleteAfterSeconds(seconds: 10f); - return null; - } - return builtEmbed; - } - - private Discord.Embed BuildEmbedFromUrl(string url) - { - WebClient webClient = new(); - byte[] buffer = webClient.DownloadData(url); - webClient.Dispose(); - string json = Encoding.UTF8.GetString(buffer); - - return BuildEmbed(json); - } - - private bool IsValidHost(string url) - { - switch (url) - { - case "hastebin.com": - case "gdl.space": - case "hastepaste.com": - case "pastebin.com": - case "pastie.org": - return true; - default: - return false; - } - } - - private string GetDownUrlFromUri(Uri uri) - { - switch (uri.Host) - { - case "hastebin.com": - case "gdl.space": - return $"https://{uri.Host}/raw{uri.AbsolutePath}"; - case "hastepaste.com": - return $"https://hastepaste.com/raw{uri.AbsolutePath.Substring(5)}"; - case "pastebin.com": - return $"https://pastebin.com/raw{uri.AbsolutePath}"; - case "pastie.org": - return $"{uri.OriginalString}/raw"; - } - return string.Empty; - } - - private Discord.Embed BuildEmbed(string json) - { - try - { - var embed_data = JsonConvert.DeserializeObject(json); - var embedBuilder = new EmbedBuilder(); - if (!String.IsNullOrEmpty(embed_data.title)) embedBuilder.Title = embed_data.title; - if (!String.IsNullOrEmpty(embed_data.description)) embedBuilder.Description = embed_data.description; - if (!String.IsNullOrEmpty(embed_data.url)) embedBuilder.Url = embed_data.url; - if (embed_data.color.HasValue) embedBuilder.Color = new Color(embed_data.color.Value); - if (embed_data.timestamp.HasValue) embedBuilder.Timestamp = embed_data.timestamp.Value; - - if (embed_data.footer != null) - { - embedBuilder.Footer = new EmbedFooterBuilder(); - if (!String.IsNullOrEmpty(embed_data.footer.icon_url)) embedBuilder.Footer.IconUrl = embed_data.footer.icon_url; - if (!String.IsNullOrEmpty(embed_data.footer.text)) embedBuilder.Footer.Text = embed_data.footer.text; - } - - if (embed_data.thumbnail != null && !String.IsNullOrEmpty(embed_data.thumbnail.url)) embedBuilder.ThumbnailUrl = embed_data.thumbnail.url; - if (embed_data.image != null && !String.IsNullOrEmpty(embed_data.image.url)) embedBuilder.ImageUrl = embed_data.image.url; - - if (embed_data.author != null) - { - embedBuilder.Author = new EmbedAuthorBuilder(); - if (!String.IsNullOrEmpty(embed_data.author.icon_url)) embedBuilder.Author.IconUrl = embed_data.author.icon_url; - if (!String.IsNullOrEmpty(embed_data.author.name)) embedBuilder.Author.Name = embed_data.author.name; - if (!String.IsNullOrEmpty(embed_data.author.url)) embedBuilder.Author.Url = embed_data.author.url; - } - - if (embed_data.fields != null) - { - foreach (var field in embed_data.fields) - { - var f = new EmbedFieldBuilder(); - if (!String.IsNullOrEmpty(field.name)) f.Name = field.name; - if (!String.IsNullOrEmpty(field.value)) f.Value = field.value; - if (field.inline.HasValue) f.IsInline = field.inline.Value; - embedBuilder.AddField(f); - } - } - - return embedBuilder.Build(); - } - catch (Exception e) - { - Console.Error.WriteLine(e); - ReplyAsync($"{Context.User.Mention}, the provided JSON is invalid.").DeleteAfterSeconds(5); - } - - return null; - } - - private readonly IEmote _thumbUpEmote = new Emoji("👍"); - - private async Task SendEmbedToChannel(Discord.Embed embed, IMessageChannel channel, ulong messageId = 0) - { - if (embed == null || embed.Length <= 0) - { - await ReplyAsync("Embed is improperly formatted or corrupt."); - return; - } - - // If context.channel is same as channel we don't need to confirm details - if (Context.Channel != channel) - { - // Confirm with user it is correct - var tempEmbed = await ReplyAsync(embed: embed); - var message = await ReplyAsync("If correct, react to this message within 20 seconds to continue."); - await message.AddReactionAsync(_thumbUpEmote); - // 20 seconds wait? - bool confirmedEmbed = false; - for (int i = 0; i < 10; i++) - { - await Task.Delay(2000); - var reactions = await message.GetReactionUsersAsync(_thumbUpEmote, 10).FlattenAsync(); - if (reactions.Count() > 1) - { - // Just in case other people are trying to react to the message,we check all reactions and confirm we got one from the user generating the embed. - foreach (var reaction in reactions) - { - if (reaction.Id == Context.User.Id) - { - confirmedEmbed = true; - break; - } - } - } - - i++; - } - - await tempEmbed.DeleteAsync(); - await message.DeleteAsync(); - // If no reaction, we assume it was bad and abort - if (!confirmedEmbed) - { - await ReplyAsync("Reaction not detected, embed aborted.").DeleteAfterSeconds(seconds: 5); - return; - } - } - - if (messageId != 0) - { - var messageToEdit = await channel.GetMessageAsync(messageId) as IUserMessage; - if (messageToEdit == null) - { - await ReplyAsync($"Bot doesn't own the message ID ``{messageId}`` used").DeleteAfterSeconds(5); - return; - } - - // Modify the old message, we clear any text it might have had. - await messageToEdit.ModifyAsync(x => - { - x.Content = ""; - x.Embed = embed; - }); - await ReplyAsync("Message replaced!").DeleteAfterSeconds(5); - } - else - { - await channel.SendMessageAsync(embed: embed); - if (Context.Channel != channel) - await ReplyAsync("Embed Posted!").DeleteAfterSeconds(5); - } - } -} \ No newline at end of file diff --git a/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.Games.cs similarity index 99% rename from DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs rename to DiscordBot/Modules/Fun/Casino/CasinoSlashModule.Games.cs index c0d6926d..846cf528 100644 --- a/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs +++ b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.Games.cs @@ -3,7 +3,7 @@ using DiscordBot.Domain; using DiscordBot.Services; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Fun.Casino; public enum CasinoGame { @@ -17,7 +17,7 @@ public partial class CasinoSlashModule : InteractionModuleBase { #region Dependency Injection - public CasinoService CasinoService { get; set; } - public ILoggingService LoggingService { get; set; } - public BotSettings BotSettings { get; set; } + public CasinoService CasinoService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; #endregion @@ -38,9 +38,10 @@ await Context.Interaction.RespondAsync( [Group("tokens", "Token management commands")] public class TokenCommands : InteractionModuleBase { - public CasinoService CasinoService { get; set; } - public ILoggingService LoggingService { get; set; } - public BotSettings BotSettings { get; set; } + public CasinoService CasinoService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; + public TransactionFormatter TransactionFormatter { get; set; } = null!; private async Task CheckChannelPermissions() { @@ -214,7 +215,7 @@ public async Task TokenHistoryAdmin( await DisplayTransactionHistory(userId: null, page: 1, targetUser: targetUser, isInitialCall: true); } - private async Task DisplayTransactionHistory(string userId = null, int page = 1, SocketGuildUser? targetUser = null, bool isInitialCall = false) + private async Task DisplayTransactionHistory(string? userId = null, int page = 1, SocketGuildUser? targetUser = null, bool isInitialCall = false) { try { @@ -235,7 +236,7 @@ private async Task DisplayTransactionHistory(string userId = null, int page = 1, } if (isAdminRequest) - queryUserId = targetUser.Id.ToString(); + queryUserId = targetUser!.Id.ToString(); } const int transactionsPerPage = 5; @@ -301,7 +302,7 @@ private async Task DisplayTransactionHistory(string userId = null, int page = 1, foreach (var transaction in transactions) { var amountText = transaction.Amount >= 0 ? $"+{transaction.Amount}" : transaction.Amount.ToString(); - var (emoji, transactionTitle, transactionDescription) = FormatTransactionDisplay(transaction, isAllUsersRequest); + var (emoji, transactionTitle, transactionDescription) = TransactionFormatter.Format(transaction, Context.Guild, isAllUsersRequest); embed.AddField($"{emoji} {transactionTitle}", $"{amountText} tokens - *{TimestampTag.FromDateTime(transaction.CreatedAt)}*\n{transactionDescription}", @@ -378,82 +379,6 @@ public async Task NavigateHistory(string userId, string pageStr, string requestT await DisplayTransactionHistory(userId: userId == "all" ? null : userId, page: page, targetUser: targetUser, isInitialCall: false); } - private (string emoji, string title, string description) FormatTransactionDisplay(TokenTransaction transaction, bool showUserInfo = false) - { - var (emoji, title, description) = transaction.Kind switch - { - TransactionKind.TokenInitialisation => ("🎯", "Account Created", ""), - TransactionKind.DailyReward => ("📅", "Daily Reward", ""), - TransactionKind.Gift => GetGiftDisplay(transaction), - TransactionKind.Game => GetGameDisplay(transaction), - TransactionKind.Admin => GetAdminDisplay(transaction), - _ => ("❓", transaction.TransactionType, "") - }; - - // If showing user info (for all-users view), prepend user name to title - if (showUserInfo) - { - var user = Context.Guild.GetUser(ulong.Parse(transaction.UserID)); - var username = user?.DisplayName ?? "Unknown User"; - return (emoji, $"{username}: {title}", description); - } - - return (emoji, title, description); - } - - private (string emoji, string title, string description) GetGiftDisplay(TokenTransaction transaction) - { - SocketGuildUser? user = null; - var userId = transaction.Details?.GetValueOrDefault(transaction.Amount >= 0 ? "from" : "to"); - if (userId != null) user = Context.Guild.GetUser(ulong.Parse(userId)); - - string title = transaction.Amount > 0 ? "Gift Received" : "Gift Sent"; - if (user != null) title = transaction.Amount > 0 ? $"Gift from {user.DisplayName}" : $"Gift to {user.DisplayName}"; - - return ("🎁", title, ""); - } - - private (string emoji, string title, string description) GetGameDisplay(TokenTransaction transaction) - { - var gameName = transaction.Details?.GetValueOrDefault("game"); - - string emoji = transaction.Amount >= 0 ? "📈" : "📉"; - string title = transaction.Amount >= 0 ? "Won" : "Lost"; - if (gameName != null) title += $" {CapitalizeFirst(gameName)}"; - - return (emoji, title, ""); - } - - private (string emoji, string title, string description) GetAdminDisplay(TokenTransaction transaction) - { - var adminId = transaction.Details?.GetValueOrDefault("admin"); - var action = transaction.Details?.GetValueOrDefault("action"); - SocketGuildUser? admin = null; - if (adminId != null) admin = Context.Guild.GetUser(ulong.Parse(adminId)); - - string title = action switch - { - "add" => "Tokens Added", - "set" => "Tokens Set", - _ => $"UNKNOWN ACTION: {action}" - }; - string description = action switch - { - "set" => "This overrides past transactions", - _ => "" - }; - - if (admin != null) title += $" by Admin {admin.DisplayName}"; - - return ("⚙️", title, description); - } - - private string CapitalizeFirst(string input) - { - if (string.IsNullOrEmpty(input)) - return input; - return char.ToUpper(input[0]) + input.Substring(1).ToLower(); - } #region Admin Commands diff --git a/DiscordBot/Modules/Fun/DuelSlashModule.cs b/DiscordBot/Modules/Fun/DuelSlashModule.cs new file mode 100644 index 00000000..b6c84516 --- /dev/null +++ b/DiscordBot/Modules/Fun/DuelSlashModule.cs @@ -0,0 +1,242 @@ +using Discord.Interactions; +using DiscordBot.Services; + +namespace DiscordBot.Modules.Fun; + +public class DuelSlashModule : InteractionModuleBase +{ + public DuelService DuelService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; + + [SlashCommand("duel", "Challenge another user to a duel!")] + public async Task Duel( + [Summary(description: "The user you want to duel")] IUser opponent, + [Summary(description: "Type of duel")] + [Choice("Normal", "normal")] + [Choice("Mute", "mute")] + string type = "normal") + { + if (opponent.Id == Context.User.Id) + { + await Context.Interaction.RespondAsync("You cannot duel yourself!", ephemeral: true); + return; + } + + if (opponent.IsBot) + { + await Context.Interaction.RespondAsync("You cannot duel a bot!", ephemeral: true); + return; + } + + string duelKey = $"{Context.User.Id}_{opponent.Id}"; + + if (!DuelService.TryStartDuel(duelKey, Context.User.Id, opponent.Id)) + { + await Context.Interaction.RespondAsync("There's already an active duel between you two!", ephemeral: true); + return; + } + + var embed = new EmbedBuilder() + .WithColor(Color.Orange) + .WithTitle("⚔️ Duel Challenge!") + .WithDescription($"{Context.User.Mention} has challenged {opponent.Mention} to a duel!") + .WithFooter($"This challenge will expire in 60 seconds"); + + if (type == "mute") + { + embed.AddField("Risk", "The loser will be muted for 5 minutes."); + } + + var components = new ComponentBuilder() + .WithButton("⚔️ Accept", $"duel_accept:{duelKey}:{type}", ButtonStyle.Success) + .WithButton("🛡️ Refuse", $"duel_refuse:{duelKey}", ButtonStyle.Danger) + .WithButton("❌ Cancel", $"duel_cancel:{duelKey}", ButtonStyle.Secondary) + .Build(); + + await Context.Interaction.RespondAsync(embed: embed.Build(), components: components); + + var originalResponse = await Context.Interaction.GetOriginalResponseAsync(); + + _ = Task.Run(async () => + { + await Task.Delay(60000); + var duel = DuelService.GetDuel(duelKey); + if (duel != null) + { + DuelService.TryRemoveDuel(duelKey, out _); + + try + { + var challenger = await Context.Guild.GetUserAsync(duel.Value.challengerId); + var challengedUser = await Context.Guild.GetUserAsync(duel.Value.opponentId); + + string timeoutMessage = challengedUser != null + ? $"⏰ Duel challenge to {challengedUser.Mention} expired." + : "⏰ Duel challenge expired."; + + await originalResponse.ModifyAsync(msg => + { + msg.Content = string.Empty; + msg.Embed = new EmbedBuilder() + .WithColor(Color.LightGrey) + .WithDescription(timeoutMessage) + .Build(); + msg.Components = new ComponentBuilder().Build(); + }); + } + catch (Exception ex) + { + await LoggingService.LogChannelAndFile($"Failed to modify duel timeout message: {ex.Message}", ExtendedLogSeverity.Warning); + } + } + }); + } + + [ComponentInteraction("duel_accept:*:*")] + public async Task DuelAccept(string duelKey, string type) + { + var userIds = duelKey.Split('_'); + if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) + { + await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); + return; + } + + if (Context.User.Id != opponentId) + { + await Context.Interaction.RespondAsync("Only the challenged user can accept this duel!", ephemeral: true); + return; + } + + if (!DuelService.TryRemoveDuel(duelKey, out _)) + { + await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); + return; + } + + await Context.Interaction.DeferAsync(); + + var challenger = await Context.Guild.GetUserAsync(challengerId); + var opponent = await Context.Guild.GetUserAsync(opponentId); + + if (challenger == null || opponent == null) + { + await Context.Interaction.FollowupAsync("One of the duel participants is no longer available!"); + return; + } + + bool challengerWins = DuelService.ChallengerWins(); + var winner = challengerWins ? challenger : opponent; + var loser = challengerWins ? opponent : challenger; + if (type == "mute") + { + var isChallengerAdmin = challenger.GuildPermissions.Has(GuildPermission.Administrator); + var isOpponentAdmin = opponent.GuildPermissions.Has(GuildPermission.Administrator); + if (isChallengerAdmin || isOpponentAdmin) + { + type = "friendly"; + } + } + + string flavorMessage = DuelService.GetWinMessage(winner.Mention, loser.Mention); + + var resultEmbed = new EmbedBuilder() + .WithColor(Color.Gold) + .WithTitle("⚔️ Duel Results!") + .WithDescription(flavorMessage) + .AddField("Winner", winner.Mention, inline: true) + .Build(); + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Embed = resultEmbed; + msg.Components = new ComponentBuilder().Build(); + }); + + if (type == "mute") + { + try + { + var guildLoser = loser as IGuildUser; + if (guildLoser != null) + { + await guildLoser.SetTimeOutAsync(TimeSpan.FromMinutes(5), new RequestOptions { AuditLogReason = "Lost /duel" }); + await Context.Interaction.FollowupAsync($"💀 {loser.Mention} has been timed out for 5 minutes as the duel loser!", ephemeral: false); + } + } + catch (Exception ex) + { + await LoggingService.LogChannelAndFile($"Failed to timeout the loser of the duel: {ex.Message}", ExtendedLogSeverity.Error); + await Context.Interaction.FollowupAsync("Failed to timeout the loser.", ephemeral: false); + } + } + } + + [ComponentInteraction("duel_refuse:*")] + public async Task DuelRefuse(string duelKey) + { + var userIds = duelKey.Split('_'); + if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) + { + await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); + return; + } + + if (Context.User.Id != opponentId) + { + await Context.Interaction.RespondAsync("Only the challenged user can refuse this duel!", ephemeral: true); + return; + } + + if (!DuelService.TryRemoveDuel(duelKey, out _)) + { + await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); + return; + } + + await Context.Interaction.DeferAsync(); + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = string.Empty; + msg.Embed = new EmbedBuilder() + .WithColor(Color.LightGrey) + .WithDescription("🛡️ Duel challenge was refused.") + .Build(); + msg.Components = new ComponentBuilder().Build(); + }); + } + + [ComponentInteraction("duel_cancel:*")] + public async Task DuelCancel(string duelKey) + { + var userIds = duelKey.Split('_'); + if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) + { + await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); + return; + } + + if (Context.User.Id != challengerId) + { + await Context.Interaction.RespondAsync("Only the challenger can cancel this duel!", ephemeral: true); + return; + } + + if (!DuelService.TryRemoveDuel(duelKey, out _)) + { + await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); + return; + } + + await Context.Interaction.DeferAsync(); + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = string.Empty; + msg.Embed = new EmbedBuilder() + .WithColor(Color.LightGrey) + .WithDescription("❌ Duel challenge was cancelled by the challenger.") + .Build(); + msg.Components = new ComponentBuilder().Build(); + }); + } +} diff --git a/DiscordBot/Modules/Fun/FunModule.cs b/DiscordBot/Modules/Fun/FunModule.cs new file mode 100644 index 00000000..4533675f --- /dev/null +++ b/DiscordBot/Modules/Fun/FunModule.cs @@ -0,0 +1,129 @@ +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 FuzzTable _slapObjects = new(); + private 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 + { + if (_slapObjects.Count == 0) + _slapObjects.Load(Settings.FunCommands.SlapObjectsTable!); + } + catch (Exception) + { + await LoggingService.LogChannelAndFile($"Error while loading '{Settings.FunCommands.SlapObjectsTable}'.", + ExtendedLogSeverity.LowWarning); + return; + } + 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 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; + } + + 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("around a bit with a large "); + 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("around a bit with a large "); + 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)!; + } + + [Command("Roll"), Priority(23)] + [Summary("Roll a dice. Syntax: !roll [sides]")] + public async Task RollDice(int sides = 20) + { + await RollDice(sides, 0); + } + + [Command("Roll"), Priority(23)] + [Summary("Roll a dice. Syntax: !roll [sides] [minimum]")] + public async Task RollDice(int sides, int number) + { + if (sides < 1 || sides > 1000) + { + await ReplyAsync("Invalid number of sides. Please choose a number between 1 and 1000.").DeleteAfterSeconds(seconds: 10)!; + await Context.Message.DeleteAsync(); + return; + } + + var uname = Context.User.GetUserPreferredName(); + var roll = _random.Next(1, sides + 1); + var message = $"**{uname}** rolled a D{sides} and got **{roll}**!"; + if (number < 1) + message = " :game_die: " + message; + else if (roll >= number) + message = " :white_check_mark: " + message + " [Needed: " + number + "]"; + else + message = " :x: " + message + " [Needed: " + number + "]"; + + await ReplyAsync(message); + await Context.Message.DeleteAfterSeconds(seconds: 1)!; + } + + [Command("D20"), Priority(23)] + [Summary("Roll a D20 dice. Syntax: !d20 [minimum]")] + public async Task RollD20(int number = 0) + { + await RollDice(20, number); + } +} diff --git a/DiscordBot/Modules/ModerationModule.cs b/DiscordBot/Modules/ModerationModule.cs deleted file mode 100644 index 69c17141..00000000 --- a/DiscordBot/Modules/ModerationModule.cs +++ /dev/null @@ -1,514 +0,0 @@ -using System.IO; -using System.Text; -using Discord.Commands; -using Discord.WebSocket; -using DiscordBot.Services; -using DiscordBot.Settings; -using Pathoschild.NaturalTimeParser.Parser; -using DiscordBot.Attributes; -using DiscordBot.Utils; - -namespace DiscordBot.Modules; - -public class ModerationModule : ModuleBase -{ - #region Dependency Injection - - public CommandHandlingService CommandHandlingService { get; set; } - public DatabaseService DatabaseService { get; set; } - public ILoggingService LoggingService { get; set; } - public Rules Rules { get; set; } - public BotSettings Settings { get; set; } - public UserService UserService { get; set; } - public ModerationService ModerationService { get; set; } - - #endregion - - private async Task IsModerationEnabled() - { - if (Settings.ModeratorCommandsEnabled) return true; - if (await Context.Guild.GetChannelAsync(Settings.BotAnnouncementChannel.Id) is IMessageChannel botAnnouncementChannel) - { - var sentMessage = await botAnnouncementChannel.SendMessageAsync($"{Context.User.Mention} some moderation commands are disabled, try using Wick."); - await Context.Message.DeleteAsync(); - await sentMessage.DeleteAfterSeconds(seconds: 60); - } - return false; - } - - [Command("Mute")] - [Summary("Mute a user for a fixed duration.")] - [Alias("shutup", "stfu")] - [RequireModerator] - public async Task MuteUser(IUser user, uint arg) - { - if (!await IsModerationEnabled()) return; - - await Context.Message.DeleteAsync(); - - var u = user as IGuildUser; - if (u != null && u.RoleIds.Contains(Settings.MutedRoleId)) return; - - await u.AddRoleAsync(Context.Guild.GetRole(Settings.MutedRoleId)); - - var reply = await ReplyAsync($"User {user} has been muted for {Utils.Utils.FormatTime(arg)} ({arg} seconds)."); - await LoggingService.LogChannelAndFile( - $"{Context.User.Username} has muted {u.Username} ({u.Id}) for {Utils.Utils.FormatTime(arg)} ({arg} seconds)."); - - UserService.MutedUsers.AddCooldown(u.Id, (int)arg, ignoreExisting: true); - - await UserService.MutedUsers.AwaitCooldown(u.Id); - await reply.DeleteAsync(); - await UnmuteUser(user, true); - } - - [Command("Mute")] - [Summary("Mute a user for a fixed duration.")] - [Alias("shutup", "stfu")] - [RequireModerator] - public async Task MuteUser(IUser user, string duration, params string[] messages) - { - if (!await IsModerationEnabled()) return; - try - { - var dt = DateTime.Now.Offset(duration); - if (dt < DateTime.Now) - { - await ReplyAsync("Invalid DateTime specified."); - return; - } - - await MuteUser(user, (uint)Math.Round((dt - DateTime.Now).TotalSeconds), messages); - } - catch (Exception) - { - await ReplyAsync("Invalid DateTime specified."); - await Context.Message.DeleteAsync(); - } - } - - [Command("Mute")] - [Summary("Mute a user for a fixed duration.")] - [Alias("shutup", "stfu")] - [RequireModerator] - public async Task MuteUser(IUser user, uint seconds, params string[] messages) - { - if (!await IsModerationEnabled()) return; - var message = string.Join(' ', messages); - - await Context.Message.DeleteAsync(); - - var u = user as IGuildUser; - if (u != null && u.RoleIds.Contains(Settings.MutedRoleId)) return; - - await u.AddRoleAsync(Context.Guild.GetRole(Settings.MutedRoleId)); - - var reply = - await ReplyAsync($"User {user} has been muted for {Utils.Utils.FormatTime(seconds)} ({seconds} seconds). Reason : {message}"); - await LoggingService.LogChannelAndFile( - $"{Context.User.Username} has muted {u.Username} ({u.Id}) for {Utils.Utils.FormatTime(seconds)} ({seconds} seconds). Reason : {message}"); - - var dm = await user.CreateDMChannelAsync(new RequestOptions()); - if (!await dm.TrySendMessage( - $"You have been muted from UDC for **{Utils.Utils.FormatTime(seconds)}** for the following reason : **{message}**. " + - "This is not appealable and any tentative to avoid it will result in your permanent ban.")) - { - if (await Context.Guild.GetChannelAsync(Settings.BotCommandsChannel.Id) is ISocketMessageChannel botCommandChannel) - await botCommandChannel.SendMessageAsync( - $"I could not DM you {user.Mention}!\nYou have been muted from UDC for **{Utils.Utils.FormatTime(seconds)}** for the following reason : **{message}**. " + - "This is not appealable and any tentative to avoid it will result in your permanent ban."); - await LoggingService.Log(LogBehaviour.Channel, $"User {user.Username} has DM blocked and the mute reason couldn't be sent."); - } - - UserService.MutedUsers.AddCooldown(u.Id, (int)seconds, ignoreExisting: true); - await UserService.MutedUsers.AwaitCooldown(u.Id); - - await UnmuteUser(user, true); - if (reply != null) - await reply.DeleteAsync(); - } - - [Command("Unmute")] - [Summary("Unmute a muted user.")] - [RequireModerator] - public async Task UnmuteUser(IUser user, bool fromMute = false) - { - var u = user as IGuildUser; - - if (!fromMute && u == Context.Message.Author) - { - await ReplyAsync("You can't unmute yourself.").DeleteAfterSeconds(30); - return; - } - - if (!fromMute && Context != null && Context.Message != null) - await Context.Message.DeleteAsync(); - - UserService.MutedUsers.Remove(user.Id); - await u.RemoveRoleAsync(Context.Guild.GetRole(Settings.MutedRoleId)); - var reply = await ReplyAsync("User " + user + " has been unmuted."); - reply?.DeleteAfterSeconds(10d); - } - - [Command("AddRole")] - [Summary("Add a role to a user.")] - [Alias("roleadd")] - [RequireModerator] - public async Task AddRole(IRole role, IUser user) - { - var contextUser = Context.User as SocketGuildUser; - await Context.Message.DeleteAsync(); - - if (Settings.UserAssignableRoles.Roles.Contains(role.Name)) - { - var u = user as IGuildUser; - await u.AddRoleAsync(role); - await ReplyAsync("Role " + role + " has been added to " + user).DeleteAfterTime(minutes: 5); - await LoggingService.LogChannelAndFile($"{contextUser.Username} has added role {role} to {u.Username}"); - return; - } - - await ReplyAsync($"Bot cannot add {role.Name} role. Administrator must do it manually.").DeleteAfterSeconds(25); - } - - [Command("RemoveRole")] - [Summary("Remove a role from a user.")] - [Alias("roleremove")] - [RequireModerator] - public async Task RemoveRole(IRole role, IUser user) - { - var contextUser = Context.User as SocketGuildUser; - await Context.Message.DeleteAsync(); - - if (Settings.UserAssignableRoles.Roles.Contains(role.Name)) - { - var u = user as IGuildUser; - - await u.RemoveRoleAsync(role); - await ReplyAsync("Role " + role + " has been removed from " + user).DeleteAfterTime(minutes: 5); - await LoggingService.LogChannelAndFile($"{contextUser.Username} has removed role {role} from {u.Username}"); - return; - } - - await ReplyAsync($"Bot cannot remove {role.Name} role. Administrator must do it manually.").DeleteAfterSeconds(25); - } - - [Command("Clear")] - [Summary("Removes the last x messages from channel.")] - [Alias("clean", "nuke", "purge")] - [RequireModerator] - public async Task ClearMessages(int count) - { - var channel = Context.Channel as ITextChannel; - - var messages = await channel.GetMessagesAsync(count + 1).FlattenAsync(); - await channel.DeleteMessagesAsync(messages); - - await ReplyAsync("Messages deleted.").DeleteAfterSeconds(seconds: 5); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has removed {count} messages from {Context.Channel.Name}"); - } - - [Command("Clear")] - [Summary("Removes messages until the message at the specified id.")] - [Alias("clean", "nuke", "purge")] - [RequireModerator] - public async Task ClearMessages(ulong messageId) - { - var channel = (ITextChannel)Context.Channel; - - var messages = await channel.GetMessagesAsync(messageId, Direction.After).FlattenAsync(); - var enumerable = messages.ToList(); - await channel.DeleteMessagesAsync(enumerable); - - await ReplyAsync("Messages deleted.").DeleteAfterSeconds(seconds: 5); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has removed {enumerable.Count} messages from {Context.Channel.Name}"); - } - - [Command("Kick")] - [Summary("Kick a user.")] - [RequireUserPermission(GuildPermission.KickMembers)] - internal async Task KickUser(IUser user) - { - if (!await IsModerationEnabled()) return; - - var u = user as IGuildUser; - - await u.KickAsync(); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has kicked {u.Username}"); - } - - [Command("Ban")] - [Summary("Ban an user")] - [RequireUserPermission(GuildPermission.BanMembers)] - public async Task BanUser(IUser user, params string[] reasons) - { - if (!await IsModerationEnabled()) return; - - var reason = string.Join(' ', reasons); - await Context.Guild.AddBanAsync(user, 7, reason, RequestOptions.Default); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has banned {user.Username} with the reason \"{reasons}\""); - } - - [Command("Rules")] - [Summary("Display rules of the current channel.")] - [RequireModerator] - public async Task RulesCommand(int seconds = 60) - { - await RulesCommand(Context.Channel, seconds); - await Context.Message.DeleteAsync(); - } - - [Command("Rules")] - [Summary("Display rules of the mentioned channel.")] - [RequireModerator] - public async Task RulesCommand(IMessageChannel channel, int seconds = 60) - { - //Display rules of this channel for x seconds - var rule = Rules.Channel.First(x => x.Id == 0); - var m = await ReplyAsync( - $"{rule.Header}{(rule.Content.Length > 0 ? rule.Content : "There is no special rule for this channel.\nPlease follow global rules (you can get them by typing `!globalrules`)")}"); - - var deleteAsync = Context.Message?.DeleteAsync(); - if (deleteAsync != null) await deleteAsync; - - if (seconds == -1) - return; - await m.DeleteAfterSeconds(seconds: seconds); - } - - [Command("GlobalRules")] - [Summary("Display global rules in current channel.")] - [RequireModerator] - public async Task GlobalRules(int seconds = 60) - { - //Display rules of this channel for x seconds - var globalRules = Rules.Channel.First(x => x.Id == 0).Content; - var m = await ReplyAsync(globalRules); - await Context.Message.DeleteAsync(); - - if (seconds == -1) - return; - await m.DeleteAfterSeconds(seconds: seconds); - } - - [Command("Channels")] - [Summary("Get a description of the channels.")] - [RequireModerator] - public async Task ChannelsDescription(int seconds = 60) - { - //Display rules of this channel for x seconds - var channelData = Rules.Channel; - var sb = new StringBuilder(); - - foreach (var c in channelData) - sb.Append($"{(await Context.Guild.GetTextChannelAsync(c.Id))?.Mention} - {c.Header}\n"); - var text = sb.ToString(); - IUserMessage m; - IUserMessage m2 = null; - - if (sb.ToString().Length > 2000) - { - m = await ReplyAsync(text.Substring(0, 2000)); - m2 = await ReplyAsync(text.Substring(2000)); - } - else - m = await ReplyAsync(text); - - await Context.Message.DeleteAsync(); - - if (seconds == -1) - return; - await m.DeleteAfterSeconds(seconds: seconds); - var deleteAsync = m2?.DeleteAsync(); - if (deleteAsync != null) await deleteAsync; - } - - [Command("SlowMode")] - [Summary("Turn on slowmode.")] - [RequireModerator] - public async Task SlowMode(int time) - { - await Context.Message.DeleteAsync(); - await (Context.Channel as ITextChannel).ModifyAsync(p => p.SlowModeInterval = time); - await ReplyAsync($"Slowmode has been set to {time}s !").DeleteAfterSeconds(10); - } - - [Command("TagRole")] - [Summary("Tag a role and post a message.")] - [Alias("mentionrole", "pingrole", "rolemention", "roletag", "roleping")] - [RequireAdmin] - public async Task TagRole(IRole role, params string[] messages) - { - var message = string.Join(' ', messages); - var isMentionable = role.IsMentionable; - if (!isMentionable) await role.ModifyAsync(properties => { properties.Mentionable = true; }); - await role.ModifyAsync(properties => { properties.Mentionable = true; }); - await Context.Channel.SendMessageAsync($"{role.Mention}\n{message}"); - if (!isMentionable) await role.ModifyAsync(properties => { properties.Mentionable = false; }); - await Context.Message.DeleteAsync(); - } - - [Command("React")] - [Alias("reaction", "reactions", "addreactions", "addreaction")] - [Summary("Adds the requested reactions to a message.")] - [RequireAdmin] - public async Task React(ulong msgId, params string[] emojis) - { - var msg = (IUserMessage)await Context.Channel.GetMessageAsync(msgId); - await Context.Message.DeleteAsync(); - foreach (var emoji in emojis) - if (Emote.TryParse(emoji, out var emote)) - await msg.AddReactionAsync(emote); - else - await msg.AddReactionAsync(new Emoji(emoji)); - } - - [Command("React")] - [Alias("reaction", "reactions", "addreactions", "addreaction")] - [Summary("Adds the requested reactions to a message.")] - [RequireAdmin] - public async Task React(params string[] emojis) - { - var msg = (IUserMessage)(await Context.Channel.GetMessagesAsync(2).FlattenAsync()).Last(); - - await Context.Message.DeleteAsync(); - foreach (var emoji in emojis) - if (Emote.TryParse(emoji, out var emote)) - await msg.AddReactionAsync(emote); - else - await msg.AddReactionAsync(new Emoji(emoji)); - } - - [Command("ClosePoll")] - [Summary("Close a poll and append a message.")] - [Alias("pollclose")] - [RequireAdmin] - public async Task ClosePoll(IMessageChannel channel, ulong messageId, params string[] additionalNotes) - { - var additionalNote = string.Join(' ', additionalNotes); - var message = (IUserMessage)await channel.GetMessageAsync(messageId); - var reactions = message.Reactions; - - var reactionCount = string.Empty; - foreach (var reaction in reactions) - reactionCount += $" {reaction.Key.Name} ({reaction.Value.ReactionCount})"; - - await message.ModifyAsync(properties => - { - properties.Content = message.Content + - $"\n\nThe poll has been closed. Here's the vote results :{reactionCount}\nAdditional notes : {additionalNote}"; - }); - } - - [Command("CommandHistory")] - [Summary("Get a text file of the command history for bot.")] - [RequireModerator] - public async Task CommandHistory(int count = 20) - { - await Context.Message.DeleteAsync(); - await LoggingService.LogChannelAndFile("Command history requested by " + Context.User.Username, - ExtendedLogSeverity.Info); - - var response = await ModerationService.GetBotCommandHistory(count); - using var stream = new MemoryStream(); - using (var writer = new StreamWriter(stream)) - { - await writer.WriteAsync(response); - await writer.FlushAsync(); - stream.Position = 0; - - // Send the MemoryStream as a file - await Context.User.SendFileAsync(stream, "CommandHistory.txt"); - } - } - - [Command("DBSync")] - [Summary("Force add a user to the database.")] - [RequireAdmin] - public async Task DbSync(IUser user) - { - await DatabaseService.GetOrAddUser((SocketGuildUser)user); - } - - [Command("DBFullSync")] - [Summary("Inserts all missing users, and updates any tracked data.")] - [RequireAdmin] - public async Task FullSync() - { - await Context.Message.DeleteAsync(); - var tracker = await ReplyAsync("Updating user data: "); - await DatabaseService.FullDbSync(Context.Guild, tracker); - } - - #region General Utility Commands - - [Command("WelcomeMessageCount")] - [Summary("Returns a count of pending welcome messages.")] - [RequireModerator, HideFromHelp] - // Simple method to check if there are many welcome messages waiting, and when the next one is due. - public async Task WelcomeMessageCount() - { - var count = UserService.WaitingWelcomeMessagesCount; - // If there are more than 0 messages waiting, show when nearest one is - if (count > 0) - { - var next = UserService.NextWelcomeMessage.ToUnixTimestamp(); - await ReplyAsync($"There are {count} pending welcome messages. The next one is in ").DeleteAfterSeconds(seconds: 10); - } - else - { - await ReplyAsync("There are no pending welcome messages.").DeleteAfterSeconds(seconds: 10); - } - await Context.Message.DeleteAsync(); - } - - // Command to show the tags available for a specific channel, so the command needs to be run in a channel with tags or specific a channel id to check - [Command("ChannelTags")] - [Summary("Returns a list of tags for the current channel.")] - [RequireModerator, HideFromHelp] - public async Task ChannelTags(ulong channelId) - { - // Get the channel - var channel = await Context.Guild.GetChannelAsync(channelId); - - if (channel is not IForumChannel forumChannel) - { - await ReplyAsync($"<#{channelId}> is not a forum channel and has no tags.").DeleteAfterSeconds(seconds: 10); - return; - } - - var tags = forumChannel.Tags; - // If there are no tags, say so - if (tags.Count == 0) - { - await ReplyAsync($"<#{channelId}> has no tags.").DeleteAfterSeconds(seconds: 10); - return; - } - - // If there are tags, list them in an embed in format of (ID: `id` - Name: `name`) - var embed = new EmbedBuilder() - .WithTitle($"Tags for <#{channelId}>") - .WithDescription(string.Join("\n", tags.Select(tag => $"ID: `{tag.Id}` - Name: `{tag.Name}`")) + - $"\n\n{StringUtil.MessageSelfDestructIn(60)}") - .WithColor(Color.Blue) - .Build(); - - await Context.Message.DeleteAsync(); - await ReplyAsync(embed: embed).DeleteAfterSeconds(seconds: 60); - } - - #endregion - - #region CommandList - [RequireModerator] - [Summary("Does what you see now.")] - [Command("Mod Help")] - public async Task ModerationHelp() - { - foreach (var message in CommandHandlingService.GetCommandListMessages("ModerationModule", true, true, false)) - { - await ReplyAsync(message); - } - } - #endregion -} diff --git a/DiscordBot/Modules/Profiles/BirthdayModule.cs b/DiscordBot/Modules/Profiles/BirthdayModule.cs new file mode 100644 index 00000000..943b63a6 --- /dev/null +++ b/DiscordBot/Modules/Profiles/BirthdayModule.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Utils; + +namespace DiscordBot.Modules.Profiles; + +[Group("UserModule"), Alias("")] +public class BirthdayModule : ModuleBase +{ + public IWebClient WebClient { get; set; } = null!; + + [Command("Birthday"), HideFromHelp] + [Summary("Display next member birthday.")] + [Alias("bday")] + public async Task Birthday() + { + const string nextBirthday = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; + + var tableText = await WebClient.GetHtmlNodeInnerText(nextBirthday, "/html/body/table/tr[2]/td"); + var message = $"**{tableText}**"; + + await (ReplyAsync(message).DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); + await (Context.Message.DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); + } + + [Command("Birthday"), Priority(27)] + [Summary("Display birthday of mentioned user. Syntax : !birthday @user")] + [Alias("bday")] + public async Task Birthday(IUser user) + { + var searchName = user.Username; + const string birthdayTable = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; + var relevantNodes = await WebClient.GetHtmlNodes(birthdayTable, "/html/body/table/tr"); + + var birthdate = default(DateTime); + + HtmlAgilityPack.HtmlNode? matchedNode = null; + var matchedLength = int.MaxValue; + + if (relevantNodes != null) + { + foreach (var row in relevantNodes) + { + var nameNode = row.SelectSingleNode("td[2]"); + if (nameNode == null) continue; + var name = nameNode.InnerText; + + if (!name.ToLower().Contains(searchName.ToLower()) || name.Length >= matchedLength) + continue; + + matchedNode = row; + matchedLength = name.Length; + if (name.Length == searchName.Length) break; + } + } + + if (matchedNode != null) + { + var dateNode = matchedNode.SelectSingleNode("td[1]"); + var yearNode = matchedNode.SelectSingleNode("td[3]"); + + if (dateNode != null && yearNode != null) + { + var provider = CultureInfo.InvariantCulture; + var wrongFormat = "M/d/yyyy"; + + var dateString = dateNode.InnerText; + if (!yearNode.InnerText.Contains(" ")) dateString = dateString + "/" + yearNode.InnerText; + + dateString = dateString.Trim(); + + try + { + birthdate = DateTime.ParseExact(dateString, wrongFormat, provider); + } + catch (FormatException) + { + birthdate = DateTime.ParseExact(dateString, "M/d", provider); + } + } + } + + if (birthdate == default) + { + await (ReplyAsync( + $"Sorry, I couldn't find **{searchName}**'s birthday date. They can add it at https://docs.google.com/forms/d/e/1FAIpQLSfUglZtJ3pyMwhRk5jApYpvqT3EtKmLBXijCXYNwHY-v-lKxQ/viewform !") + .DeleteAfterSeconds(30) ?? Task.CompletedTask); + } + else + { + var date = birthdate.ToUnixTimestamp(); + var message = + $"**{searchName}**'s birthdate: __**{birthdate.ToString("dd MMMM yyyy", CultureInfo.InvariantCulture)}**__ " + + $"({(int)((DateTime.Now - birthdate).TotalDays / 365)}yo)"; + + await (ReplyAsync(message).DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); + } + + await (Context.Message.DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); + } +} diff --git a/DiscordBot/Modules/Profiles/ProfileModule.cs b/DiscordBot/Modules/Profiles/ProfileModule.cs new file mode 100644 index 00000000..de984466 --- /dev/null +++ b/DiscordBot/Modules/Profiles/ProfileModule.cs @@ -0,0 +1,62 @@ +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 userId = Context.User.Id; + 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/RankModule.cs b/DiscordBot/Modules/Profiles/RankModule.cs new file mode 100644 index 00000000..801dd7c4 --- /dev/null +++ b/DiscordBot/Modules/Profiles/RankModule.cs @@ -0,0 +1,121 @@ +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 tearly 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/Server/EmbedModule.cs b/DiscordBot/Modules/Server/EmbedModule.cs new file mode 100644 index 00000000..5192ed09 --- /dev/null +++ b/DiscordBot/Modules/Server/EmbedModule.cs @@ -0,0 +1,140 @@ +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Services; + +// ReSharper disable all UnusedMember.Local +namespace DiscordBot.Modules.Server; + +[RequireAdmin] +public class EmbedModule : ModuleBase +{ + public EmbedParsingService EmbedParsingService { get; set; } = null!; + + /// + /// Generate an embed + /// + [RequireAdmin] + [Command("embed"), Summary("Generate an embed.")] + public async Task EmbedCommand(IMessageChannel? channel = null, ulong messageId = 0) + { + await Context.Message.DeleteAsync(); + channel ??= Context.Channel; + + if (Context.Message.Attachments.Count < 1) + { + await ReplyAsync($"{Context.User.Mention}, you must provide a JSON file or a JSON url.").DeleteAfterSeconds(5)!; + return; + } + var attachment = Context.Message.Attachments.ElementAt(0); + var embed = await EmbedParsingService.BuildEmbedFromUrl(attachment.Url); + + await SendEmbedToChannel(embed, channel, messageId); + } + + [Command("embed"), Summary("Generate an embed from an URL (hastebin).")] + public async Task EmbedCommand(string url, IMessageChannel? channel = null, ulong messageId = 0) + { + await Context.Message.DeleteAsync(); + channel ??= Context.Channel; + Discord.Embed? builtEmbed = await TryGetEmbedFromUrl(url); + if (builtEmbed != null) + await SendEmbedToChannel(builtEmbed, channel, messageId); + } + + private async Task TryGetEmbedFromUrl(string url) + { + bool result = Uri.TryCreate(url, UriKind.Absolute, out var uriResult) + && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); + if (!result) + { + await ReplyAsync($"{Context.User.Mention}, the parameter is not a valid URL.").DeleteAfterSeconds(5)!; + return null; + } + if (!EmbedParsingService.IsValidHost(uriResult!.Host)) + { + await ReplyAsync($"{Context.User.Mention}, supported URLs: [https://hastebin.com, https://pastebin.com, https://gdl.space, https://hastepaste.com, http://pastie.org].").DeleteAfterSeconds(5)!; + return null; + } + string downloadUrl = EmbedParsingService.GetDownloadUrl(uriResult); + var builtEmbed = await EmbedParsingService.BuildEmbedFromUrl(downloadUrl); + if (builtEmbed.Length == 0) + { + await ReplyAsync("Failed to generate embed from url.").DeleteAfterSeconds(seconds: 10f)!; + return null; + } + return builtEmbed; + } + + private readonly IEmote _thumbUpEmote = new Emoji("👍"); + + private async Task SendEmbedToChannel(Discord.Embed embed, IMessageChannel channel, ulong messageId = 0) + { + if (embed == null || embed.Length <= 0) + { + await ReplyAsync("Embed is improperly formatted or corrupt."); + return; + } + + // If context.channel is same as channel we don't need to confirm details + if (Context.Channel != channel) + { + // Confirm with user it is correct + var tempEmbed = await ReplyAsync(embed: embed); + var message = await ReplyAsync("If correct, react to this message within 20 seconds to continue."); + await message.AddReactionAsync(_thumbUpEmote); + // 20 seconds wait? + bool confirmedEmbed = false; + for (int i = 0; i < 10; i++) + { + await Task.Delay(2000); + var reactions = await message.GetReactionUsersAsync(_thumbUpEmote, 10).FlattenAsync(); + if (reactions.Count() > 1) + { + foreach (var reaction in reactions) + { + if (reaction.Id == Context.User.Id) + { + confirmedEmbed = true; + break; + } + } + } + + if (confirmedEmbed) break; + } + + await tempEmbed.DeleteAsync(); + await message.DeleteAsync(); + // If no reaction, we assume it was bad and abort + if (!confirmedEmbed) + { + await ReplyAsync("Reaction not detected, embed aborted.").DeleteAfterSeconds(seconds: 5)!; + return; + } + } + + if (messageId != 0) + { + var messageToEdit = await channel.GetMessageAsync(messageId) as IUserMessage; + if (messageToEdit == null) + { + await ReplyAsync($"Bot doesn't own the message ID ``{messageId}`` used").DeleteAfterSeconds(5)!; + return; + } + + // Modify the old message, we clear any text it might have had. + await messageToEdit.ModifyAsync(x => + { + x.Content = ""; + x.Embed = embed; + }); + await ReplyAsync("Message replaced!").DeleteAfterSeconds(5)!; + } + else + { + await channel.SendMessageAsync(embed: embed); + if (Context.Channel != channel) + await ReplyAsync("Embed Posted!").DeleteAfterSeconds(5)!; + } + } +} \ No newline at end of file diff --git a/DiscordBot/Modules/Server/QuoteModule.cs b/DiscordBot/Modules/Server/QuoteModule.cs new file mode 100644 index 00000000..1a0a996d --- /dev/null +++ b/DiscordBot/Modules/Server/QuoteModule.cs @@ -0,0 +1,83 @@ +using System.Text.RegularExpressions; +using Discord.Commands; +using DiscordBot.Attributes; + +namespace DiscordBot.Modules.Server; + +[Group("UserModule"), Alias("")] +public class QuoteModule : ModuleBase +{ + [Command("Quote"), HideFromHelp] + public async Task QuoteMessageCommand(IMessageChannel channel, ulong messageId) + { + await QuoteMessage(messageId: messageId, channel: channel); + } + + [Command("Quote"), Priority(10)] + [Summary("Quote a message. Syntax : !quote messageid (#channel)")] + public async Task QuoteMessageCommand(ulong messageId, ulong channel) + { + IMessageChannel targetChannel = (IMessageChannel)await Context.Client.GetChannelAsync(channel) ?? (IMessageChannel)await Context.Client.GetChannelAsync(messageId); + if (targetChannel == null) + { + await (ReplyAsync("Channel or MessageID does not exist").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); + return; + } + + if (targetChannel.Id == channel) + await QuoteMessage(messageId, targetChannel); + else + await QuoteMessage(channel, targetChannel); + } + + [Command("Quote"), HideFromHelp] + [Summary("Quote a message. Syntax : !quote messageid (#channel)")] + public async Task QuoteMessage(ulong messageId, IMessageChannel? channel = null) + { + channel ??= Context.Channel; + var message = await channel.GetMessageAsync(messageId); + if (message == null) + { + await (Context.Message?.DeleteAfterSeconds(seconds: 1) ?? Task.CompletedTask); + await (ReplyAsync("No message with that id found.").DeleteAfterSeconds(seconds: 4) ?? Task.CompletedTask); + return; + } + if (message.Author.IsBot) + { + await (Context.Message?.DeleteAfterSeconds(seconds: 2) ?? Task.CompletedTask); + return; + } + + var messageLink = "https://discordapp.com/channels/" + Context.Guild.Id + "/" + channel.Id + "/" + messageId; + + var msgContent = message.Content; + + if (msgContent != null) + { + msgContent = msgContent.Truncate(1020); + + var regex = new Regex(@"\[([^\[\]\(\)]*)\]\((.*?)\)"); + var matches = regex.Matches(msgContent); + + foreach (var match in matches as IEnumerable) + { + msgContent = msgContent.Replace(match.Value, $"\\{match.Value}"); + } + } + + var msgAttachment = string.Empty; + if (message.Attachments?.Count > 0) msgAttachment = "\t📸"; + var builder = new EmbedBuilder() + .WithColor(new Color(200, 128, 128)) + .WithTimestamp(message.Timestamp) + .FooterQuoteBy(Context.User, message.Channel) + .AddAuthor(message.Author); + if (msgContent == string.Empty && msgAttachment != string.Empty) msgContent = "📸"; + + msgContent += $"\n\n***[Linkback]({messageLink})***"; + builder.Description = msgContent; + + await ReplyAsync(embed: builder.Build()); + await (Context.Message?.DeleteAfterSeconds(1.0) ?? Task.CompletedTask); + } +} diff --git a/DiscordBot/Modules/ReminderModule.cs b/DiscordBot/Modules/Server/ReminderModule.cs similarity index 70% rename from DiscordBot/Modules/ReminderModule.cs rename to DiscordBot/Modules/Server/ReminderModule.cs index dba0abda..ac204de5 100644 --- a/DiscordBot/Modules/ReminderModule.cs +++ b/DiscordBot/Modules/Server/ReminderModule.cs @@ -5,15 +5,15 @@ using DiscordBot.Attributes; using System.Text.RegularExpressions; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Server; [Group("UserModule"), Alias("")] public class ReminderModule : ModuleBase { #region Dependency Injection - public ReminderService ReminderService { get; set; } - public BotSettings Settings { get; set; } + public ReminderService ReminderService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; #endregion @@ -25,16 +25,15 @@ public async Task RemindMe(string time, [Remainder] string message) { if (Context.Message.MentionedEveryone || Context.Message.MentionedRoleIds.Count > 0) { - await ReplyAsync("You can't mention groups or roles in a reminder.").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync("You can't mention groups or roles in a reminder.").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); return; } if (Context.Message.MentionedUserIds.Count > 0) { // IUserMessage does not guarantee .MentionedUsers so go through the class instead if possible - if (Context.Message is SocketMessage) + if (Context.Message is SocketMessage sm) { - var sm = (Context.Message as SocketMessage); // convert <@123> to **Joe** foreach (var user in sm.MentionedUsers) message = Regex.Replace(message, $"[<][@]{user.Id}[>]", user.GetUserPreferredName().ToBold()); @@ -43,18 +42,18 @@ public async Task RemindMe(string time, [Remainder] string message) } else { - await ReplyAsync($"You can't mention users in a reminder.\n`{message}`").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync($"You can't mention users in a reminder.\n`{message}`").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); return; } } - var reminderDate = Utils.Utils.ParseTimeFromString(time); + var reminderDate = global::DiscordBot.Utils.Utils.ParseTimeFromString(time); if (reminderDate < DateTime.Now) { // There isn't really a way to add negative time - await ReplyAsync( + await (ReplyAsync( "Invalid format for reminder.\\nCorrect Syntax: ``!remindme <1hour5m> ``") - .DeleteAfterSeconds(seconds: 10); + .DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); return; } @@ -71,8 +70,8 @@ await ReplyAsync( // Check if user has to many reminders and tell them to delete some if so if (ReminderService.UserHasTooManyReminders(Context.User.Id)) { - await ReplyAsync("You have too many reminders! Please delete some before adding more.") - .DeleteAfterSeconds(seconds: 10); + await (ReplyAsync("You have too many reminders! Please delete some before adding more.") + .DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); return; } @@ -94,9 +93,10 @@ await ReplyAsync("You have too many reminders! Please delete some before adding ReminderService.AddReminder(reminder); await Context.Message.AddReactionAsync(ReminderService.BotResponseEmoji); - await ReplyAsync( - $"Reminder set for {Utils.Utils.FormatTime((uint)(reminderDate - DateTime.Now).TotalSeconds)}") - .DeleteAfterSeconds(seconds: 10); + var formattedTime = global::DiscordBot.Utils.Utils.FormatTime((uint)(reminderDate - DateTime.Now).TotalSeconds); + await (ReplyAsync( + $"Reminder set for {formattedTime}") + .DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); } [Command("remindme"), HideFromHelp] @@ -138,7 +138,7 @@ public async Task Reminders(IUser user) var reminders = ReminderService.GetUserReminders(user.Id); if (reminders.Count == 0) { - await ReplyAsync($"{user.Username} has no reminders!").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync($"{user.Username} has no reminders!").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); return; } @@ -148,15 +148,16 @@ public async Task Reminders(IUser user) int index = 1; foreach (var reminder in reminders) { - var msgLink = Utils.Utils.MessageLinkBack(Context.Guild.Id, reminder.ChannelId, reminder.MessageId); + var msgLink = global::DiscordBot.Utils.Utils.MessageLinkBack(Context.Guild.Id, reminder.ChannelId, reminder.MessageId); + var timeLeft = global::DiscordBot.Utils.Utils.FormatTime((uint)(reminder.When - DateTime.Now).TotalSeconds); embed.AddField( - $"#{index++} | {Utils.Utils.FormatTime((uint)(reminder.When - DateTime.Now).TotalSeconds)}", + $"#{index++} | {timeLeft}", $"[Link]({msgLink}) \"{reminder.Message}\""); } - if (await Context.Guild.GetChannelAsync(Settings.BotCommandsChannel.Id)is IMessageChannel botCommands) - await botCommands + if (await Context.Guild.GetChannelAsync(Settings.Channels.BotCommands.Id) is IMessageChannel botCommands) + await (botCommands .SendMessageAsync(Context.User.Mention, false, embed.Build()) - .DeleteAfterSeconds(seconds: 30); + .DeleteAfterSeconds(seconds: 30) ?? Task.CompletedTask); } // Removes a users reminders for them @@ -165,7 +166,7 @@ await botCommands [Summary("Clears user reminders.")] public async Task RemoveReminders(IUser user, int index = 0) { - await Context.Message.DeleteAfterSeconds(seconds: 1); + await (Context.Message?.DeleteAfterSeconds(seconds: 1) ?? Task.CompletedTask); int removedReminders = ReminderService.RemoveReminders(user, index); if (removedReminders == 0) return; @@ -175,7 +176,7 @@ public async Task RemoveReminders(IUser user, int index = 0) return; } - await ReplyAsync($"{removedReminders.ToString()} Reminders removed.").DeleteAfterSeconds(seconds: 2); + await (ReplyAsync($"{removedReminders.ToString()} Reminders removed.").DeleteAfterSeconds(seconds: 2) ?? Task.CompletedTask); } [RequireModerator] @@ -185,15 +186,15 @@ public async Task RebootReminderService() { if (ReminderService.IsRunning) { - await ReplyAsync("Reminder service is still running.").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync("Reminder service is still running.").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); return; } var result = ReminderService.RestartService(); if (result) - await ReplyAsync("Reminder service restarted.").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync("Reminder service restarted.").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); else - await ReplyAsync("Reminder service failed to restart.").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync("Reminder service failed to restart.").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); } #endregion diff --git a/DiscordBot/Modules/Server/RulesModule.cs b/DiscordBot/Modules/Server/RulesModule.cs new file mode 100644 index 00000000..83b1df89 --- /dev/null +++ b/DiscordBot/Modules/Server/RulesModule.cs @@ -0,0 +1,188 @@ +using System.Text; +using Discord.Commands; +using Discord.WebSocket; +using DiscordBot.Services; +using DiscordBot.Settings; +using DiscordBot.Attributes; + +namespace DiscordBot.Modules.Server; + +[Group("UserModule"), Alias("")] +public class RulesModule : ModuleBase +{ + public WelcomeService WelcomeService { get; set; } = null!; + public UpdateService UpdateService { get; set; } = null!; + public Rules Rules { get; set; } = null!; + + [Command("Rules"), Priority(1)] + [Summary("Rules of current channel by DM.")] + public async Task RulesCommand() + { + await RulesCommand(Context.Channel); + await Context.Message.DeleteAsync(); + } + + [Command("Rules"), Priority(99)] + [Summary("Rules of the mentioned channel by DM. !rules #channel")] + [Alias("rule")] + public async Task RulesCommand(IMessageChannel channel) + { + var rule = Rules.Channel.First(x => x.Id == channel.Id); + var dm = await Context.User.CreateDMChannelAsync(); + bool sentMessage = false; + + sentMessage = await dm.TrySendMessage($"{rule.Header}{(rule.Content.Length > 0 ? rule.Content : $"There is no special rule for {channel.Name} channel.\nPlease follow global rules (you can get them by typing `!globalrules`)")}"); + if (!sentMessage) + await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10)!; + } + + [Command("GlobalRules"), Priority(99)] + [Summary("Global Rules by DM.")] + public async Task GlobalRules(int seconds = 60) + { + var globalRules = Rules.Channel.First(x => x.Id == 0).Content; + var dm = await Context.User.CreateDMChannelAsync(); + await Context.Message.DeleteAsync(); + if (!await dm.TrySendMessage(globalRules)) + { + await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10)!; + } + } + + [Command("Welcome"), Priority(1)] + [Summary("Condensed version of the rules and links to quality resources.")] + public async Task ServerWelcome() + { + if (!await WelcomeService.DMFormattedWelcome((Context.User as SocketGuildUser)!)) + { + await ReplyAsync("Could not send welcome, your DMs are disabled.").DeleteAfterSeconds(seconds: 2)!; + } + await Context.Message.DeleteAfterSeconds(seconds: 4)!; + } + + [Command("Channels"), Priority(92)] + [Summary("Description of the channels by DM.")] + public async Task ChannelsDescription() + { + var channelData = Rules.Channel; + var sb = new StringBuilder(); + foreach (var c in channelData) + sb.Append((await Context.Guild.GetTextChannelAsync(c.Id))?.Mention).Append(" - ").Append(c.Header).Append("\n"); + + var dm = await Context.User.CreateDMChannelAsync(); + + var messages = sb.ToString().MessageSplitToSize(); + await Context.Message.DeleteAsync(); + foreach (var message in messages) + { + if (!await dm.TrySendMessage(message)) + { + await ReplyAsync("Could not send channel descriptions, your DMs are disabled.").DeleteAfterSeconds(seconds: 10)!; + break; + } + } + } + + [Command("FAQ")] + [Summary("Searches UDC FAQs. Syntax : !faq \"query\"")] + public async Task SearchFaqs(params string[] queries) + { + var faqDataList = UpdateService.GetFaqData(); + + if (queries.Length == 1 && ParseNumber(queries[0]) > 0) + { + var id = ParseNumber(queries[0]) - 1; + if (id < faqDataList.Count) + await ReplyAsync(embed: GetFaqEmbed(faqDataList[id])); + else + await ReplyAsync("Invalid FAQ ID selected."); + } + else if (queries.Length > 0 && !(queries.Length == 1 && queries[0].Equals("list"))) + { + var minimumScore = double.MaxValue; + FaqData? mostSimilarFaq = null; + var query = string.Join(" ", queries); + + foreach (var faq in faqDataList) + { + foreach (var keyword in faq.Keywords) + { + var curScore = CalculateScore(keyword, query); + if (curScore < minimumScore) + { + minimumScore = curScore; + mostSimilarFaq = faq; + } + } + } + + if (mostSimilarFaq != null) + await ReplyAsync(embed: GetFaqEmbed(mostSimilarFaq)); + else + await ReplyAsync("No FAQs Found."); + } + else + await ListFaqs(faqDataList); + } + + private async Task ListFaqs(List faqs) + { + var sb = new StringBuilder(faqs.Count); + var index = 1; + var keywordSb = new StringBuilder(); + foreach (var faq in faqs) + { + sb.Append(FormatFaq(index, faq) + "\n"); + keywordSb.Append("["); + for (var i = 0; i < faq.Keywords.Length; i++) + { + keywordSb.Append(faq.Keywords[i]); + keywordSb.Append(i < faq.Keywords.Length - 1 ? ", " : "]\n\n"); + } + + index++; + sb.Append(keywordSb); + keywordSb.Clear(); + } + + await ReplyAsync(sb.ToString()).DeleteAfterTime(minutes: 3)!; + } + + private Embed GetFaqEmbed(FaqData faq) + { + var builder = new EmbedBuilder() + .WithTitle($"{faq.Question}") + .WithDescription($"{faq.Answer}") + .WithColor(new Color(0x33CC00)); + return builder.Build(); + } + + private string FormatFaq(int id, FaqData faq) => $"{id}. **{faq.Question}** - {faq.Answer}"; + + private double CalculateScore(string s1, string s2) + { + double curScore = 0; + var i = 0; + + foreach (var q in s1.Split(' ')) + { + foreach (var x in s2.Split(' ')) + { + i++; + if (x.Equals(q)) + curScore -= 50; + else + curScore += x.CalculateLevenshteinDistance(q); + } + } + + curScore /= i; + return curScore; + } + + private int ParseNumber(string s) + { + if (int.TryParse(s, out int id)) return id; + return -1; + } +} diff --git a/DiscordBot/Modules/Server/ServerModule.cs b/DiscordBot/Modules/Server/ServerModule.cs new file mode 100644 index 00000000..7cc81097 --- /dev/null +++ b/DiscordBot/Modules/Server/ServerModule.cs @@ -0,0 +1,67 @@ +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Services; +using DiscordBot.Settings; + +namespace DiscordBot.Modules.Server; + +[Group("UserModule"), Alias("")] +public class ServerModule : ModuleBase +{ + public CommandHandlingService CommandHandlingService { get; set; } = null!; + public ServerService ServerService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; + + [Command("Help"), Priority(100)] + [Summary("Does what you see now.")] + [Alias("command", "commands")] + public async Task DisplayHelp() + { + var commandMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); + if (Context.Channel.Id != Settings.Channels.BotCommands.Id) + { + try + { + foreach (var message in commandMessages) + { + await Context.User.SendMessageAsync(message); + } + } + catch (Exception) + { + await ReplyAsync($"Your direct messages are disabled, please use <#{Settings.Channels.BotCommands.Id}> instead!").DeleteAfterSeconds(10)!; + } + } + else + { + foreach (var message in commandMessages) + { + await ReplyAsync(message); + } + } + await Context.Message.DeleteAsync(); + } + + [Command("Ping"), Priority(98)] + [Summary("Bot latency.")] + [Alias("pong")] + public async Task Ping() + { + var message = await ReplyAsync("Pong"); + var time = message.CreatedAt.Subtract(Context.Message.Timestamp); + await message.ModifyAsync(m => + m.Content = $"Pong (**{time.TotalMilliseconds}** *ms* / gateway **{ServerService.GetGatewayPing()}** *ms*)"); + await message.DeleteAfterTime(seconds: 10)!; + + await Context.Message.DeleteAfterTime(seconds: 5)!; + } + + [Command("Members"), Priority(90)] + [Summary("Current member count.")] + [Alias("MemberCount")] + public async Task MemberCount() + { + await ReplyAsync( + $"We currently have {(await Context.Guild.GetUsersAsync()).Count - 1} members. Let's keep on growing as the strong community we are :muscle:"); + } +} diff --git a/DiscordBot/Modules/Server/ServerSlashModule.cs b/DiscordBot/Modules/Server/ServerSlashModule.cs new file mode 100644 index 00000000..fa02212e --- /dev/null +++ b/DiscordBot/Modules/Server/ServerSlashModule.cs @@ -0,0 +1,114 @@ +using Discord.Interactions; +using DiscordBot.Services; +using DiscordBot.Settings; + +namespace DiscordBot.Modules.Server; + +public class ServerSlashModule : InteractionModuleBase +{ + public CommandHandlingService CommandHandlingService { get; set; } = null!; + public WelcomeService WelcomeService { get; set; } = null!; + public ServerService ServerService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; + + #region Help + + [SlashCommand("help", "Shows available commands")] + private async Task Help(string search = "") + { + await Context.Interaction.DeferAsync(ephemeral: true); + + var helpEmbed = HelpEmbed(0, search); + if (helpEmbed.Item1 >= 0) + { + ComponentBuilder builder = new(); + builder.WithButton("Next Page", $"user_module_help_next:{0}"); + + await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true, + components: builder.Build()); + } + else + { + await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true); + } + } + + [ComponentInteraction("user_module_help_next:*")] + private async Task InteractionHelp(string pageString) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + int page = int.Parse(pageString); + + var helpEmbed = HelpEmbed(page + 1); + ComponentBuilder builder = new(); + builder.WithButton("Next Page", $"user_module_help_next:{helpEmbed.Item1}"); + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Components = builder.Build(); + msg.Embed = helpEmbed.Item2; + }); + } + + private (int, Embed) HelpEmbed(int page, string search = "") + { + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = "User Module Commands"; + embedBuilder.Color = Color.LighterGrey; + + List? helpMessages = null; + if (search == string.Empty) + { + helpMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); + + if (page >= helpMessages.Count) + page = 0; + else if (page < 0) + page = helpMessages.Count - 1; + + embedBuilder.WithFooter(text: $"Page {page + 1} of {helpMessages.Count}"); + embedBuilder.Description = helpMessages[page]; + } + else + { + page = -1; + helpMessages = CommandHandlingService.SearchForCommand(("UserModule", false, true, false), search); + if (helpMessages[0].Length > 0) + { + embedBuilder.WithFooter(text: $"Search results for {search}"); + embedBuilder.Description = helpMessages[0]; + } + else + { + embedBuilder.WithFooter(text: $"No results for {search}"); + embedBuilder.Description = "No commands found"; + } + } + + return (page, embedBuilder.Build()); + } + + #endregion + + [SlashCommand("welcome", "An introduction to the server!")] + public async Task SlashWelcome() + { + await Context.Interaction.RespondAsync(string.Empty, + embed: WelcomeService.GetWelcomeEmbed(Context.User.Username), ephemeral: true); + } + + [SlashCommand("ping", "Bot latency")] + public async Task Ping() + { + await Context.Interaction.RespondAsync("Bot latency: ...", ephemeral: true); + await Context.Interaction.ModifyOriginalResponseAsync(m => + m.Content = $"Bot latency: {ServerService.GetGatewayPing().ToString()}ms"); + } + + [SlashCommand("invite", "Returns the invite link for the server.")] + public async Task ReturnInvite() + { + await Context.Interaction.RespondAsync(text: BotSettings.Invite, ephemeral: true); + } +} diff --git a/DiscordBot/Modules/TicketModule.cs b/DiscordBot/Modules/Server/TicketModule.cs similarity index 80% rename from DiscordBot/Modules/TicketModule.cs rename to DiscordBot/Modules/Server/TicketModule.cs index 67fb8f71..2fb5ef10 100644 --- a/DiscordBot/Modules/TicketModule.cs +++ b/DiscordBot/Modules/Server/TicketModule.cs @@ -4,15 +4,15 @@ using DiscordBot.Settings; // ReSharper disable all UnusedMember.Local -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Server; public class TicketModule : ModuleBase { #region Dependency Injection - public CommandHandlingService CommandHandlingService { get; set; } - public BotSettings Settings { get; set; } - + public CommandHandlingService CommandHandlingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; + #endregion /// @@ -24,27 +24,27 @@ public async Task Complaint() { await Context.Message.DeleteAsync(); - var categoryExist = (await Context.Guild.GetCategoriesAsync()).Any(category => category.Id == Settings.ComplaintCategoryId); + var categoryExist = (await Context.Guild.GetCategoriesAsync()).Any(category => category.Id == Settings.Channels.ComplaintCategoryId); var hash = Context.User.Id.ToString().GetSha256().Substring(0, 8); - var channelName = ParseToDiscordChannel($"{Settings.ComplaintChannelPrefix}-{hash}"); + var channelName = ParseToDiscordChannel($"{Settings.Channels.ComplaintPrefix}-{hash}"); var channels = await Context.Guild.GetChannelsAsync(); // Check if channel with same name already exist in the Complaint Category (if it exists). - if (channels.Any(channel => channel.Name == channelName && (!categoryExist || ((INestedChannel)channel).CategoryId == Settings.ComplaintCategoryId))) + if (channels.Any(channel => channel.Name == channelName && (!categoryExist || ((INestedChannel)channel).CategoryId == Settings.Channels.ComplaintCategoryId))) { await ReplyAsync($"{Context.User.Mention}, you already have an open complaint! Please use that channel!") - .DeleteAfterSeconds(15); + .DeleteAfterSeconds(15)!; return; } var newChannel = await Context.Guild.CreateTextChannelAsync(channelName, x => { - if (categoryExist) x.CategoryId = Settings.ComplaintCategoryId; + if (categoryExist) x.CategoryId = Settings.Channels.ComplaintCategoryId; }); var userPerms = new OverwritePermissions(viewChannel: PermValue.Allow); - var modRole = Context.Guild.Roles.First(r => r.Id == Settings.ModeratorRoleId); + var modRole = Context.Guild.Roles.First(r => r.Id == Settings.Roles.Moderator); await newChannel.AddPermissionOverwriteAsync(Context.Guild.EveryoneRole, new OverwritePermissions(viewChannel: PermValue.Deny)); await newChannel.AddPermissionOverwriteAsync(Context.User, userPerms); await newChannel.AddPermissionOverwriteAsync(modRole, userPerms); @@ -66,9 +66,9 @@ public async Task Close() { await Context.Message.DeleteAsync(); - if (!Context.Channel.Name.StartsWith(Settings.ComplaintChannelPrefix.ToLower())) return; + if (!Context.Channel.Name.StartsWith(Settings.Channels.ComplaintPrefix.ToLower())) return; - var categoryExist = (await Context.Guild.GetCategoriesAsync()).Any(category => category.Id == Settings.ClosedComplaintCategoryId); + var categoryExist = (await Context.Guild.GetCategoriesAsync()).Any(category => category.Id == Settings.Channels.ClosedComplaintCategoryId); var currentChannel = await Context.Guild.GetChannelAsync(Context.Channel.Id); @@ -81,10 +81,10 @@ public async Task Close() await currentChannel.RemovePermissionOverwriteAsync(user); } - var newName = Settings.ClosedComplaintChannelPrefix + currentChannel.Name; + var newName = Settings.Channels.ClosedComplaintPrefix + currentChannel.Name; await currentChannel.ModifyAsync(x => { - if (categoryExist) x.CategoryId = Settings.ClosedComplaintCategoryId; + if (categoryExist) x.CategoryId = Settings.Channels.ClosedComplaintCategoryId; x.Name = newName; }); } @@ -98,8 +98,8 @@ private async Task Delete() { await Context.Message.DeleteAsync(); - if (Context.Channel.Name.StartsWith(Settings.ComplaintChannelPrefix.ToLower()) || - Context.Channel.Name.StartsWith(Settings.ClosedComplaintChannelPrefix.ToLower())) + if (Context.Channel.Name.StartsWith(Settings.Channels.ComplaintPrefix.ToLower()) || + Context.Channel.Name.StartsWith(Settings.Channels.ClosedComplaintPrefix.ToLower())) { await Context.Guild.GetChannelAsync(Context.Channel.Id).Result.DeleteAsync(); } diff --git a/DiscordBot/Modules/TipModule.cs b/DiscordBot/Modules/TipModule.cs deleted file mode 100644 index 16136d74..00000000 --- a/DiscordBot/Modules/TipModule.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System.IO; -using Discord.Commands; -using DiscordBot.Attributes; -using DiscordBot.Services; -using DiscordBot.Services.Tips; -using DiscordBot.Services.Tips.Components; -using DiscordBot.Settings; - -// ReSharper disable all UnusedMember.Local -namespace DiscordBot.Modules; - -public class TipModule : ModuleBase -{ - #region Dependency Injection - - public CommandHandlingService CommandHandlingService { get; set; } - public BotSettings Settings { get; set; } - public TipService TipService { get; set; } - - #endregion - - private bool IsAuthorized(IUser user) - { - if (user.HasRoleGroup(Settings.ModeratorRoleId)) - return true; - if (user.HasRoleGroup(Settings.TipsUserRoleId)) - return true; - - return false; - } - - [Command("Tip")] - [Summary("Find and provide pre-authored tips (images or text) by their keywords.")] - /* removing [RequireModerator] for custom check */ - public async Task Tip(params string[] keywords) - { - var user = Context.Message.Author; - if (!IsAuthorized(user)) - return; - - var terms = string.Join(",", keywords); - var tips = TipService.GetTips(terms); - if (tips.Count == 0) - { - await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5); - return; - } - - foreach (var tip in tips) - tip.Requests++; - - var isAnyTextTips = tips.Any(tip => !string.IsNullOrEmpty(tip.Content)); - var builder = new EmbedBuilder(); - if (isAnyTextTips) - { - // Loop through tips in order, have dot point list of the .Content property in an embed - builder - .WithTitle("Tip List") - .WithDescription("Here are the tips for your keywords:"); - foreach (var tip in tips) - { - builder.AddField(tip.Keywords.Count == 1 ? tip.Keywords[0] : "Multiple Keywords", tip.Content); - } - } - - var attachments = tips - .Where(tip => tip.ImagePaths != null && tip.ImagePaths.Any()) - .SelectMany(tip => tip.ImagePaths) - .Select(imagePath => new FileAttachment(TipService.GetTipPath(imagePath))) - .ToList(); - - if (attachments.Count > 0) - { - if (isAnyTextTips) - { - await Context.Channel.SendFilesAsync(attachments, embed: builder.Build()); - } - else - { - await Context.Channel.SendFilesAsync(attachments); - } - } - else - { - await ReplyAsync(embed: builder.Build()); - } - - var ids = string.Join(" ", tips.Select(t => t.Id.ToString()).ToArray()); - await ReplyAsync($"-# Tip ID {ids}"); - await Context.Message.DeleteAsync(); - await TipService.CommitTipDatabase(); - } - - [Command("AddTip")] - [Summary("Add a tip to the database.")] - [RequireModerator] - public async Task AddTip(string keywords, string content = "") - { - await TipService.AddTip(Context.Message, keywords, content); - } - - [Command("RemoveTip")] - [Summary("Remove a tip from the database.")] - [RequireModerator] - public async Task RemoveTip(ulong tipId) - { - Tip tip = TipService.GetTip(tipId); - if (tip == null) - { - await Context.Channel.SendMessageAsync("No such tip found to be removed.").DeleteAfterSeconds(5); - return; - } - - await TipService.RemoveTip(Context.Message, tip); - } - - [Command("ReplaceTip")] - [Summary("Replace image content of an existing tip in the database.")] - [RequireModerator] - public async Task ReplaceTip(ulong tipId, string content = "") - { - Tip tip = TipService.GetTip(tipId); - if (tip == null) - { - await Context.Channel.SendMessageAsync("No such tip found to be replaced.").DeleteAfterSeconds(5); - return; - } - - await TipService.ReplaceTip(Context.Message, tip, content); - } - - [Command("ReloadTips")] - [Summary("Reload the database of tips.")] - [RequireModerator] - public async Task ReloadTipDatabase() - { - // rare usage, but in case someone with a shell decides - // to edit the json for debugging/expansion reasons... - await TipService.ReloadTipDatabase(); - await ReplyAsync("Tip index reloaded."); - } - - [Command("ListTips")] - [Summary("List available tips by their keywords.")] - /* removing [RequireModerator] for custom check */ - public async Task ListTips(params string[] keywords) - { - var user = Context.Message.Author; - if (!IsAuthorized(user)) - return; - - int floodCount = 20; - - List tips = null; - if (keywords?.Length > 0) - { - var terms = string.Join(",", keywords); - tips = TipService.GetTips(terms); - if (tips.Count == 0) - { - await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5); - return; - } - if (tips.Count >= floodCount) - { - await ReplyAsync($"Total of {tips.Count} tips found for the keywords provided; refine your search.").DeleteAfterSeconds(5); - return; - } - } - else - { - tips = TipService.GetAllTips().OrderBy(t => t.Id).ToList(); - if (tips.Count >= floodCount) - { - var terms = new HashSet(); - foreach (var tip in tips) - foreach (var term in tip.Keywords) - terms.Add(term); - await ReplyAsync($"Total of {tips.Count} tips found, add one or more keywords to narrow the search."); - var termList = new List(); - foreach (var tip in terms.OrderBy(k => k)) - termList.Add(tip); - floodCount = 150; - while (termList.Count > 0) - { - int count = termList.Count; - if (count > floodCount) - count = floodCount-10; - string keywordList = "Keywords: "; - for (int i = 0; i < count; i++) - { - keywordList += $"`{termList[0]}`, "; - termList.RemoveAt(0); - } - keywordList = keywordList.Substring(0, keywordList.Length-2); - await ReplyAsync(keywordList); - if (termList.Count > 0) - await Task.Delay(500); - } - return; - } - } - - int chunkCount = 10; - int chunkTime = 1500; - bool first = true; - - while (tips.Count > 0) - { - var builder = new EmbedBuilder(); - if (first) - { - builder - .WithTitle("List of Tips") - .WithDescription("Tips available for the following keywords:"); - first = false; - } - - int chunk = 0; - while (tips.Count > 0 && chunk < chunkCount) - { - string keywordlist = string.Join("`, `", tips[0].Keywords.OrderBy(k => k)); - string images = String.Concat( - Enumerable.Repeat(" :frame_photo:", - tips[0].ImagePaths.Count).ToArray()); - builder.AddField($"ID: {tips[0].Id} {images}", $"`{keywordlist}`"); - tips.RemoveAt(0); - chunk++; - } - - await ReplyAsync(embed: builder.Build()); - if (tips.Count > 0) - await Task.Delay(chunkTime); - } - } - - #region CommandList - [Command("TipHelp")] - [Alias("TipsHelp")] - [Summary("Shows available tip database commands.")] - public async Task TipHelp() - { - // NOTE: skips the RequireModerator commands, so nearly an empty list - foreach (var message in CommandHandlingService.GetCommandListMessages("TipModule", true, true, false)) - { - await ReplyAsync(message); - } - } - #endregion - -} diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs deleted file mode 100644 index d8bc3b65..00000000 --- a/DiscordBot/Modules/UserModule.cs +++ /dev/null @@ -1,1256 +0,0 @@ -using System.Globalization; -using System.Net; -using System.Text; -using System.Text.RegularExpressions; -using Discord.Commands; -using Discord.WebSocket; -using DiscordBot.Services; -using DiscordBot.Settings; -using DiscordBot.Utils; -using HtmlAgilityPack; -using DiscordBot.Attributes; -using DiscordBot.Data; - -namespace DiscordBot.Modules; - -public class UserModule : ModuleBase -{ - #region Dependency Injection - - public UserService UserService { get; set; } - public ILoggingService LoggingService { get; set; } - public CurrencyService CurrencyService { get; set; } - public DatabaseService DatabaseService { get; set; } - public UpdateService UpdateService { get; set; } - public CommandHandlingService CommandHandlingService { get; set; } - public WeatherService WeatherService { get; set; } - public UserExtendedService UserExtendedService { get; set; } - public BotSettings Settings { get; set; } - public Rules Rules { get; set; } - - #endregion - - private readonly Random _random = new(); - private FuzzTable _slapObjects = new(); - private FuzzTable _slapFails = new(); - - [Command("Help"), Priority(100)] - [Summary("Does what you see now.")] - [Alias("command", "commands")] - public async Task DisplayHelp() - { - var commandMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); - if (Context.Channel.Id != Settings.BotCommandsChannel.Id) - { - try - { - foreach (var message in commandMessages) - { - await Context.User.SendMessageAsync(message); - } - } - catch (Exception) - { - await ReplyAsync($"Your direct messages are disabled, please use <#{Settings.BotCommandsChannel.Id}> instead!").DeleteAfterSeconds(10); - } - } - else - { - foreach (var message in commandMessages) - { - await ReplyAsync(message); - } - } - await Context.Message.DeleteAsync(); - } - #region Quote - - [Command("Quote"), HideFromHelp] - public async Task QuoteMessageCommand(IMessageChannel channel, ulong messageId) - { - await QuoteMessage(messageId: messageId, channel: channel); - } - - [Command("Quote"), Priority(10)] - [Summary("Quote a message. Syntax : !quote messageid (#channel)")] - public async Task QuoteMessageCommand(ulong messageId, ulong channel) - { - // Get channel, if channel doesn't exist, we try get channel from messageID - IMessageChannel targetChannel = (IMessageChannel)await Context.Client.GetChannelAsync(channel) ?? (IMessageChannel)await Context.Client.GetChannelAsync(messageId); - if (targetChannel == null) - { - await ReplyAsync("Channel or MessageID does not exist").DeleteAfterSeconds(seconds: 5); - return; - } - - if (targetChannel.Id == channel) - await QuoteMessage(messageId, targetChannel); - else - await QuoteMessage(channel, targetChannel); - } - - [Command("Quote"), HideFromHelp] - [Summary("Quote a message. Syntax : !quote messageid (#channel)")] - public async Task QuoteMessage(ulong messageId, IMessageChannel channel = null) - { - // If channel is null use Context.Channel, else use the provided channel - channel ??= Context.Channel; - var message = await channel.GetMessageAsync(messageId); - if (message == null) - { - await Context.Message.DeleteAfterSeconds(seconds: 1); - await ReplyAsync("No message with that id found.").DeleteAfterSeconds(seconds: 4); - return; - } - if (message.Author.IsBot) // Can't imagine we need to quote the bots - { - await Context.Message.DeleteAfterSeconds(seconds: 2); - return; - } - - var messageLink = "https://discordapp.com/channels/" + Context.Guild.Id + "/" + channel.Id + "/" + messageId; - - var msgContent = message.Content; - - if (msgContent != null) - { - msgContent = msgContent.Truncate(1020); - - // Searches for embed links such as [Google](https://bing.com/) - var regex = new Regex(@"\[([^\[\]\(\)]*)\]\((.*?)\)"); - - var matches = regex.Matches(msgContent); - - foreach (var match in matches as IEnumerable) - { - msgContent = msgContent.Replace(match.Value, $"\\{match.Value}"); - } - } - - var msgAttachment = string.Empty; - if (message.Attachments?.Count > 0) msgAttachment = "\t📸"; - var builder = new EmbedBuilder() - .WithColor(new Color(200, 128, 128)) - .WithTimestamp(message.Timestamp) - .FooterQuoteBy(Context.User, message.Channel) - .AddAuthor(message.Author); - if (msgContent == string.Empty && msgAttachment != string.Empty) msgContent = "📸"; - - msgContent += $"\n\n***[Linkback]({messageLink})***"; - builder.Description = msgContent; - - await ReplyAsync(embed: builder.Build()); - await Context.Message.DeleteAfterSeconds(1.0); - } - #endregion - - /* Not really a required feature of the bot? - [Command("compile")] - [Summary("Try to compile a snippet of C# code. Be sure to escape your strings. Syntax : !compile \"Your code\"")] - [Alias("code", "compute", "assert")] - public async Task CompileCode(params string[] code) - { - var codeComplete = Resources.PaizaCodeTemplate.Replace("{code}", string.Join(" ", code)); - - var parameters = new Dictionary {{"source_code", codeComplete}, {"language", "csharp"}, {"api_key", "guest"}}; - - var content = new FormUrlEncodedContent(parameters); - - var message = await ReplyAsync( - $"Please wait a moment, trying to compile your code interpreted as\n {codeComplete.AsCodeBlock()}"); - - using (var client = new HttpClient()) - { - var httpResponse = await client.PostAsync("https://api.paiza.io/runners/create", content); - var response = JsonConvert.DeserializeObject>(await httpResponse.Content.ReadAsStringAsync()); - - var id = response["id"]; - string status; - var startTime = DateTime.Now; - const int maxTime = 30; - - do - { - httpResponse = await client.GetAsync($"http://api.paiza.io/runners/get_details?id={id}&api_key=guest"); - response = JsonConvert.DeserializeObject>(await httpResponse.Content.ReadAsStringAsync()); - status = response["status"]; - await Task.Delay(300); - } while (status != "completed" && (DateTime.Now - startTime).TotalSeconds < maxTime); - - string newMessage; - - if (status != "completed") - { - newMessage = (message.Content + "The code didn't compile in time.").Truncate(1990); - await message.ModifyAsync(m => m.Content = newMessage); - return; - } - - var buildStddout = response["build_stdout"]; - var stdout = response["stdout"]; - var stderr = response["stderr"]; - var buildStderr = response["build_stderr"]; - var result = response["build_result"]; - - string fullMessage; - if (result == "failure") - { - fullMessage = message.Content + "The code resulted in a failure.\n"; - fullMessage += buildStddout.Length > 0 ? buildStddout.AsCodeBlock() : string.Empty; - fullMessage += buildStderr.Length > 0 ? buildStderr.AsCodeBlock() : string.Empty; - } - else - { - fullMessage = message.Content + "Result : "; - fullMessage += stdout.Length > 0 ? stdout.AsCodeBlock() : string.Empty; - fullMessage += stderr.Length > 0 ? stderr.AsCodeBlock() : string.Empty; - } - - httpResponse = await client.PostAsync("https://hastebin.com/documents", new StringContent(fullMessage.Truncate(10000))); - response = JsonConvert.DeserializeObject>(await httpResponse.Content.ReadAsStringAsync()); - - newMessage = ($"\nFull result : https://hastebin.com/{response["key"]}\n" + fullMessage).Truncate(1990) + "```"; - await message.ModifyAsync(m => m.Content = newMessage); - } - } - */ - - [Command("Ping"), Priority(98)] - [Summary("Bot latency.")] - [Alias("pong")] - public async Task Ping() - { - var message = await ReplyAsync("Pong"); - var time = message.CreatedAt.Subtract(Context.Message.Timestamp); - await message.ModifyAsync(m => - m.Content = $"Pong (**{time.TotalMilliseconds}** *ms* / gateway **{UserService.GetGatewayPing()}** *ms*)"); - await message.DeleteAfterTime(seconds: 10); - - await Context.Message.DeleteAfterTime(seconds: 5); - } - - [Command("Members"), Priority(90)] - [Summary("Current member count.")] - [Alias("MemberCount")] - public async Task MemberCount() - { - await ReplyAsync( - $"We currently have {(await Context.Guild.GetUsersAsync()).Count - 1} members. Let's keep on growing as the strong community we are :muscle:"); - } - - [Command("ChristmasCompleted"), HideFromHelp] - [Summary("Reward for christmas event.")] - public async Task UserCompleted(string message) - { - //Make sure they're the santa bot - if (Context.Message.Author.Id != 514979161144557600L) return; - - if (!long.TryParse(message, out var userId)) - { - await ReplyAsync("Invalid user id"); - return; - } - - const int xpGain = 5000; - var userXp = await DatabaseService.Query.GetXp(userId.ToString()); - await DatabaseService.Query.UpdateXp(userId.ToString(), userXp + xpGain); - await Context.Message.DeleteAsync(); - } - - [Group("Role"), BotCommandChannel] - public class RoleModule : ModuleBase - { - public BotSettings Settings { get; set; } - public ILoggingService LoggingService { get; set; } - - [Command("Add")] - [Summary("Add a role to yourself. Syntax: !role add rolename")] - public async Task AddRoleUser(IRole role) - { - if (!Settings.UserAssignableRoles.Roles.Contains(role.Name)) - { - await ReplyAsync("This role is not assignable."); - return; - } - - var u = Context.User as IGuildUser; - var uname = u.GetUserPreferredName(); - - await u.AddRoleAsync(role); - await ReplyAsync($"{uname}, you now have the `{role.Name}` role."); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has added {role} to themself."); - } - - [Command("Remove")] - [Summary("Remove a role from yourself. Syntax: !role remove rolename")] - [Alias("delete")] - public async Task RemoveRoleUser(IRole role) - { - if (!Settings.UserAssignableRoles.Roles.Contains(role.Name)) - { - await ReplyAsync("This role is not assignable."); - return; - } - - var u = Context.User as IGuildUser; - var uname = u.GetUserPreferredName(); - - await u.RemoveRoleAsync(role); - await ReplyAsync($"{uname}, your `{role.Name}` role has been removed."); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has removed role {role} from themself."); - } - - [Command("List")] - [Summary("List of available roles. Syntax: !role list")] - public async Task ListRole() - { - await ReplyAsync("**The following roles are available on this server** :\n" + - "We offer multiple roles to show what you specialize in, whether it's professionally or as a hobby, so if there's something you're good at, assign the corresponding role! \n" + - "You can assign as much roles as you want, but try to keep them for what you're good at :) \n"); - await ReplyAsync( - "```!role add/remove 2D-Artists - If you're good at drawing, painting, digital art, concept art or anything else that's flat. \n" + - "!role add/remove 3D-Artists - If you are a wizard with vertices or like to forge your models from mud. \n" + - "!role add/remove Animators - If you like to bring characters to life. \n" + - "!role add/remove Technical-Artists - If you write tools and shaders to bridge the gap between art and programming. \n" + - "!role add/remove Programmers - If you like typing away to make your dreams come true (or the code come to your dreams). \n" + - "!role add/remove Game-Designers - If you are good at designing games, mechanics and levels.\n" + - "!role add/remove Audio-Engineers - If you live life to the rhythm of your own music and sounds.\n" + - "!role add/remove Generalists - If you like to dabble in everything.\n" + - "!role add/remove Hobbyists - If you're using Unity as a hobby.\n" + - "!role add/remove Students - If you're currently studying in a game-dev related field. \n" + - "!role add/remove XR-Developers - If you're a VR, AR or MR sorcerer. \n" + - "!role add/remove Writers - If you like writing lore, scenarios, characters and stories. \n" + - "```"); - } - } - #region All Rules - - [Command("Rules"), Priority(1)] - [Summary("Rules of current channel by DM.")] - public async Task RulesCommand() - { - await RulesCommand(Context.Channel); - await Context.Message.DeleteAsync(); - } - - [Command("Rules"), Priority(99)] - [Summary("Rules of the mentioned channel by DM. !rules #channel")] - [Alias("rule")] - public async Task RulesCommand(IMessageChannel channel) - { - var rule = Rules.Channel.First(x => x.Id == channel.Id); - var dm = await Context.User.CreateDMChannelAsync(); - bool sentMessage = false; - - sentMessage = await dm.TrySendMessage($"{rule.Header}{(rule.Content.Length > 0 ? rule.Content : $"There is no special rule for {channel.Name} channel.\nPlease follow global rules (you can get them by typing `!globalrules`)")}"); - if (!sentMessage) - await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); - } - - [Command("GlobalRules"), Priority(99)] - [Summary("Global Rules by DM.")] - public async Task GlobalRules(int seconds = 60) - { - var globalRules = Rules.Channel.First(x => x.Id == 0).Content; - var dm = await Context.User.CreateDMChannelAsync(); - await Context.Message.DeleteAsync(); - if (!await dm.TrySendMessage(globalRules)) - { - await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); - } - } - - [Command("Welcome"), Priority(1)] - [Summary("Condensed version of the rules and links to quality resources.")] - public async Task ServerWelcome() - { - if (!await UserService.DMFormattedWelcome(Context.User as SocketGuildUser)) - { - await ReplyAsync("Could not send welcome, your DMs are disabled.").DeleteAfterSeconds(seconds: 2); - } - await Context.Message.DeleteAfterSeconds(seconds: 4); - } - - [Command("Channels"), Priority(92)] - [Summary("Description of the channels by DM.")] - public async Task ChannelsDescription() - { - //Display rules of this channel for x seconds - var channelData = Rules.Channel; - var sb = new StringBuilder(); - foreach (var c in channelData) - sb.Append((await Context.Guild.GetTextChannelAsync(c.Id))?.Mention).Append(" - ").Append(c.Header).Append("\n"); - - var dm = await Context.User.CreateDMChannelAsync(); - - var messages = sb.ToString().MessageSplitToSize(); - await Context.Message.DeleteAsync(); - foreach (var message in messages) - { - if (!await dm.TrySendMessage(message)) - { - await ReplyAsync("Could not send channel descriptions, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); - break; - } - } - } - - #endregion - - #region XP & Karma - - [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); - } - - [Command("Top"), Priority(6)] - [Summary("Display top 10 users by level.")] - [Alias("toplevel", "ranking")] - public async Task TopLevel() - { - var users = await DatabaseService.Query.GetTopLevel(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.Level)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Level"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - [Command("TopKarma"), Priority(5)] - [Summary("Display top 10 users by karma.")] - [Alias("karmarank", "rankingkarma", "topk")] - public async Task TopKarma() - { - var users = await DatabaseService.Query.GetTopKarma(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.Karma)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - [Command("TopKarmaWeekly"), Priority(5)] - [Summary("Display weekly top 10 users by karma.")] - [Alias("karmarankweekly", "rankingkarmaweekly", "topkw")] - public async Task TopKarmaWeekly() - { - var users = await DatabaseService.Query.GetTopKarmaWeekly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaWeekly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Weekly Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - [Command("TopKarmaMonthly"), Priority(5)] - [Summary("Display monthly top 10 users by karma.")] - [Alias("karmarankmonthly", "rankingkarmamonthly", "topkm")] - public async Task TopKarmaMonthly() - { - var users = await DatabaseService.Query.GetTopKarmaMonthly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaMonthly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Monthly Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - [Command("TopKarmaYearly"), Priority(5)] - [Summary("Display tearly top 10 users by karma.")] - [Alias("karmaranktearly", "rankingkarmayearly", "topky")] - public async Task TopKarmaYearly() - { - var users = await DatabaseService.Query.GetTopKarmaYearly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaYearly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Yearly Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - 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"; // For cases where the user has left the guild - 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(); - } - - [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 UserService.GenerateProfileCard(user); - if (string.IsNullOrEmpty(profileCard)) - { - await ReplyAsync("Failed to generate profile card.").DeleteAfterSeconds(seconds: 10); - return; - } - - var profile = await Context.Channel.SendFileAsync(profileCard); - await profile.DeleteAfterTime(minutes: 3); - } - catch (Exception e) - { - await LoggingService.LogAction($"Error while generating profile card for {user.Username}.\nEx:{e}", - ExtendedLogSeverity.LowWarning); - } - } - - [Command("JoinDate"), Priority(91)] - [Summary("Display date you joined the server.")] - public async Task JoinDate() - { - var userId = Context.User.Id; - 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("SetCity"), Priority(100)] - [Alias("SetDefaultCity")] - [Summary("Set 'Default City' which can be used by various commands.")] - public async Task SetDefaultCity(params string[] city) - { - var uname = Context.User.GetUserPreferredName(); - var fullCityName = string.Join(" ", city); - var (exists, result) = await WeatherService.CityExists(fullCityName); - if (!exists) - { - await ReplyAsync($"Sorry, {uname}, but I couldn't find a city with that name.").DeleteAfterSeconds(30); - await Context.Message.DeleteAsync(); - return; - } - // Set default city - await UserExtendedService.SetUserDefaultCity(Context.User, result.name); - await ReplyAsync($"{uname}, your default city has been set to {result.name}."); - } - - [Command("RemoveCity"), Priority(100)] - [Alias("RemoveDefaultCity")] - [Summary("Remove 'Default City' which can be used by various commands.")] - public async Task RemoveDefaultCity() - { - var uname = Context.User.GetUserPreferredName(); - if (!await UserExtendedService.DoesUserHaveDefaultCity(Context.User)) - { - await ReplyAsync($"{uname}, you don't have a default city set.").DeleteAfterSeconds(30); - await Context.Message.DeleteAsync(); - return; - } - await UserExtendedService.RemoveUserDefaultCity(Context.User); - await ReplyAsync($"{uname}, your default city has been removed."); - } - - #endregion - - #region Codetips - - [Command("CodeTip"), Priority(20)] - [Summary("Show code formatting example. Syntax: !codetip userToPing(optional)")] - [Alias("codetips")] - public async Task CodeTip(IUser user = null) - { - var message = user != null ? user.Mention + ", " : ""; - message += "When posting code, format it like so:" + Environment.NewLine; - message += UserService.CodeFormattingExample; - await Context.Message.DeleteAsync(); - await ReplyAsync(message).DeleteAfterSeconds(seconds: 60); - } - - [Command("DisableCodeTips"), Priority(91)] - [Summary("Stops code formatting reminders.")] - public async Task DisableCodeTips() - { - await Context.Message.DeleteAsync(); - if (!UserService.CodeReminderCooldown.IsPermanent(Context.User.Id)) - { - UserService.CodeReminderCooldown.SetPermanent(Context.User.Id, true); - var uname = Context.User.GetUserPreferredName(); - await ReplyAsync($"{uname}, you will no longer be reminded about correct code formatting.").DeleteAfterTime(20); - } - } - - #endregion - - #region Fun - [Command("Slap"), Priority(21)] - [Summary("Slap the specified user(s). Syntax : !slap @user1 [@user2 @user3...]")] - public async Task SlapUser(params IUser[] users) - { - try - { - if (_slapObjects.Count == 0) - _slapObjects.Load(Settings.UserModuleSlapObjectsTable); - } - catch (Exception e) - { - await LoggingService.LogChannelAndFile($"Error while loading '{Settings.UserModuleSlapObjectsTable}'.\nEx:{e}", - ExtendedLogSeverity.LowWarning); - return; - } - if (_slapObjects.Count == 0) - _slapObjects.Add(Settings.UserModuleSlapChoices); - if (_slapObjects.Count == 0) - _slapObjects.Add("fish|mallet"); - - if (_slapFails.Count == 0) - _slapFails.Add(Settings.UserModuleSlapFails); - if (_slapFails.Count == 0) - _slapFails.Add("hurting themselves"); - - 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; - } - - 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("around a bit with a large "); - 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("around a bit with a large "); - 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); - } - - [Command("Roll"), Priority(23)] - [Summary("Roll a dice. Syntax: !roll [sides]")] - public async Task RollDice(int sides = 20) - { - await RollDice(sides, 0); - } - - [Command("Roll"), Priority(23)] - [Summary("Roll a dice. Syntax: !roll [sides] [minimum]")] - public async Task RollDice(int sides, int number) - { - if (sides < 1 || sides > 1000) - { - await ReplyAsync("Invalid number of sides. Please choose a number between 1 and 1000.").DeleteAfterSeconds(seconds: 10); - await Context.Message.DeleteAsync(); - return; - } - - var uname = Context.User.GetUserPreferredName(); - var roll = _random.Next(1, sides + 1); - var message = $"**{uname}** rolled a D{sides} and got **{roll}**!"; - if (number < 1) - message = " :game_die: " + message; - else if (roll >= number) - message = " :white_check_mark: " + message + " [Needed: " + number + "]"; - else - message = " :x: " + message + " [Needed: " + number + "]"; - - await ReplyAsync(message); - await Context.Message.DeleteAfterSeconds(seconds: 1); - } - - [Command("D20"), Priority(23)] - [Summary("Roll a D20 dice. Syntax: !d20 [minimum]")] - public async Task RollD20(int number = 0) - { - await RollDice(20, number); - } - - #endregion - - #region Search - [Command("Search"), Priority(25)] - [Summary("Searches DuckDuckGo for results. Syntax: !search c# lambda help")] - [Alias("s", "ddg")] - public async Task SearchResults(params string[] messages) - { - StringBuilder sb = new(); - foreach (var msg in messages) - sb.Append(msg).Append(" "); - await SearchResults(sb.ToString()); - } - - [Command("Search"), HideFromHelp] - [Summary("Searches DuckDuckGo for web results. Syntax : !search \"query\" resNum site")] - [Alias("s", "ddg")] - public async Task SearchResults(string query, uint resNum = 3, string site = "") - { - // Cleaning inputs from user (maybe we can ban certain domains or keywords) - resNum = resNum <= 5 ? resNum : 5; - var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+'); - - if (site != string.Empty) searchQuery += "+site:" + site; - - var doc = new HtmlWeb().Load(searchQuery); - var counter = 1; - - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = $"Q: {WebUtility.UrlDecode(query)}"; - string resultTitle = string.Empty; - - // XPath for DuckDuckGo as of 10/05/2018, if results stop showing up, check this first! - // Still working (13/05/21) - foreach (var row in doc.DocumentNode.SelectNodes("/html/body/div[1]/div[3]/div/div/div[*]/div/h2/a")) - { - if (counter > resNum) break; - - // Seems to be some weird additional data attached to links. Fix added (13/05/21) - row.Attributes["href"].Value = row.Attributes["href"].Value.Replace("//duckduckgo.com/l/?uddg=", string.Empty); - - // Check if we are within the allowed number of results and if the result is valid (i.e. no evil ads) - if (counter <= resNum && IsValidResult(row)) // && IsValidResult(row)) - { - var url = WebUtility.UrlDecode(row.Attributes["href"].Value); // .Replace("/l/?kh=-1&uddg=", "")); <- no longer works (14/05/21) - - // We count how many & there are, as links with multiple may be broken, so we include a ~ just to try give a bit more info if there is more than 1. - int andCount = url.Count(c => c == '&'); - url = url.Substring(0, url.LastIndexOf('&')); - - resultTitle += $"{counter}. {(row.InnerText.Length > 60 ? $"{row.InnerText[..60]}.." : row.InnerText)}" + $" [__Read More..__{(andCount > 1 ? "~" : string.Empty)}]({url})\n"; - - counter++; - } - } - - embedBuilder.AddField("Search Query", searchQuery); - embedBuilder.AddField("Results", resultTitle, inline: false); - - embedBuilder.Color = new Color(81, 50, 169); - embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from DuckDuckGo."); - - var embed = embedBuilder.Build(); - await ReplyAsync(embed: embed); - } - - // Utility function for avoiding evil ads from DuckDuckGo - bool IsValidResult(HtmlNode node) - { - return (!node.Attributes["href"].Value.Contains("duckduckgo.com") && - !node.Attributes["href"].Value.Contains("duck.co")); - } - - [Command("Manual"), Priority(8)] - [Summary("Searches Unity3D manual for results. Syntax : !manual \"query\"")] - public async Task SearchManual(params string[] queries) - { - // Download Unity3D Documentation Database (lol) - - // Calculate the closest match to the input query - var minimumScore = double.MaxValue; - string[] mostSimilarPage = null; - var pages = await UpdateService.GetManualDatabase(); - var query = string.Join(" ", queries); - foreach (var p in pages) - { - var curScore = CalculateScore(p[1], query); - if (!(curScore < minimumScore)) continue; - - minimumScore = curScore; - mostSimilarPage = p; - } - - // If a page has been found (should be), return the message, else return information - if (mostSimilarPage != null) - { - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = $"Found {mostSimilarPage[0]}"; - embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)"; - embedBuilder.Color = new Color(81, 50, 169); - embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); - var message = await ReplyAsync(embed: embedBuilder.Build()); - - var doc = new HtmlWeb().Load($"https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html"); - // Get first Header as this'll contain the main part we need - var descriptionNode = doc.DocumentNode.SelectSingleNode("//h1"); - if (descriptionNode == null) return; - // Description is in next , but we need to strip out tooltips - descriptionNode = descriptionNode.SelectSingleNode("following-sibling::p"); - descriptionNode.Descendants().Where(n => n.GetAttributeValue("class", "").Contains("tooltip")).ToList().ForEach(n => n.Remove()); - var description = descriptionNode.InnerText; - - embedBuilder.WithDescription($"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + $"[Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)"); - await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); - } - else - await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); - } - - [Command("Doc"), Priority(9)] - [Summary("Searches Unity3D API for results. Syntax : !api \"query\"")] - [Alias("ref", "reference", "api", "docs")] - public async Task SearchApi(params string[] queries) - { - // Download Unity3D Documentation Database (lol) - - // Calculate the closest match to the input query - var minimumScore = double.MaxValue; - string[] mostSimilarPage = null; - var pages = await UpdateService.GetApiDatabase(); - var query = string.Join(" ", queries); - foreach (var p in pages) - { - var curScore = CalculateScore(p[1], query); - if (!(curScore < minimumScore)) continue; - - minimumScore = curScore; - mostSimilarPage = p; - } - - // If a page has been found (should be), return the message, else return information - if (mostSimilarPage != null) - { - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = $"Found {mostSimilarPage[0]}"; - embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; - embedBuilder.Color = new Color(81, 50, 169); - embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); - var message = await ReplyAsync(embed: embedBuilder.Build()); - - // Load the page, and look for a Description tag, and then get the next tag - var doc = new HtmlWeb().Load($"https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html"); - var descriptionNode = doc.DocumentNode.SelectSingleNode("//h3[contains(text(), 'Description')]"); - - string descriptionString = ""; - string manualLinkString = ""; - if (descriptionNode != null) - { - var description = descriptionNode.SelectSingleNode("following-sibling::p").InnerText; - descriptionString = - $"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + - $"[Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; - - } - - // We check the page for the first "switch-link" class, which will be a link to a Manual page - var manualLink = doc.DocumentNode.SelectSingleNode("//a[contains(@class, 'switch-link')]"); - if (manualLink != null && manualLink.Attributes.Contains("title")) - { - var manualLinkText = manualLink.GetAttributes("title").First().Value; - var manualLinkUrl = "https://docs.unity3d.com/" + manualLink.GetAttributeValue("href", ""); - manualLinkString = $"\n**Manual:** [{manualLinkText}]({manualLinkUrl})"; - } - - embedBuilder.WithDescription(descriptionString + manualLinkString); - await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); - } - else - await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); - } - - private double CalculateScore(string s1, string s2) - { - double curScore = 0; - var i = 0; - - foreach (var q in s1.Split(' ')) - { - foreach (var x in s2.Split(' ')) - { - i++; - if (x.Equals(q)) - curScore -= 50; - else - curScore += x.CalculateLevenshteinDistance(q); - } - } - - curScore /= i; - return curScore; - } - - [Command("FAQ")] - [Summary("Searches UDC FAQs. Syntax : !faq \"query\"")] - public async Task SearchFaqs(params string[] queries) - { - var faqDataList = UpdateService.GetFaqData(); - - // Check if query is faq ID (e.g. "!faq 1") - if (queries.Length == 1 && ParseNumber(queries[0]) > 0) - { - var id = ParseNumber(queries[0]) - 1; - if (id < faqDataList.Count) - await ReplyAsync(embed: GetFaqEmbed(faqDataList[id])); - else - await ReplyAsync("Invalid FAQ ID selected."); - } - // Check if query contains "list" command (i.e. "!faq list") - else if (queries.Length > 0 && !(queries.Length == 1 && queries[0].Equals("list"))) - { - // Calculate the closest match to the input query - var minimumScore = double.MaxValue; - FaqData mostSimilarFaq = null; - var query = string.Join(" ", queries); - - // Go through each FAQ in the list and check the most similar - foreach (var faq in faqDataList) - { - foreach (var keyword in faq.Keywords) - { - var curScore = CalculateScore(keyword, query); - if (curScore < minimumScore) - { - minimumScore = curScore; - mostSimilarFaq = faq; - } - } - } - - // If an FAQ has been found (should be), return the FAQ, else return information msg - if (mostSimilarFaq != null) - await ReplyAsync(embed: GetFaqEmbed(mostSimilarFaq)); - else - await ReplyAsync("No FAQs Found."); - } - else - // List all the FAQs available - await ListFaqs(faqDataList); - } - - private async Task ListFaqs(List faqs) - { - var sb = new StringBuilder(faqs.Count); - var index = 1; - var keywordSb = new StringBuilder(); - foreach (var faq in faqs) - { - sb.Append(FormatFaq(index, faq) + "\n"); - keywordSb.Append("["); - for (var i = 0; i < faq.Keywords.Length; i++) - { - keywordSb.Append(faq.Keywords[i]); - keywordSb.Append(i < faq.Keywords.Length - 1 ? ", " : "]\n\n"); - } - - index++; - sb.Append(keywordSb); - keywordSb.Clear(); - } - - await ReplyAsync(sb.ToString()).DeleteAfterTime(minutes: 3); - } - - private Embed GetFaqEmbed(FaqData faq) - { - var builder = new EmbedBuilder() - .WithTitle($"{faq.Question}") - .WithDescription($"{faq.Answer}") - .WithColor(new Color(0x33CC00)); - return builder.Build(); - } - - private string FormatFaq(int id, FaqData faq) => $"{id}. **{faq.Question}** - {faq.Answer}"; - - [Command("Wiki"), Priority(26)] - [Summary("Searches Wikipedia. Syntax : !wiki \"query\"")] - [Alias("wikipedia")] - public async Task SearchWikipedia([Remainder] string query) - { - var article = await UpdateService.DownloadWikipediaArticle(query); - - // If an article is found return it, else return error message - if (article.url == null) - { - await ReplyAsync($"No Articles for \"{query}\" were found."); - return; - } - - await ReplyAsync(embed: GetWikipediaEmbed(article.name, article.extract, article.url)); - } - - private Embed GetWikipediaEmbed(string subject, string articleExtract, string articleUrl) - { - var builder = new EmbedBuilder() - .WithTitle($"Wikipedia | {subject}") - .WithDescription($"{articleExtract}") - .WithUrl(articleUrl) - .WithColor(new Color(0x33CC00)); - return builder.Build(); - } - - private int ParseNumber(string s) - { - int id; - if (int.TryParse(s, out id)) return id; - - return -1; - } - - #endregion - - #region Birthday - - [Command("Birthday"), HideFromHelp] - [Summary("Display next member birthday.")] - [Alias("bday")] - public async Task Birthday() - { - // URL to cell C15/"Next birthday" cell from Corn's google sheet - const string nextBirthday = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; - - var tableText = await WebUtil.GetHtmlNodeInnerText(nextBirthday, "/html/body/table/tr[2]/td"); - var message = $"**{tableText}**"; - - await ReplyAsync(message).DeleteAfterTime(minutes: 3); - await Context.Message.DeleteAfterTime(minutes: 3); - } - - [Command("Birthday"), Priority(27)] - [Summary("Display birthday of mentioned user. Syntax : !birthday @user")] - [Alias("bday")] - public async Task Birthday(IUser user) - { - var searchName = user.Username; - // URL to columns B to D of Corn's google sheet - const string birthdayTable = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; - var relevantNodes = await WebUtil.GetHtmlNodes(birthdayTable, "/html/body/table/tr"); - - var birthdate = default(DateTime); - - HtmlNode matchedNode = null; - var matchedLength = int.MaxValue; - - // XPath to each table row - foreach (var row in relevantNodes) - { - // XPath to the name column (C) - var nameNode = row.SelectSingleNode("td[2]"); - var name = nameNode.InnerText; - - if (!name.ToLower().Contains(searchName.ToLower()) || name.Length >= matchedLength) - continue; - - // Check for a "Closer" match - matchedNode = row; - matchedLength = name.Length; - // Nothing will match "Better" so we may as well break out - if (name.Length == searchName.Length) break; - } - - if (matchedNode != null) - { - // XPath to the date column (B) - var dateNode = matchedNode.SelectSingleNode("td[1]"); - // XPath to the year column (D) - var yearNode = matchedNode.SelectSingleNode("td[3]"); - - var provider = CultureInfo.InvariantCulture; - var wrongFormat = "M/d/yyyy"; - //string rightFormat = "dd-MMMM-yyyy"; - - var dateString = dateNode.InnerText; - if (!yearNode.InnerText.Contains(" ")) dateString = dateString + "/" + yearNode.InnerText; - - dateString = dateString.Trim(); - - try - { - // Converting the birthdate from the wrong format to the right format WITH year - birthdate = DateTime.ParseExact(dateString, wrongFormat, provider); - } - catch (FormatException) - { - // Converting the birthdate from the wrong format to the right format WITHOUT year - birthdate = DateTime.ParseExact(dateString, "M/d", provider); - } - } - - // Business as usual - if (birthdate == default) - { - await ReplyAsync( - $"Sorry, I couldn't find **{searchName}**'s birthday date. They can add it at https://docs.google.com/forms/d/e/1FAIpQLSfUglZtJ3pyMwhRk5jApYpvqT3EtKmLBXijCXYNwHY-v-lKxQ/viewform !") - .DeleteAfterSeconds(30); - } - else - { - var date = birthdate.ToUnixTimestamp(); - var message = - $"**{searchName}**'s birthdate: __**{birthdate.ToString("dd MMMM yyyy", CultureInfo.InvariantCulture)}**__ " + - $"({(int)((DateTime.Now - birthdate).TotalDays / 365)}yo)"; - - await ReplyAsync(message).DeleteAfterTime(minutes: 3); - } - - await Context.Message.DeleteAfterTime(minutes: 3); - } - - #endregion - - #region Temperatures - - [Command("FtoC"), Priority(28)] - [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")] - public async Task FahrenheitToCelsius(float f) - { - await ReplyAsync($"{Context.User.Mention} {f}°F is {MathUtility.FahrenheitToCelsius(f)}°C."); - } - - [Command("CtoF"), Priority(28)] - [Summary("Converts a temperature in celsius to fahrenheit. Syntax : !ftoc temperature")] - public async Task CelsiusToFahrenheit(float c) - { - await ReplyAsync($"{Context.User.Mention} {c}°C is {MathUtility.CelsiusToFahrenheit(c)}°F"); - } - - #endregion - - #region Translate - - [Command("Translate"), HideFromHelp] - [Summary("Translate a message. Syntax : !translate messageId language")] - public async Task Translate(ulong messageId, string language = "en") - { - await Translate((await Context.Channel.GetMessageAsync(messageId)).Content, language); - } - - [Command("Translate"), HideFromHelp] - [Summary("Translate a message. Syntax : !translate text language")] - public async Task Translate(string text, string language = "en") - { - var msg = await ReplyAsync($"Here: "); - await Context.Message.DeleteAfterSeconds(seconds: 1); - await msg.DeleteAfterSeconds(seconds: 20); - } - - #endregion - - #region Currency - - [Command("CurrencyName"), Priority(29)] - [Summary("Get the name of a currency. Syntax : !currname USD")] - [Alias("currname")] - public async Task CurrencyName(string currency) - { - if (Context.HasAnyPingableMention()) - return; - var name = await CurrencyService.GetCurrencyName(currency); - if (name == string.Empty) - { - await Context.Message.ReplyAsync($"Sorry, I couldn't find the name of the currency **{currency}**."); - return; - } - await Context.Message.ReplyAsync($"The name of the currency **{currency.ToUpper()}** is **{name}**."); - } - - [Command("Currency"), HideFromHelp] - [Summary("Converts a currency. Syntax : !currency fromCurrency toCurrency")] - [Alias("curr")] - public async Task ConvertCurrency(string from, string to = "usd") - { - await ConvertCurrency(1, from, to); - } - - [Command("Currency"), Priority(29)] - [Summary("Converts a currency. Syntax : !currency amount fromCurrency toCurrency")] - [Alias("curr")] - public async Task ConvertCurrency(double amount, string from, string to = "usd") - { - if (Context.HasAnyPingableMention()) - { - // Only continue command if the user is replying to a message - if (!Context.IsReply()) - return; - // And that mention is only the author of the replied message - if (!Context.IsOnlyReplyingToAuthor()) - return; - } - - from = from.ToLower(); - to = to.ToLower(); - - // We check if both currencies are valid - bool fromValid = await CurrencyService.IsCurrency(from.ToLower()); - bool toValid = await CurrencyService.IsCurrency(to.ToLower()); - - // Check if valid - if (!fromValid || !toValid) - { - await Context.Message.ReplyAsync("One of the currencies provided is invalid."); - return; - } - - var response = await CurrencyService.GetConversion(to, from); - if (Math.Abs(response - (-1)) < 0.01) - { - await Context.Message.ReplyAsync("An error occured while converting the currency, the API may be down!"); - return; - } - - var totalAmount = Math.Round(amount * response, 2); - await Context.Message.ReplyAsync($"**{amount} {from.ToUpper()}** = **{totalAmount} {to.ToUpper()}**"); - } - - #endregion -} diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs deleted file mode 100644 index 1d2febd2..00000000 --- a/DiscordBot/Modules/UserSlashModule.cs +++ /dev/null @@ -1,544 +0,0 @@ -using System.Collections.Concurrent; -using Discord.Interactions; -using DiscordBot.Services; -using DiscordBot.Settings; - -namespace DiscordBot.Modules; - -// For commands that only require a single interaction, these can be done automatically and don't require complex setup or configuration. -// ie; A command that might just return the result of a service method such as Ping, or Welcome -public class UserSlashModule : InteractionModuleBase -{ - #region Dependency Injection - - public CommandHandlingService CommandHandlingService { get; set; } - public UserService UserService { get; set; } - public BotSettings BotSettings { get; set; } - public ILoggingService LoggingService { get; set; } - - #endregion - - #region Help - - [SlashCommand("help", "Shows available commands")] - private async Task Help(string search = "") - { - await Context.Interaction.DeferAsync(ephemeral: true); - - var helpEmbed = HelpEmbed(0, search); - if (helpEmbed.Item1 >= 0) - { - ComponentBuilder builder = new(); - builder.WithButton("Next Page", $"user_module_help_next:{0}"); - - await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true, - components: builder.Build()); - } - else - { - await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true); - } - } - - [ComponentInteraction("user_module_help_next:*")] - private async Task InteractionHelp(string pageString) - { - await Context.Interaction.DeferAsync(ephemeral: true); - - int page = int.Parse(pageString); - - var helpEmbed = HelpEmbed(page + 1); - ComponentBuilder builder = new(); - builder.WithButton("Next Page", $"user_module_help_next:{helpEmbed.Item1}"); - - await Context.Interaction.ModifyOriginalResponseAsync(msg => - { - msg.Components = builder.Build(); - msg.Embed = helpEmbed.Item2; - }); - } - - // Returns an embed with the help text for a module, if the page is outside the bounds (high) it will return to the first page. - private (int, Embed) HelpEmbed(int page, string search = "") - { - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = "User Module Commands"; - embedBuilder.Color = Color.LighterGrey; - - List helpMessages = null; - if (search == string.Empty) - { - helpMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); - - if (page >= helpMessages.Count) - page = 0; - else if (page < 0) - page = helpMessages.Count - 1; - - embedBuilder.WithFooter(text: $"Page {page + 1} of {helpMessages.Count}"); - embedBuilder.Description = helpMessages[page]; - } - else - { - // We need search results which we don't cache, so we don't want to provide a page number - page = -1; - helpMessages = CommandHandlingService.SearchForCommand(("UserModule", false, true, false), search); - if (helpMessages[0].Length > 0) - { - embedBuilder.WithFooter(text: $"Search results for {search}"); - embedBuilder.Description = helpMessages[0]; - } - else - { - embedBuilder.WithFooter(text: $"No results for {search}"); - embedBuilder.Description = "No commands found"; - } - } - - return (page, embedBuilder.Build()); - } - - #endregion - - [SlashCommand("welcome", "An introduction to the server!")] - public async Task SlashWelcome() - { - await Context.Interaction.RespondAsync(string.Empty, - embed: UserService.GetWelcomeEmbed(Context.User.Username), ephemeral: true); - } - - [SlashCommand("ping", "Bot latency")] - public async Task Ping() - { - await Context.Interaction.RespondAsync("Bot latency: ...", ephemeral: true); - await Context.Interaction.ModifyOriginalResponseAsync(m => - m.Content = $"Bot latency: {UserService.GetGatewayPing().ToString()}ms"); - } - - [SlashCommand("invite", "Returns the invite link for the server.")] - public async Task ReturnInvite() - { - await Context.Interaction.RespondAsync(text: BotSettings.Invite, ephemeral: true); - } - - #region Moderation - - [MessageCommand("Report Message")] - public async Task ReportMessage(IMessage reportedMessage) - { - if (reportedMessage.Author.Id == Context.User.Id) - { - await Context.Interaction.RespondAsync(text: "You can't report your own messages!", ephemeral: true); - return; - } - if (reportedMessage.Author.IsBot) // Don't report bots - { - await Context.Interaction.RespondAsync(text: "You can't report bot messages!", ephemeral: true); - return; - } - if (reportedMessage.Author.IsWebhook) // Don't report webhooks - { - await Context.Interaction.RespondAsync(text: "You can't report webhook messages!", ephemeral: true); - return; - } - await Context.Interaction.RespondWithModalAsync($"report_{reportedMessage.Id}"); - } - - // Defines the modal that will be sent. - public class ReportMessageModal : IModal - { - public string Title => "Report a message"; - - // Additional parameters can be specified to further customize the input. - [InputLabel("Reason")] - [ModalTextInput("report_reason", TextInputStyle.Paragraph, maxLength: 500)] - public string Reason { get; set; } - } - - // Responds to the modal. - [ModalInteraction("report_*")] - public async Task ModalResponse(ulong id, ReportMessageModal modal) - { - var reportedMessage = await Context.Channel.GetMessageAsync(id); - - var reportedMessageChannel = await Context.Guild.GetTextChannelAsync(BotSettings.ReportedMessageChannel.Id); - if (reportedMessageChannel == null) - return; - - var embed = new EmbedBuilder() - .WithColor(new Color(0xFF0000)) - .WithDescription(reportedMessage.Content) - .WithTimestamp(reportedMessage.Timestamp) - .WithFooter(footer => - { - footer - .WithText($"Reported by {Context.User.GetPreferredAndUsername()} • From channel {reportedMessage.Channel.Name}") - .WithIconUrl(Context.User.GetAvatarUrl()); - }) - .AddAuthor(reportedMessage.Author); - - embed.Description += $"\n\n***[Linkback]({reportedMessage.GetJumpUrl()})***"; - - if (reportedMessage.Attachments.Count > 0) - { - var attachments = reportedMessage.Attachments.Select(a => a.Url).ToList(); - string attachmentString = string.Empty; - for (int i = 0; i < attachments.Count; i++) - { - attachmentString += $"• {attachments[i]}"; - if (i < attachments.Count - 1) - attachmentString += "\n"; - } - embed.AddField("Attachments", attachmentString); - } - embed.AddField("Reason", modal.Reason); - - await reportedMessageChannel.SendMessageAsync(string.Empty, embed: embed.Build()); - await RespondAsync("Message has been reported.", ephemeral: true); - } - - #endregion // Moderation - - #region User Roles - - [SlashCommand("roles", "Give or Remove roles for yourself (Programmer, Artist, Designer, etc)")] - public async Task UserRoles() - { - await Context.Interaction.DeferAsync(ephemeral: true); - - ComponentBuilder builder = new(); - - foreach (var userRole in BotSettings.UserAssignableRoles.Roles) - { - builder.WithButton(userRole, $"user_role_add:{userRole}"); - } - - builder.Build(); - - await Context.Interaction.FollowupAsync(text: "Click any role that applies to you!", embed: null, - ephemeral: true, components: builder.Build()); - } - - [ComponentInteraction("user_role_add:*")] - public async Task UserRoleAdd(string role) - { - await Context.Interaction.DeferAsync(ephemeral: true); - - var user = Context.User as IGuildUser; - var guild = Context.Guild; - - // Try get the role from the guild - var roleObj = guild.Roles.FirstOrDefault(r => r.Name == role); - if (roleObj == null) - { - await Context.Interaction.ModifyOriginalResponseAsync(msg => - msg.Content = $"Failed to add role {role}, role not found."); - return; - } - // We make sure the role is in our UserAssignableRoles just in case - if (BotSettings.UserAssignableRoles.Roles.Contains(roleObj.Name)) - { - if (user.RoleIds.Contains(roleObj.Id)) - { - await user.RemoveRoleAsync(roleObj); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - msg.Content = $"{roleObj.Name} has been removed!"); - } - else - { - await user.AddRoleAsync(roleObj); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - msg.Content = $"You now have the {roleObj.Name} role!"); - } - } - } - - #endregion - - #region Duel System - - private static readonly ConcurrentDictionary _activeDuels = new ConcurrentDictionary(); - private static readonly Random _random = new Random(); - - private static readonly string[] _normalWinMessages = - { - "{winner} lands a solid hit on {loser} and wins the duel!", - "{winner} uses their sword to attack {loser}, but {loser} fails to dodge and {winner} wins!", - "{winner} outmaneuvers {loser} with a swift strike and claims victory!", - "{winner} blocks {loser}'s attack and counters with a decisive blow!", - "{winner} dodges {loser}'s clumsy swing and delivers the winning hit!", - "{winner} parries {loser}'s blade and strikes back to win the duel!", - "{winner} feints left, strikes right, and defeats {loser}!", - "{winner} overwhelms {loser} with superior technique and emerges victorious!" - }; - - [SlashCommand("duel", "Challenge another user to a duel!")] - public async Task Duel( - [Summary(description: "The user you want to duel")] IUser opponent, - [Summary(description: "Type of duel")] - [Choice("Normal", "normal")] - [Choice("Mute", "mute")] - string type = "normal") - { - // Prevent self-dueling - if (opponent.Id == Context.User.Id) - { - await Context.Interaction.RespondAsync("You cannot duel yourself!", ephemeral: true); - return; - } - - // Prevent dueling bots - if (opponent.IsBot) - { - await Context.Interaction.RespondAsync("You cannot duel a bot!", ephemeral: true); - return; - } - - // Check for active duel - string duelKey = $"{Context.User.Id}_{opponent.Id}"; - string reverseDuelKey = $"{opponent.Id}_{Context.User.Id}"; - - if (_activeDuels.ContainsKey(duelKey) || _activeDuels.ContainsKey(reverseDuelKey)) - { - await Context.Interaction.RespondAsync("There's already an active duel between you two!", ephemeral: true); - return; - } - - // Store the duel with both user IDs for timeout tracking - _activeDuels[duelKey] = (Context.User.Id, opponent.Id); - - var embed = new EmbedBuilder() - .WithColor(Color.Orange) - .WithTitle("⚔️ Duel Challenge!") - .WithDescription($"{Context.User.Mention} has challenged {opponent.Mention} to a duel!") - .WithFooter($"This challenge will expire in 60 seconds"); - - if (type == "mute") - { - embed.AddField("Risk", "The loser will be muted for 5 minutes."); - } - - var components = new ComponentBuilder() - .WithButton("⚔️ Accept", $"duel_accept:{duelKey}:{type}", ButtonStyle.Success) - .WithButton("🛡️ Refuse", $"duel_refuse:{duelKey}", ButtonStyle.Danger) - .WithButton("❌ Cancel", $"duel_cancel:{duelKey}", ButtonStyle.Secondary) - .Build(); - - await Context.Interaction.RespondAsync(embed: embed.Build(), components: components); - - // Store the message reference for timeout - var originalResponse = await Context.Interaction.GetOriginalResponseAsync(); - - // Auto-timeout after 60 seconds - _ = Task.Run(async () => - { - await Task.Delay(60000); // 60 seconds - if (_activeDuels.ContainsKey(duelKey)) - { - var (challengerId, opponentId) = _activeDuels[duelKey]; - _activeDuels.TryRemove(duelKey, out _); - - try - { - var challenger = await Context.Guild.GetUserAsync(challengerId); - var challengedUser = await Context.Guild.GetUserAsync(opponentId); - - string timeoutMessage = challengedUser != null - ? $"⏰ Duel challenge to {challengedUser.Mention} expired." - : "⏰ Duel challenge expired."; - - await originalResponse.ModifyAsync(msg => - { - msg.Content = string.Empty; - msg.Embed = new EmbedBuilder() - .WithColor(Color.LightGrey) - .WithDescription(timeoutMessage) - .Build(); - msg.Components = new ComponentBuilder().Build(); - }); - } - catch (Exception ex) - { - await LoggingService.LogChannelAndFile($"Failed to modify duel timeout message: {ex.Message}", ExtendedLogSeverity.Warning); - } - } - }); - } - - [ComponentInteraction("duel_accept:*:*")] - public async Task DuelAccept(string duelKey, string type) - { - // Extract user IDs from the duel key - var userIds = duelKey.Split('_'); - if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) - { - await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); - return; - } - - // Only the challenged user can accept - if (Context.User.Id != opponentId) - { - await Context.Interaction.RespondAsync("Only the challenged user can accept this duel!", ephemeral: true); - return; - } - - // Check if duel is still active - if (!_activeDuels.ContainsKey(duelKey)) - { - await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); - return; - } - - // Remove from active duels - _activeDuels.TryRemove(duelKey, out _); - - await Context.Interaction.DeferAsync(); - - // Get users - var challenger = await Context.Guild.GetUserAsync(challengerId); - var opponent = await Context.Guild.GetUserAsync(opponentId); - - if (challenger == null || opponent == null) - { - await Context.Interaction.FollowupAsync("One of the duel participants is no longer available!"); - return; - } - - // Randomly select winner (50/50) - bool challengerWins = _random.Next(2) == 0; - var winner = challengerWins ? challenger : opponent; - var loser = challengerWins ? opponent : challenger; - if (type == "mute") - { - var isChallengerAdmin = challenger.GuildPermissions.Has(GuildPermission.Administrator); - var isOpponentAdmin = opponent.GuildPermissions.Has(GuildPermission.Administrator); - if (isChallengerAdmin || isOpponentAdmin) - { - // Unfair advantages are unfair. Also, bot can't mute admins. Remove the stakes. - type = "friendly"; - } - } - - // Generate flavor message - string flavorMessage = _normalWinMessages[_random.Next(_normalWinMessages.Length)]; - flavorMessage = flavorMessage.Replace("{winner}", winner.Mention).Replace("{loser}", loser.Mention); - - var resultEmbed = new EmbedBuilder() - .WithColor(Color.Gold) - .WithTitle("⚔️ Duel Results!") - .WithDescription(flavorMessage) - .AddField("Winner", winner.Mention, inline: true) - .Build(); - - await Context.Interaction.ModifyOriginalResponseAsync(msg => - { - msg.Embed = resultEmbed; - msg.Components = new ComponentBuilder().Build(); - }); - - // Handle mute duel using Discord timeout - if (type == "mute") - { - try - { - var guildLoser = loser as IGuildUser; - if (guildLoser != null) - { - // Use Discord's timeout feature for 5 minutes - await guildLoser.SetTimeOutAsync(TimeSpan.FromMinutes(5), new RequestOptions { AuditLogReason = "Lost /duel" }); - await Context.Interaction.FollowupAsync($"💀 {loser.Mention} has been timed out for 5 minutes as the duel loser!", ephemeral: false); - } - } - catch (Exception ex) - { - await LoggingService.LogChannelAndFile($"Failed to timeout the loser of the duel: {ex.Message}", ExtendedLogSeverity.Error); - await Context.Interaction.FollowupAsync("Failed to timeout the loser.", ephemeral: false); - } - } - } - - [ComponentInteraction("duel_refuse:*")] - public async Task DuelRefuse(string duelKey) - { - // Extract user IDs from the duel key - var userIds = duelKey.Split('_'); - if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) - { - await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); - return; - } - - // Only the challenged user can refuse - if (Context.User.Id != opponentId) - { - await Context.Interaction.RespondAsync("Only the challenged user can refuse this duel!", ephemeral: true); - return; - } - - // Check if duel is still active - if (!_activeDuels.ContainsKey(duelKey)) - { - await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); - return; - } - - // Remove from active duels - _activeDuels.TryRemove(duelKey, out _); - - // Edit the embed to show refusal instead of deleting - await Context.Interaction.DeferAsync(); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - { - msg.Content = string.Empty; - msg.Embed = new EmbedBuilder() - .WithColor(Color.LightGrey) - .WithDescription("🛡️ Duel challenge was refused.") - .Build(); - msg.Components = new ComponentBuilder().Build(); - }); - } - - [ComponentInteraction("duel_cancel:*")] - public async Task DuelCancel(string duelKey) - { - // Extract user IDs from the duel key - var userIds = duelKey.Split('_'); - if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) - { - await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); - return; - } - - // Only the challenger can cancel - if (Context.User.Id != challengerId) - { - await Context.Interaction.RespondAsync("Only the challenger can cancel this duel!", ephemeral: true); - return; - } - - // Check if duel is still active - if (!_activeDuels.ContainsKey(duelKey)) - { - await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); - return; - } - - // Remove from active duels - _activeDuels.TryRemove(duelKey, out _); - - // Edit the embed to show cancellation - await Context.Interaction.DeferAsync(); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - { - msg.Content = string.Empty; - msg.Embed = new EmbedBuilder() - .WithColor(Color.LightGrey) - .WithDescription("❌ Duel challenge was cancelled by the challenger.") - .Build(); - msg.Components = new ComponentBuilder().Build(); - }); - } - - #endregion -} diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/Utils/AirportModule.cs similarity index 77% rename from DiscordBot/Modules/AirportModule.cs rename to DiscordBot/Modules/Utils/AirportModule.cs index 5a35ae31..0d543cc8 100644 --- a/DiscordBot/Modules/AirportModule.cs +++ b/DiscordBot/Modules/Utils/AirportModule.cs @@ -1,9 +1,8 @@ using Discord.Commands; -using DiscordBot.Modules.Weather; using DiscordBot.Services; using DiscordBot.Settings; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Utils; // Allows UserModule !help to show commands from this module [Group("UserModule"), Alias("")] @@ -11,25 +10,25 @@ public class AirportModule : ModuleBase { #region Dependency Injection - public AirportService AirportService { get; set; } - public BotSettings Settings { get; set; } + public AirportService AirportService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; // Needed to locate cities lon/lat easier - public WeatherService WeatherService { get; set; } + public WeatherService WeatherService { get; set; } = null!; #endregion // Dependency Injection #region API Results - + public class FlightResults { - public string iata { get; set; } - public string fs { get; set; } - public string name { get; set; } + public string iata { get; set; } = string.Empty; + public string fs { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; } public class FlightRoot { - public List data { get; set; } + public List data { get; set; } = []; } #endregion // API Results @@ -41,19 +40,19 @@ public class FlightRoot public async Task FlyTo(string from, string to) { // Make sure command is in Bot-Commands or OffTopic - if (Context.Channel.Id != Settings.BotCommandsChannel.Id && Context.Channel.Id != Settings.GeneralChannel.Id) + if (Context.Channel.Id != Settings.Channels.BotCommands.Id && Context.Channel.Id != Settings.Channels.General.Id) { - await ReplyAsync($"Command can only be used in <#{Settings.BotCommandsChannel.Id}> or <#{Settings.GeneralChannel.Id}>.").DeleteAfterSeconds(5f); - await Context.Message.DeleteAfterSeconds(2f); + await (ReplyAsync($"Command can only be used in <#{Settings.Channels.BotCommands.Id}> or <#{Settings.Channels.General.Id}>.").DeleteAfterSeconds(5f) ?? Task.CompletedTask); + await (Context.Message.DeleteAfterSeconds(2f) ?? Task.CompletedTask); return; } - + EmbedBuilder embed = new(); embed.Title = "Flight Finder"; embed.Description = "Finding cities"; var msg = await ReplyAsync(string.Empty, false, embed.Build()); - + // Use Weather API to get lon/lat of cities var fromCity = await GetCity(from, embed, msg); if (fromCity == null) @@ -61,7 +60,7 @@ public async Task FlyTo(string from, string to) var toCity = await GetCity(to, embed, msg); if (toCity == null) return; - + // Find closest Airport using AirLabs API embed.Description = "Finding airports"; await msg.ModifyAsync(x => x.Embed = embed.Build()); @@ -72,11 +71,11 @@ public async Task FlyTo(string from, string to) var toAirport = await GetAirport(toCity, embed, msg); if (toAirport == null) return; - + // Find cheapest flight using GetFlightInfo embed.Description = $"Searching {fromAirport.name} to {toAirport.name}"; await msg.ModifyAsync(x => x.Embed = embed.Build()); - + var daysUntilTuesday = (int)DateTime.Now.DayOfWeek - 2; if (daysUntilTuesday < 0) daysUntilTuesday += 7; @@ -86,12 +85,12 @@ public async Task FlyTo(string from, string to) { embed.Description += "\\nNo flights found, sorry."; await msg.ModifyAsync(x => x.Embed = embed.Build()); - await msg.DeleteAfterSeconds(30f); + await (msg.DeleteAfterSeconds(30f) ?? Task.CompletedTask); return; } var flight = flights[0]; - + var itinerary = flight.itineraries.First(); var numberOfStops = itinerary.segments.Count - 1; var departTime = itinerary.segments.First().departure; @@ -106,7 +105,7 @@ public async Task FlyTo(string from, string to) // embed.Description += // $"\nSeats remaining: {flight.numberOfBookableSeats}, Bags: {(flight.pricingOptions.includedCheckedBagsOnly ? "Y" : "N")}, OneWay: {(flight.oneWay ? "Y" : "N")}"; embed.Description += $"\nDepart: {departTime.at:dd/MM/yy HH:MM}, Arrive: {arriveTime.at:dd/MM/yy HH:MM}"; - + // string price = $"Base: {flight.price.@base}"; // foreach (var fee in flight.price.fees) // { @@ -121,35 +120,35 @@ public async Task FlyTo(string from, string to) } #endregion // Commands - + #region Utility Methods - - private async Task GetCity(string city, EmbedBuilder embed, IUserMessage msg) + + private async Task GetCity(string city, EmbedBuilder embed, IUserMessage msg) { var cityResult = await WeatherService.GetWeather(city); if (cityResult == null) { embed.Description += $"\n{city} could not be found."; await msg.ModifyAsync(x => x.Embed = embed.Build()); - await msg.DeleteAfterSeconds(10f); + await (msg.DeleteAfterSeconds(10f) ?? Task.CompletedTask); return null; } return cityResult; } - - private async Task GetAirport(WeatherContainer.Result weather, EmbedBuilder embed, IUserMessage msg) + + private async Task GetAirport(WeatherContainer.Result weather, EmbedBuilder embed, IUserMessage msg) { var airportResult = await AirportService.GetClosestAirport(weather.coord.Lat, weather.coord.Lon); if (airportResult == null) { embed.Description += $"\nAirport near {weather.name} ({weather.sys.country}) could not be found."; await msg.ModifyAsync(x => x.Embed = embed.Build()); - await msg.DeleteAfterSeconds(10f); + await (msg.DeleteAfterSeconds(10f) ?? Task.CompletedTask); return null; } return airportResult; } #endregion // Utility Methods - + } \ No newline at end of file diff --git a/DiscordBot/Modules/Utils/ConvertModule.cs b/DiscordBot/Modules/Utils/ConvertModule.cs new file mode 100644 index 00000000..293bc7e5 --- /dev/null +++ b/DiscordBot/Modules/Utils/ConvertModule.cs @@ -0,0 +1,102 @@ +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Services; +using DiscordBot.Utils; + +namespace DiscordBot.Modules.Utils; + +[Group("UserModule"), Alias("")] +public class ConvertModule : ModuleBase +{ + public CurrencyService CurrencyService { get; set; } = null!; + + [Command("FtoC"), Priority(28)] + [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")] + public async Task FahrenheitToCelsius(float f) + { + await ReplyAsync($"{Context.User.Mention} {f}°F is {MathUtility.FahrenheitToCelsius(f)}°C."); + } + + [Command("CtoF"), Priority(28)] + [Summary("Converts a temperature in celsius to fahrenheit. Syntax : !ftoc temperature")] + public async Task CelsiusToFahrenheit(float c) + { + await ReplyAsync($"{Context.User.Mention} {c}°C is {MathUtility.CelsiusToFahrenheit(c)}°F"); + } + + [Command("Translate"), HideFromHelp] + [Summary("Translate a message. Syntax : !translate messageId language")] + public async Task Translate(ulong messageId, string language = "en") + { + await Translate((await Context.Channel.GetMessageAsync(messageId)).Content, language); + } + + [Command("Translate"), HideFromHelp] + [Summary("Translate a message. Syntax : !translate text language")] + public async Task Translate(string text, string language = "en") + { + var msg = await ReplyAsync($"Here: "); + await Context.Message.DeleteAfterSeconds(seconds: 1)!; + await msg.DeleteAfterSeconds(seconds: 20)!; + } + + [Command("CurrencyName"), Priority(29)] + [Summary("Get the name of a currency. Syntax : !currname USD")] + [Alias("currname")] + public async Task CurrencyName(string currency) + { + if (Context.HasAnyPingableMention()) + return; + var name = await CurrencyService.GetCurrencyName(currency); + if (name == string.Empty) + { + await Context.Message.ReplyAsync($"Sorry, I couldn't find the name of the currency **{currency}**."); + return; + } + await Context.Message.ReplyAsync($"The name of the currency **{currency.ToUpper()}** is **{name}**."); + } + + [Command("Currency"), HideFromHelp] + [Summary("Converts a currency. Syntax : !currency fromCurrency toCurrency")] + [Alias("curr")] + public async Task ConvertCurrency(string from, string to = "usd") + { + await ConvertCurrency(1, from, to); + } + + [Command("Currency"), Priority(29)] + [Summary("Converts a currency. Syntax : !currency amount fromCurrency toCurrency")] + [Alias("curr")] + public async Task ConvertCurrency(double amount, string from, string to = "usd") + { + if (Context.HasAnyPingableMention()) + { + if (!Context.IsReply()) + return; + if (!Context.IsOnlyReplyingToAuthor()) + return; + } + + from = from.ToLower(); + to = to.ToLower(); + + bool fromValid = await CurrencyService.IsCurrency(from.ToLower()); + bool toValid = await CurrencyService.IsCurrency(to.ToLower()); + + if (!fromValid || !toValid) + { + await Context.Message.ReplyAsync("One of the currencies provided is invalid."); + return; + } + + var response = await CurrencyService.GetConversion(to, from); + if (Math.Abs(response - (-1)) < 0.01) + { + await Context.Message.ReplyAsync("An error occured while converting the currency, the API may be down!"); + return; + } + + var totalAmount = Math.Round(amount * response, 2); + await Context.Message.ReplyAsync($"**{amount} {from.ToUpper()}** = **{totalAmount} {to.ToUpper()}**"); + } +} diff --git a/DiscordBot/Modules/Utils/SearchModule.cs b/DiscordBot/Modules/Utils/SearchModule.cs new file mode 100644 index 00000000..2c3a14c0 --- /dev/null +++ b/DiscordBot/Modules/Utils/SearchModule.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Text; +using Discord.Commands; +using DiscordBot.Services; +using DiscordBot.Settings; +using DiscordBot.Attributes; + +namespace DiscordBot.Modules.Utils; + +[Group("UserModule"), Alias("")] +public class SearchModule : ModuleBase +{ + public ILoggingService LoggingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; + public UpdateService UpdateService { get; set; } = null!; + public SearchService SearchService { get; set; } = null!; + + [Command("Search"), Priority(25)] + [Summary("Searches DuckDuckGo for results. Syntax: !search c# lambda help")] + [Alias("s", "ddg")] + public async Task SearchResults(params string[] messages) + { + StringBuilder sb = new(); + foreach (var msg in messages) + sb.Append(msg).Append(" "); + await SearchResults(sb.ToString()); + } + + [Command("Search"), HideFromHelp] + [Summary("Searches DuckDuckGo for web results. Syntax : !search \"query\" resNum site")] + [Alias("s", "ddg")] + public async Task SearchResults(string query, uint resNum = 3, string site = "") + { + var results = SearchService.SearchDuckDuckGo(query, resNum, site); + + var resultTitle = string.Empty; + for (int i = 0; i < results.Count; i++) + { + resultTitle += $"{i + 1}. {results[i].Title} [__Read More__]({results[i].Url})\n"; + } + + var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+'); + if (site != string.Empty) searchQuery += "+site:" + site; + + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = $"Q: {WebUtility.UrlDecode(query)}"; + embedBuilder.AddField("Search Query", searchQuery); + embedBuilder.AddField("Results", resultTitle.Length > 0 ? resultTitle : "No results found.", inline: false); + embedBuilder.Color = new Color(81, 50, 169); + embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from DuckDuckGo."); + + await ReplyAsync(embed: embedBuilder.Build()); + } + + [Command("Manual"), Priority(8)] + [Summary("Searches Unity3D manual for results. Syntax : !manual \"query\"")] + public async Task SearchManual(params string[] queries) + { + var pages = await UpdateService.GetManualDatabase(); + var query = string.Join(" ", queries); + var match = SearchService.FindBestMatch(query, pages!, "https://docs.unity3d.com/Manual"); + + if (match != null) + { + var url = $"{match.BaseUrl}/{match.PageName}.html"; + + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = $"Found {match.PageName}"; + embedBuilder.Description = $"**{match.Title}** - [Read More..]({url})"; + embedBuilder.Color = new Color(81, 50, 169); + embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); + var message = await ReplyAsync(embed: embedBuilder.Build()); + + var description = SearchService.FetchPageDescription(url, "//h1", "following-sibling::p"); + if (description != null) + { + embedBuilder.WithDescription($"**Description:** {description}\n[Read More..]({url})"); + await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); + } + } + else + await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10)!; + } + + [Command("Doc"), Priority(9)] + [Summary("Searches Unity3D API for results. Syntax : !api \"query\"")] + [Alias("ref", "reference", "api", "docs")] + public async Task SearchApi(params string[] queries) + { + var pages = await UpdateService.GetApiDatabase(); + var query = string.Join(" ", queries); + var match = SearchService.FindBestMatch(query, pages!, "https://docs.unity3d.com/ScriptReference"); + + if (match != null) + { + var url = $"{match.BaseUrl}/{match.PageName}.html"; + + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = $"Found {match.PageName}"; + embedBuilder.Description = $"**{match.Title}** - [Read More..]({url})"; + embedBuilder.Color = new Color(81, 50, 169); + embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); + var message = await ReplyAsync(embed: embedBuilder.Build()); + + var description = SearchService.FetchPageDescription(url, "//h3[contains(text(), 'Description')]", "following-sibling::p"); + var manualLink = SearchService.FetchManualLink(url); + + string descriptionString = description != null + ? $"**Description:** {description}\n[Read More..]({url})" + : string.Empty; + string manualLinkString = manualLink != null + ? $"\n**Manual:** {manualLink}" + : string.Empty; + + embedBuilder.WithDescription(descriptionString + manualLinkString); + await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); + } + else + await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10)!; + } + + [Command("Wiki"), Priority(26)] + [Summary("Searches Wikipedia. Syntax : !wiki \"query\"")] + [Alias("wikipedia")] + public async Task SearchWikipedia([Remainder] string query) + { + var article = await UpdateService.DownloadWikipediaArticle(query); + + if (article.url == null) + { + await ReplyAsync($"No Articles for \"{query}\" were found."); + return; + } + + await ReplyAsync(embed: GetWikipediaEmbed(article.name!, article.extract!, article.url!)); + } + + private Embed GetWikipediaEmbed(string subject, string articleExtract, string articleUrl) + { + var builder = new EmbedBuilder() + .WithTitle($"Wikipedia | {subject}") + .WithDescription($"{articleExtract}") + .WithUrl(articleUrl) + .WithColor(new Color(0x33CC00)); + return builder.Build(); + } +} diff --git a/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs b/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs new file mode 100644 index 00000000..dde28698 --- /dev/null +++ b/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs @@ -0,0 +1,129 @@ +using Newtonsoft.Json; + +namespace DiscordBot.Modules.Utils.Weather; + +#region Weather Results + +#pragma warning disable 0649 +// ReSharper disable InconsistentNaming +public class WeatherContainer +{ + public class Coord + { + public double Lon { get; set; } + public double Lat { get; set; } + } + + public class Weather + { + public int id { get; set; } + [JsonProperty("main")] public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + } + + public class Main + { + public float Temp { get; set; } + [JsonProperty("feels_like")] public double Feels { get; set; } + [JsonProperty("temp_min")] public double Min { get; set; } + [JsonProperty("temp_max")] public double Max { get; set; } + public int Pressure { get; set; } + public int Humidity { get; set; } + } + + public class Wind + { + public double Speed { get; set; } + public int Deg { get; set; } + } + + public class Clouds + { + public int all { get; set; } + } + + public class Rain + { + [JsonProperty("1h")] public double Rain1h { get; set; } + [JsonProperty("3h")] public double Rain3h { get; set; } + } + + public class Snow + { + [JsonProperty("1h")] public double Snow1h { get; set; } + [JsonProperty("3h")] public double Snow3h { get; set; } + } + + public class Sys + { + public int type { get; set; } + public int id { get; set; } + public double message { get; set; } + public string country { get; set; } = string.Empty; + public int sunrise { get; set; } + public int sunset { get; set; } + } + + public class Result + { + public Coord coord { get; set; } = null!; + public List weather { get; set; } = []; + public string @base { get; set; } = string.Empty; + public Main main { get; set; } = null!; + public int visibility { get; set; } + public Wind wind { get; set; } = null!; + public Clouds clouds { get; set; } = null!; + public Rain rain { get; set; } = null!; + public Snow snow { get; set; } = null!; + public int dt { get; set; } + public Sys sys { get; set; } = null!; + public int timezone { get; set; } + public int id { get; set; } + public string name { get; set; } = string.Empty; + public int cod { get; set; } + } +} + +#endregion +#region Pollution Results + +public class PollutionContainer +{ + public class Coord + { + public double lon { get; set; } + public double lat { get; set; } + } + public class Main + { + public int aqi { get; set; } + } + public class Components + { + [JsonProperty("co")] public double CarbonMonoxide { get; set; } + [JsonProperty("no")] public double NitrogenMonoxide { get; set; } + [JsonProperty("no2")] public double NitrogenDioxide { get; set; } + [JsonProperty("o3")] public double Ozone { get; set; } + [JsonProperty("so2")] public double SulphurDioxide { get; set; } + [JsonProperty("pm2_5")] public double FineParticles { get; set; } + [JsonProperty("pm10")] public double CoarseParticulate { get; set; } + [JsonProperty("nh3")] public double Ammonia { get; set; } + } + + public class List + { + public Main main { get; set; } = null!; + public Components components { get; set; } = null!; + public int dt { get; set; } + } + public class Result + { + public Coord coord { get; set; } = null!; + public List list { get; set; } = []; + } +} + +// ReSharper restore InconsistentNaming +#pragma warning restore 0649 +#endregion \ No newline at end of file diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Utils/Weather/WeatherModule.cs similarity index 79% rename from DiscordBot/Modules/Weather/WeatherModule.cs rename to DiscordBot/Modules/Utils/Weather/WeatherModule.cs index 4da268fd..a4abfe1f 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Utils/Weather/WeatherModule.cs @@ -1,10 +1,9 @@ using Discord.Commands; using DiscordBot.Attributes; -using DiscordBot.Modules.Weather; using DiscordBot.Services; using Newtonsoft.Json; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Utils.Weather; // https://openweathermap.org/current#call // Allows UserModule !help to show commands from this module @@ -12,12 +11,12 @@ namespace DiscordBot.Modules; public class WeatherModule : ModuleBase { #region Dependency Injection - - public WeatherService WeatherService { get; set; } - public UserExtendedService UserExtendedService { get; set; } - + + public WeatherService WeatherService { get; set; } = null!; + public UserExtendedService UserExtendedService { get; set; } = null!; + #endregion - + private List AQI_Index = new List() {"Invalid", "Good", "Fair", "Moderate", "Poor", "Very Poor"}; @@ -31,15 +30,15 @@ public async Task WeatherHelp() .WithDescription( "If the city isn't correct you will need to include the correct [city codes](https://www.iso.org/obp/ui/#search).\n**Example Usage**: *!Weather Wellington, UK*"); await Context.Message.DeleteAsync(); - await ReplyAsync(embed: builder.Build()).DeleteAfterSeconds(seconds: 30); + await ReplyAsync(embed: builder.Build()).DeleteAfterSeconds(seconds: 30)!; } #region Temperature - - private async Task TemperatureEmbed(string city, string replaceCityWith = "") + + private async Task TemperatureEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); + if (!await IsResultsValid(res) || res is null) return null; EmbedBuilder builder = new EmbedBuilder() @@ -50,16 +49,16 @@ private async Task TemperatureEmbed(string city, string replaceCit return builder; } - + [Command("Temperature"), HideFromHelp] [Summary("Attempts to provide the temperature of the user provided.")] [Alias("temp"), Priority(20)] - public async Task Temperature(IUser user = null) + public async Task Temperature(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await TemperatureEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -68,7 +67,7 @@ public async Task Temperature(IUser user = null) await ReplyAsync(embed: builder.Build()); } - + [Command("Temperature")] [Summary("Attempts to provide the temperature of the city provided.")] [Alias("temp"), Priority(20)] @@ -80,30 +79,30 @@ public async Task Temperature(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Temperature #region Weather - - private async Task WeatherEmbed(string city, string replaceCityWith = "") + + private async Task WeatherEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); + if (!await IsResultsValid(res) || res is null) return null; string extraInfo = string.Empty; - + DateTime sunrise = DateTime.UnixEpoch.AddSeconds(res.sys.sunrise) .AddSeconds(res.timezone); DateTime sunset = DateTime.UnixEpoch.AddSeconds(res.sys.sunset) .AddSeconds(res.timezone); - + // Sun rise/set if (res.sys.sunrise > 0) extraInfo += $"Sunrise **{sunrise:hh\\:mmtt}**, "; - if (res.sys.sunrise > 0) + if (res.sys.sunset > 0) extraInfo += $"Sunset **{sunset:hh\\:mmtt}**\n"; - + if (res.main.Temp > 0 && res.rain != null) { if (res.rain.Rain3h > 0) @@ -128,18 +127,18 @@ private async Task WeatherEmbed(string city, string replaceCityWit .WithFooter( $"{res.clouds.all}% cloud cover with {GetWindDirection((float)res.wind.Deg)} {Math.Round((res.wind.Speed * 60f * 60f) / 1000f, 2)} km/h winds & {res.main.Humidity}% humidity.") .WithColor(GetColour(res.main.Temp)); - + return builder; } - + [Command("Weather"), HideFromHelp, Priority(20)] [Summary("Attempts to provide the weather of the user provided.")] - public async Task CurentWeather(IUser user = null) + public async Task CurentWeather(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await WeatherEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -159,21 +158,23 @@ public async Task CurentWeather(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Weather #region Pollution - private async Task PollutionEmbed(string city, string replaceCityWith = "") + private async Task PollutionEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); + if (!await IsResultsValid(res) || res is null) return null; // We can't really combine the call as having WeatherResults helps with other details - PollutionContainer.Result polResult = + PollutionContainer.Result? polResult = await WeatherService.GetPollution(Math.Round(res.coord.Lon, 4), Math.Round(res.coord.Lat, 4)); + if (polResult is null) + return null; var comp = polResult.list[0].components; double combined = comp.CarbonMonoxide + comp.NitrogenMonoxide + comp.NitrogenDioxide + comp.Ozone + @@ -211,12 +212,12 @@ private async Task PollutionEmbed(string city, string replaceCityW [Command("Pollution"), HideFromHelp, Priority(21)] [Summary("Attempts to provide the pollution conditions of the user provided.")] - public async Task Pollution(IUser user = null) + public async Task Pollution(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await PollutionEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -225,7 +226,7 @@ public async Task Pollution(IUser user = null) await ReplyAsync(embed: builder.Build()); } - + [Command("Pollution"), Priority(21)] [Summary("Attempts to provide the pollution conditions of the city provided.")] public async Task Pollution(params string[] city) @@ -236,15 +237,15 @@ public async Task Pollution(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Pollution #region Time - - private async Task TimeEmbed(string city, string replaceCityWith = "") + + private async Task TimeEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); + if (!await IsResultsValid(res) || res is null) return null; var timezone = res.timezone / 3600; @@ -256,15 +257,15 @@ private async Task TimeEmbed(string city, string replaceCityWith = return builder; } - + [Command("Time"), HideFromHelp, Priority(22)] [Summary("Attempts to provide the time of the user provided.")] - public async Task Time(IUser user = null) + public async Task Time(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await TimeEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -273,7 +274,7 @@ public async Task Time(IUser user = null) await ReplyAsync(embed: builder.Build()); } - + [Command("Time"), Priority(22)] [Summary("Attempts to provide the time of the city/location provided.")] public async Task Time(params string[] city) @@ -284,9 +285,9 @@ public async Task Time(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Time - + #region Utility Methods private async Task IsResultsValid(T res) @@ -313,18 +314,18 @@ private Color GetColour(float temp) _ => new Color(255, 0, 0) }; } - + private async Task DoesUserHaveDefaultCity(IUser user) { // If they do, return true if (await UserExtendedService.DoesUserHaveDefaultCity(user)) return true; - + // Otherwise respond and return false var uname = user.GetUserPreferredName(); await ReplyAsync($"User {uname} does not have a default city set."); return false; } - + private static string GetWindDirection(float windDeg) { if (windDeg < 22.5) @@ -345,6 +346,44 @@ private static string GetWindDirection(float windDeg) return "NW"; return "N"; } - + #endregion Utility Methods + + #region City Settings + + [Command("SetCity"), Priority(100)] + [Alias("SetDefaultCity")] + [Summary("Set 'Default City' which can be used by various commands.")] + public async Task SetDefaultCity(params string[] city) + { + var uname = Context.User.GetUserPreferredName(); + var fullCityName = string.Join(" ", city); + var (exists, result) = await WeatherService.CityExists(fullCityName); + if (!exists || result is null) + { + await ReplyAsync($"Sorry, {uname}, but I couldn't find a city with that name.").DeleteAfterSeconds(30)!; + await Context.Message.DeleteAsync(); + return; + } + await UserExtendedService.SetUserDefaultCity(Context.User, result.name); + await ReplyAsync($"{uname}, your default city has been set to {result.name}."); + } + + [Command("RemoveCity"), Priority(100)] + [Alias("RemoveDefaultCity")] + [Summary("Remove 'Default City' which can be used by various commands.")] + public async Task RemoveDefaultCity() + { + var uname = Context.User.GetUserPreferredName(); + if (!await UserExtendedService.DoesUserHaveDefaultCity(Context.User)) + { + await ReplyAsync($"{uname}, you don't have a default city set.").DeleteAfterSeconds(30)!; + await Context.Message.DeleteAsync(); + return; + } + await UserExtendedService.RemoveUserDefaultCity(Context.User); + await ReplyAsync($"{uname}, your default city has been removed."); + } + + #endregion City Settings } diff --git a/DiscordBot/Modules/Weather/WeatherContainers.cs b/DiscordBot/Modules/Weather/WeatherContainers.cs deleted file mode 100644 index 1f5d351c..00000000 --- a/DiscordBot/Modules/Weather/WeatherContainers.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Newtonsoft.Json; - -namespace DiscordBot.Modules.Weather; - - #region Weather Results - -#pragma warning disable 0649 - // ReSharper disable InconsistentNaming - public class WeatherContainer - { - public class Coord - { - public double Lon { get; set; } - public double Lat { get; set; } - } - - public class Weather - { - public int id { get; set; } - [JsonProperty("main")] public string Name { get; set; } - public string Description { get; set; } - public string Icon { get; set; } - } - - public class Main - { - public float Temp { get; set; } - [JsonProperty("feels_like")] public double Feels { get; set; } - [JsonProperty("temp_min")] public double Min { get; set; } - [JsonProperty("temp_max")] public double Max { get; set; } - public int Pressure { get; set; } - public int Humidity { get; set; } - } - - public class Wind - { - public double Speed { get; set; } - public int Deg { get; set; } - } - - public class Clouds - { - public int all { get; set; } - } - - public class Rain - { - [JsonProperty("1h")] public double Rain1h { get; set; } - [JsonProperty("3h")] public double Rain3h { get; set; } - } - - public class Snow - { - [JsonProperty("1h")] public double Snow1h { get; set; } - [JsonProperty("3h")] public double Snow3h { get; set; } - } - - public class Sys - { - public int type { get; set; } - public int id { get; set; } - public double message { get; set; } - public string country { get; set; } - public int sunrise { get; set; } - public int sunset { get; set; } - } - - public class Result - { - public Coord coord { get; set; } - public List weather { get; set; } - public string @base { get; set; } - public Main main { get; set; } - public int visibility { get; set; } - public Wind wind { get; set; } - public Clouds clouds { get; set; } - public Rain rain { get; set; } - public Snow snow { get; set; } - public int dt { get; set; } - public Sys sys { get; set; } - public int timezone { get; set; } - public int id { get; set; } - public string name { get; set; } - public int cod { get; set; } - } - } - - #endregion - #region Pollution Results - - public class PollutionContainer - { - public class Coord - { - public double lon { get; set; } - public double lat { get; set; } - } - public class Main - { - public int aqi { get; set; } - } - public class Components - { - [JsonProperty("co")] public double CarbonMonoxide { get; set; } - [JsonProperty("no")] public double NitrogenMonoxide { get; set; } - [JsonProperty("no2")] public double NitrogenDioxide { get; set; } - [JsonProperty("o3")] public double Ozone { get; set; } - [JsonProperty("so2")] public double SulphurDioxide { get; set; } - [JsonProperty("pm2_5")] public double FineParticles { get; set; } - [JsonProperty("pm10")] public double CoarseParticulate { get; set; } - [JsonProperty("nh3")] public double Ammonia { get; set; } - } - - public class List - { - public Main main { get; set; } - public Components components { get; set; } - public int dt { get; set; } - } - public class Result - { - public Coord coord { get; set; } - public List list { get; set; } - } - } - - // ReSharper restore InconsistentNaming -#pragma warning restore 0649 - #endregion \ No newline at end of file diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index f0f22093..e5174511 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -2,9 +2,7 @@ using Discord.Commands; using Discord.Interactions; using Discord.WebSocket; -using DiscordBot.Service; using DiscordBot.Services; -using DiscordBot.Services.Tips; using DiscordBot.Settings; using DiscordBot.Utils; using Microsoft.Extensions.DependencyInjection; @@ -14,26 +12,27 @@ namespace DiscordBot; public class Program { - private bool _isInitialized = false; + private int _isInitialized = 0; - private static Rules _rules; - private static BotSettings _settings; - private static UserSettings _userSettings; - private DiscordSocketClient _client; - private CommandHandlingService _commandHandlingService; + private static Rules _rules = null!; + private static BotSettings _settings = null!; + private static UserSettings _userSettings = null!; + private DiscordSocketClient _client = null!; - private CommandService _commandService; - private InteractionService _interactionService; - private IServiceProvider _services; + private CommandService _commandService = null!; + private InteractionService _interactionService = null!; + private IServiceProvider _services = null!; - private UnityHelpService _unityHelpService; - private RecruitService _recruitService; + private readonly CancellationTokenSource _cts = new(); public static void Main(string[] args) => new Program().MainAsync().GetAwaiter().GetResult(); private async Task MainAsync() { + Console.CancelKeyPress += (_, e) => { e.Cancel = true; _cts.Cancel(); }; + AppDomain.CurrentDomain.ProcessExit += (_, _) => _cts.Cancel(); + DeserializeSettings(); _client = new DiscordSocketClient(new DiscordSocketConfig @@ -41,7 +40,12 @@ private async Task MainAsync() LogLevel = LogSeverity.Verbose, AlwaysDownloadUsers = true, MessageCacheSize = 1024, - GatewayIntents = GatewayIntents.All, + GatewayIntents = GatewayIntents.Guilds + | GatewayIntents.GuildMembers + | GatewayIntents.GuildMessages + | GatewayIntents.GuildMessageReactions + | GatewayIntents.DirectMessages + | GatewayIntents.MessageContent, }); _client.Log += LoggingService.DiscordNetLogger; @@ -52,7 +56,7 @@ private async Task MainAsync() { // Ready can be called additional times if the bot disconnects for long enough, // so we need to make sure we only initialize commands and such for the bot once if it manages to re-establish connection - if (_isInitialized) return Task.CompletedTask; + if (Interlocked.CompareExchange(ref _isInitialized, 1, 0) != 0) return Task.CompletedTask; _interactionService = new InteractionService(_client); _commandService = new CommandService(new CommandServiceConfig @@ -62,29 +66,47 @@ private async Task MainAsync() }); _services = ConfigureServices(); - _commandHandlingService = _services.GetRequiredService(); + _services.GetRequiredService(); // Announce, and Log bot started to track issues a bit easier var logger = _services.GetRequiredService(); logger.LogChannelAndFile("Bot Started.", ExtendedLogSeverity.Positive); LoggingService.LogToConsole("Bot is connected.", ExtendedLogSeverity.Positive); - _isInitialized = true; - _unityHelpService = _services.GetRequiredService(); - _recruitService = _services.GetRequiredService(); - _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); _services.GetRequiredService(); return Task.CompletedTask; }; - await Task.Delay(-1); + try + { + await Task.Delay(Timeout.Infinite, _cts.Token); + } + catch (TaskCanceledException) { } + + LoggingService.LogToConsole("Shutdown signal received, stopping...", ExtendedLogSeverity.Warning); + using var shutdownTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + try { await _client.StopAsync().WaitAsync(shutdownTimeout.Token); } + catch (OperationCanceledException) { LoggingService.LogToConsole("Client stop timed out.", ExtendedLogSeverity.Warning); } + LoggingService.LogToConsole("Bot stopped.", ExtendedLogSeverity.Positive); } private IServiceProvider ConfigureServices() => new ServiceCollection() + .AddHttpClient() + .AddSingleton() + .AddSingleton(_cts) .AddSingleton(_settings) .AddSingleton(_rules) .AddSingleton(_userSettings) @@ -94,13 +116,23 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -110,6 +142,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .BuildServiceProvider(); @@ -119,5 +152,17 @@ private static void DeserializeSettings() _settings = SerializeUtil.DeserializeFile(@"Settings/Settings.json"); _rules = SerializeUtil.DeserializeFile(@"Settings/Rules.json"); _userSettings = SerializeUtil.DeserializeFile(@"Settings/UserSettings.json"); + + var (errors, warnings) = _settings.Validate(); + warnings.AddRange(_userSettings.Validate()); + foreach (var warning in warnings) + Console.WriteLine($"[Settings Warning] {warning}"); + if (errors.Count > 0) + { + foreach (var error in errors) + Console.Error.WriteLine($"[Settings Error] {error}"); + throw new InvalidOperationException( + $"Bot settings validation failed with {errors.Count} error(s). See output above."); + } } } diff --git a/DiscordBot/Services/Code/CodeCheckService.cs b/DiscordBot/Services/Code/CodeCheckService.cs new file mode 100644 index 00000000..7dca07ac --- /dev/null +++ b/DiscordBot/Services/Code/CodeCheckService.cs @@ -0,0 +1,140 @@ +using System.Text.RegularExpressions; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Code; + +public class CodeCheckService +{ + private readonly DiscordSocketClient _client; + private readonly BotSettings _settings; + private readonly UpdateService _updateService; + private readonly CancellationToken _shutdownToken; + + private readonly Regex _x3CodeBlock = + new("^(?`{3}((?\\w*?$)|$).+?({.+?}).+?`{3})", RegexOptions.Multiline | RegexOptions.Singleline); + + private readonly Regex _x2CodeBlock = new("^(`{2})[^`].+?([^`]`{2})$", RegexOptions.Multiline); + private readonly List _codeBlockWarnPatterns; + private readonly short _maxCodeBlockLengthWarning = 800; + + public readonly string CodeFormattingExample; + private readonly string _codeReminderFormattingExample; + public Dictionary CodeReminderCooldown { get; private set; } + + public CodeCheckService(DiscordSocketClient client, BotSettings settings, + UpdateService updateService, CancellationTokenSource cts) + { + _client = client; + _settings = settings; + _updateService = updateService; + _shutdownToken = cts.Token; + + CodeReminderCooldown = new Dictionary(); + + CodeFormattingExample = @"\`\`\`cs" + Environment.NewLine + + "Write your code on new line here." + Environment.NewLine + + @"\`\`\`" + Environment.NewLine; + + _codeReminderFormattingExample = CodeFormattingExample + "*To disable these reminders use \"!disablecodetips\"*"; + + _codeBlockWarnPatterns = new List + { + new(".*?({.+?}).*?", RegexOptions.Singleline), + new("(if|else\\sif).?\\(.+\\).?($|\\/{2}|\\s?)", RegexOptions.Multiline), + new("^(\\w*.\\w*)\\(\\w*?\\);($|.?($|.*?\\/{2}))", RegexOptions.Multiline), + new("^.+? =.+?($|.*?\\/\\/)", RegexOptions.Multiline) + }; + + _client.MessageReceived += EventGuard.Guarded(CodeCheck, nameof(CodeCheck)); + + LoadData(); + UpdateLoop(); + } + + private async void UpdateLoop() + { + try + { + while (!_shutdownToken.IsCancellationRequested) + { + await Task.Delay(10000, _shutdownToken); + SaveData(); + } + } + catch (OperationCanceledException) { SaveData(); } + catch (Exception e) + { + LoggingService.LogToConsole($"[CodeCheckService.UpdateLoop] Unhandled exception: {e}", LogSeverity.Error); + } + } + + private void LoadData() + { + var data = _updateService.GetUserData(); + CodeReminderCooldown = data.CodeReminderCooldown ?? new Dictionary(); + } + + private void SaveData() + { + var data = new UserData + { + CodeReminderCooldown = CodeReminderCooldown + }; + _updateService.SetUserData(data); + } + + public async Task CodeCheck(SocketMessage messageParam) + { + if (messageParam.Author.IsBot || messageParam.Channel.Id == _settings.Channels.General.Id) + return; + + if (messageParam.Content.Length < 200) + return; + + var userId = messageParam.Author.Id; + + if (!CodeReminderCooldown.HasUser(userId)) + { + var content = messageParam.Content; + + var foundTrippleCodeBlock = _x3CodeBlock.Match(content); + if (foundTrippleCodeBlock.Groups["CS"].Length > 0) + return; + if (foundTrippleCodeBlock.Groups["CodeBlock"].Success) + { + await (messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{_codeReminderFormattingExample}") + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); + return; + } + + var foundDoubleCodeBlock = _x2CodeBlock.Match(content).Success; + + int hits = 0; + foreach (var regex in _codeBlockWarnPatterns) + { + hits += regex.Match(content).Captures.Count; + } + + if (!foundDoubleCodeBlock && hits >= 3) + { + await (messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} are you sharing C# scripts? Remember to use codeblocks to help readability!\n{_codeReminderFormattingExample}") + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); + if (content.Length > _maxCodeBlockLengthWarning) + { + await (messageParam.Channel.SendMessageAsync( + "The code you're sharing is quite long, maybe use a free service like and share the link here instead.") + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); + } + } + else if (foundDoubleCodeBlock && hits > 0) + { + await (messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} when using code blocks remember to use \\`\\`\\`cs as this will help improve readability for C# scripts.\n{_codeReminderFormattingExample}") + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); + } + } + } +} diff --git a/DiscordBot/Services/Code/Tips/Components/Tip.cs b/DiscordBot/Services/Code/Tips/Components/Tip.cs new file mode 100644 index 00000000..c8032ebf --- /dev/null +++ b/DiscordBot/Services/Code/Tips/Components/Tip.cs @@ -0,0 +1,12 @@ +using Discord; + +namespace DiscordBot.Services.Code.Tips.Components; + +public class Tip : IEntity +{ + public ulong Id { get; set; } + public string Content { get; set; } = string.Empty; + public List Keywords { get; set; } = []; + public List ImagePaths { get; set; } = []; + public int Requests { get; set; } +} diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Code/Tips/TipService.cs similarity index 89% rename from DiscordBot/Services/Tips/TipService.cs rename to DiscordBot/Services/Code/Tips/TipService.cs index 24cd0404..718ac914 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Code/Tips/TipService.cs @@ -5,31 +5,32 @@ using System.Net.Http; using Discord; using Discord.WebSocket; -using DiscordBot.Services.Tips.Components; using DiscordBot.Settings; using Newtonsoft.Json; -namespace DiscordBot.Services.Tips; +namespace DiscordBot.Services.Code.Tips; public class TipService { - private const string ServiceName = "TipService"; + private const string ServiceName = "TipService"; private const string DatabaseName = "tips.json"; private readonly BotSettings _settings; private readonly ILoggingService _loggingService; - private readonly string _imageDirectory; + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _imageDirectory = null!; private ConcurrentDictionary> _tips = new(); private bool _isRunning = false; private bool _readOnly = false; - private Regex keywordPattern = null; + private Regex? keywordPattern = null; - public TipService(BotSettings settings, ILoggingService loggingService) + public TipService(BotSettings settings, ILoggingService loggingService, IHttpClientFactory httpClientFactory) { _settings = settings; _loggingService = loggingService; + _httpClientFactory = httpClientFactory; if (string.IsNullOrEmpty(_settings.ServerRootPath)) { @@ -37,25 +38,25 @@ public TipService(BotSettings settings, ILoggingService loggingService) _isRunning = false; return; } - - if (string.IsNullOrEmpty(_settings.TipImageDirectory)) + + if (string.IsNullOrEmpty(_settings.UnityHelp.TipImageDirectory)) { _loggingService.LogAction($"[{ServiceName}] TipImageDirectory not set, service will not run.", ExtendedLogSeverity.Warning); _isRunning = false; return; } - _imageDirectory = Path.Combine(_settings.ServerRootPath, _settings.TipImageDirectory); + _imageDirectory = Path.Combine(_settings.ServerRootPath, _settings.UnityHelp.TipImageDirectory); Initialize(); } - + private void Initialize() { if (_isRunning) return; _readOnly = false; - var jsonPath = GetTipPath(DatabaseName);; + var jsonPath = GetTipPath(DatabaseName); ; if (!Directory.Exists(_imageDirectory)) { _loggingService.LogAction($"[{ServiceName}] Tip directory {_imageDirectory} did not exist.", ExtendedLogSeverity.Info); @@ -65,14 +66,14 @@ private void Initialize() else { var directorySize = new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Sum(file => file.Length); - if (directorySize > _settings.TipMaxDirectoryFileSize) + if (directorySize > _settings.UnityHelp.TipMaxDirectoryFileSize) { - _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, exceeding the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning); + _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, exceeding the limit of {_settings.UnityHelp.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning); _readOnly = true; } else { - _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, within the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB.", ExtendedLogSeverity.Info); + _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, within the limit of {_settings.UnityHelp.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB.", ExtendedLogSeverity.Info); _loggingService.LogAction($"[{ServiceName}] Tip directory contains {new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Count()} files.", ExtendedLogSeverity.Info); } @@ -102,7 +103,7 @@ private bool IsValidTipKeyword(string keyword) private bool IsValidTipAttachment(IAttachment attachment) { - if (attachment.Size > _settings.TipMaxImageFileSize) + if (attachment.Size > _settings.UnityHelp.TipMaxImageFileSize) return false; // Discord-friendly attachment image file formats only @@ -154,11 +155,11 @@ public async Task AddTip(IUserMessage message, string keywords, string content) attachment.Filename.Substring(attachment.Filename.LastIndexOf('.')); var filePath = GetTipPath(newFileName); - using var client = new HttpClient(); + using var client = _httpClientFactory.CreateClient(); await using var stream = await client.GetStreamAsync(attachment.Url); await using var file = File.Create(filePath); await stream.CopyToAsync(file); - + imagePaths.Add(newFileName); } @@ -258,19 +259,19 @@ public async Task ReplaceTip(IUserMessage message, Tip tip, string content) return; } - RemoveTip(message, tip); - AddTip(message, string.Join(",", tip.Keywords), content); + await RemoveTip(message, tip); + await AddTip(message, string.Join(",", tip.Keywords), content); // REVIEW: causes two CommitTipDatabase calls } public async Task ReloadTipDatabase() { - var jsonPath = GetTipPath(DatabaseName);; + var jsonPath = GetTipPath(DatabaseName); ; if (File.Exists(jsonPath)) { - var json = File.ReadAllText(jsonPath); - _tips = JsonConvert.DeserializeObject>>(json); - _loggingService.LogAction( + var json = File.ReadAllText(jsonPath); + _tips = JsonConvert.DeserializeObject>>(json)!; + _ = _loggingService.LogAction( $"[{ServiceName}] Tip index has {_tips.Count} keywords.", ExtendedLogSeverity.Info); } @@ -302,7 +303,7 @@ public async Task ReloadTipDatabase() if (touched) { - _loggingService.LogAction( + _ = _loggingService.LogAction( $"[{ServiceName}] Tip index was de-duplicated.", ExtendedLogSeverity.Info); await CommitTipDatabase(); @@ -323,12 +324,7 @@ await File.WriteAllTextAsync(jsonPath, settings)); } - public string DumpTipDatabase() - { - return JsonConvert.SerializeObject(_tips); - } - - public Tip GetTip(ulong Id) + public Tip? GetTip(ulong Id) { foreach (var kvp in _tips) foreach (var tip in kvp.Value) diff --git a/DiscordBot/Services/Code/Unity/FeedService.cs b/DiscordBot/Services/Code/Unity/FeedService.cs new file mode 100644 index 00000000..488f6d19 --- /dev/null +++ b/DiscordBot/Services/Code/Unity/FeedService.cs @@ -0,0 +1,221 @@ +using System.IO; +using System.ServiceModel.Syndication; +using System.Xml; +using Discord.WebSocket; +using DiscordBot.Settings; +using DiscordBot.Utils; + +namespace DiscordBot.Services.Code.Unity; + +public class FeedService +{ + private const string ServiceName = "FeedService"; + private readonly DiscordSocketClient _client; + + private readonly BotSettings _settings; + private readonly ILoggingService _logging; + private readonly IWebClient _webClient; + private readonly ReleaseNotesParser _releaseNotesParser; + + #region Configurable Settings + + private const int MaxFeedLengthBuffer = 400; + #region News Feed Config + + private class ForumNewsFeed + { + public string TitleFormat { get; set; } = null!; + public string Url { get; set; } = null!; + public List IncludeTags { get; set; } = null!; + public bool IsRelease { get; set; } = false; + } + + private readonly ForumNewsFeed _betaNews = new() + { + TitleFormat = "Beta Release - {0}", + Url = "https://unity3d.com/unity/beta/latest.xml", + IncludeTags = new() { "Beta Update" }, + IsRelease = true + }; + private readonly ForumNewsFeed _releaseNews = new() + { + TitleFormat = "New Release - {0}", + Url = "https://unity3d.com/unity/releases.xml", + IncludeTags = new() { "New Release" }, + IsRelease = true + }; + private readonly ForumNewsFeed _blogNews = new() + { + TitleFormat = "Blog - {0}", + Url = "https://blogs.unity3d.com/feed/", + IncludeTags = new() { "Unity Blog" }, + IsRelease = false + }; + + #endregion // News Feed Config + + // We store the title of the last 40 posts, and check against them to prevent duplicate posts + private const int MaxHistoryCheck = 40; + private readonly List _postedFeeds = new(MaxHistoryCheck); + + private const int MaximumCheck = 3; + private const ThreadArchiveDuration ForumArchiveDuration = ThreadArchiveDuration.OneWeek; + + #endregion // Configurable Settings + + public FeedService(DiscordSocketClient client, BotSettings settings, ILoggingService logging, IWebClient webClient, ReleaseNotesParser releaseNotesParser) + { + _client = client; + _settings = settings; + _logging = logging; + _webClient = webClient; + _releaseNotesParser = releaseNotesParser; + } + + private async Task GetFeedData(string url) + { + SyndicationFeed? feed = null; + try + { + var content = await _webClient.GetXMLContent(url); + var reader = XmlReader.Create(new StringReader(content)); + feed = SyndicationFeed.Load(reader); + } + catch (Exception e) + { + LoggingService.LogToConsole($"[{ServiceName} Feed failure: {e.ToString()}", ExtendedLogSeverity.LowWarning); + } + + // Return the feed, empty feed if null to prevent additional checks for null on return + return feed ??= new SyndicationFeed(); + } + + #region Feed Handlers + + private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong channelId, ulong? roleId) + { + try + { + var feed = await GetFeedData(newsFeed.Url); + if (_client.GetChannel(channelId) is not IForumChannel channel) + { + await _logging.LogAction($"[{ServiceName}] Error: Channel {channelId} not found", ExtendedLogSeverity.Error); + return; + } + foreach (var item in feed.Items.Take(MaximumCheck)) + { + if (feedData.PostedIds.Contains(item.Id)) + continue; + feedData.PostedIds.Add(item.Id); + + // Title + var newsTitle = string.Format(newsFeed.TitleFormat, item.Title.Text); + if (newsTitle.Length > 90) + newsTitle = newsTitle[..90] + "..."; + + // Confirm we haven't posted this title before + if (_postedFeeds.Contains(newsTitle)) + continue; + _postedFeeds.Add(newsTitle); + if (_postedFeeds.Count > MaxHistoryCheck) + _postedFeeds.RemoveAt(0); + + // Message + var newsContent = string.Empty; + List releaseNotes = new(); + if (!newsFeed.IsRelease) + newsContent = GetSummary(newsFeed, item); + else + { + try + { + releaseNotes = _releaseNotesParser.Parse(item.Summary.Text); + } + catch (Exception e) + { + _ = _logging.LogChannelAndFile($"[{ServiceName}] Error generating release notes: {e}\nLikely updated format.", ExtendedLogSeverity.Warning); + releaseNotes = new List { "No release notes found" }; + } + newsContent = releaseNotes[0]; + } + + // If a role is provided we add to end of title to ping the role + var role = _client.GetGuild(_settings.GuildId).GetRole(roleId ?? 0); + if (role != null) + newsContent += $"\n{role.Mention}"; + // Link to post + if (item.Links.Count > 0) + newsContent += $"\n\n**__Source__**\n{item.Links[0].Uri}"; + + newsContent = newsContent.SanitizeEveryoneHereMentions(); + + // The Post + var post = await channel.CreatePostAsync(newsTitle, ForumArchiveDuration, null, newsContent, null, null, AllowedMentions.All); + await AddTagsToPost(channel, post, newsFeed.IncludeTags); + + if (releaseNotes.Count == 1) + continue; + + // post a new message for each release note after the first + for (int i = 1; i < releaseNotes.Count; i++) + { + if (releaseNotes[i].Length == 0) + continue; + await post.SendMessageAsync(releaseNotes[i].SanitizeEveryoneHereMentions()); + } + } + } + catch (Exception e) + { + await _logging.LogAction($"[{ServiceName}] Error: {e}", ExtendedLogSeverity.Error); + } + } + + private async Task AddTagsToPost(IForumChannel channel, IThreadChannel post, List tags) + { + if (tags.Count <= 0) + return; + + var includedTags = new List(); + foreach (var tag in tags) + { + var tagContainer = channel.Tags.FirstOrDefault(x => x.Name == tag); + if (tagContainer != null) + includedTags.Add(tagContainer.Id); + } + + await post.ModifyAsync(properties => { properties.AppliedTags = includedTags; }); + } + + private string GetSummary(ForumNewsFeed feed, SyndicationItem item) + { + var summary = global::DiscordBot.Utils.Utils.RemoveHtmlTags(item.Summary.Text); + + // If it is too long, we truncate it + var summaryLength = summary.Length; + if (summaryLength > Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) + summary = summary[..(Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer)] + "..."; + return summary; + } + + #endregion // Feed Handlers + + #region Public Feed Actions + + public async Task CheckUnityBetasAsync(FeedData feedData) + { + await HandleFeed(feedData, _betaNews, _settings.Channels.UnityReleases.Id, _settings.Roles.SubsReleases); + } + + public async Task CheckUnityReleasesAsync(FeedData feedData) + { + await HandleFeed(feedData, _releaseNews, _settings.Channels.UnityReleases.Id, _settings.Roles.SubsReleases); + } + + public async Task CheckUnityBlogAsync(FeedData feedData) + { + await HandleFeed(feedData, _blogNews, _settings.Channels.UnityNews.Id, _settings.Roles.SubsNews); + } + + #endregion // Feed Actions +} \ No newline at end of file diff --git a/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs b/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs new file mode 100644 index 00000000..3391c829 --- /dev/null +++ b/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs @@ -0,0 +1,120 @@ +using HtmlAgilityPack; + +namespace DiscordBot.Services.Code.Unity; + +public class ReleaseNotesParser +{ + private const int MaxFeedLengthBuffer = 400; + + public List Parse(string summaryHtml) + { + var htmlDoc = new HtmlDocument(); + summaryHtml = summaryHtml.Replace("→", "->"); + htmlDoc.LoadHtml(summaryHtml); + + var summaryNode = htmlDoc.DocumentNode.SelectSingleNode("//div[@class='release-notes']"); + if (summaryNode == null) + return new List { "No release notes found" }; + + var knownIssueNode = FindH3Sibling(summaryNode, "Known Issues"); + var entriesSinceNode = summaryNode.ChildNodes + .FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Entries since")); + + var featuresNode = FindH4Sibling(summaryNode, "Features"); + var improvementsNode = FindH4Sibling(summaryNode, "Improvements"); + var apiChangesNode = FindH4Sibling(summaryNode, "API Changes"); + var changesNode = FindH4Sibling(summaryNode, "Changes"); + var fixesNode = FindH4Sibling(summaryNode, "Fixes"); + var packagesUpdatedNode = summaryNode.ChildNodes + .FirstOrDefault(x => x.Name == "h4" && x.InnerText.ToLower().Contains("package changes")) + ?.NextSibling?.NextSibling?.NextSibling; + + var summary = "**Summary**\n"; + summary += GetNodeLiCountString("Known Issues", knownIssueNode?.NextSibling); + + if (entriesSinceNode != null) + summary += $"__{entriesSinceNode.InnerText}__\n\n"; + + summary += GetNodeLiCountString("Features", featuresNode?.NextSibling); + summary += GetNodeLiCountString("Improvements", improvementsNode?.NextSibling); + summary += GetNodeLiCountString("API Changes", apiChangesNode?.NextSibling); + summary += GetNodeLiCountString("Changes", changesNode?.NextSibling); + summary += GetNodeLiCountString("Fixes", fixesNode?.NextSibling); + summary += GetNodeLiCountString("Packages Updated", packagesUpdatedNode?.NextSibling); + + var releaseNotes = new List + { + BuildSection("Packages Updated", packagesUpdatedNode, summary), + BuildSection("Features", featuresNode), + BuildSection("Improvements", improvementsNode, "", 1000), + BuildSection("API Changes", apiChangesNode), + BuildSection("Changes", changesNode), + BuildSection("Fixes", fixesNode, ""), + BuildSection("Known Issues", knownIssueNode, "", 1200) + }; + + return releaseNotes; + } + + private static HtmlNode? FindH3Sibling(HtmlNode parent, string text) + { + return parent.ChildNodes + .FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains(text)) + ?.NextSibling; + } + + private static HtmlNode? FindH4Sibling(HtmlNode parent, string text) + { + return parent.ChildNodes + .FirstOrDefault(x => x.Name == "h4" && x.InnerText == text) + ?.NextSibling; + } + + private string BuildSection(string title, HtmlNode? node, string contents = "", + int maxLength = Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) + { + if (node == null) + return string.Empty; + + var summary = $"{(contents.Length > 0 ? $"{contents}\n" : string.Empty)}**{node.PreviousSibling.InnerText}**\n"; + + bool needsExtraProcessing = title is "Fixes" or "Known Issues" or "API Changes"; + + foreach (var feature in node.NextSibling.ChildNodes.Where(x => x.Name == "li")) + { + var extraText = string.Empty; + if (needsExtraProcessing) + { + var nodeContents = feature.ChildNodes[0]; + nodeContents.InnerHtml = nodeContents.InnerHtml.Replace("\n", " "); + + var linkNode = nodeContents.SelectSingleNode("a"); + if (linkNode != null) + { + nodeContents = nodeContents.RemoveChild(linkNode); + feature.InnerHtml = feature.InnerHtml.Replace("()", ""); + extraText = $" ([{linkNode.InnerText}](<{linkNode.Attributes["href"].Value}>))"; + } + } + + summary += $"- {feature.InnerText}{extraText}\n"; + if (summary.Length > maxLength) + { + var lastLine = summary[..maxLength].LastIndexOf('\n'); + summary = summary[..lastLine] + $"\n{title} truncated...\n"; + return summary; + } + } + + return summary; + } + + private static string GetNodeLiCountString(string title, HtmlNode? node) + { + if (node == null) + return string.Empty; + + var count = node.ChildNodes.Count(x => x.Name == "li"); + return $"{title}: {count}\n"; + } +} diff --git a/DiscordBot/Services/Code/Unity/UnityDocParser.cs b/DiscordBot/Services/Code/Unity/UnityDocParser.cs new file mode 100644 index 00000000..6630edda --- /dev/null +++ b/DiscordBot/Services/Code/Unity/UnityDocParser.cs @@ -0,0 +1,32 @@ +using DiscordBot.Domain; +using HtmlAgilityPack; + +namespace DiscordBot.Services.Code.Unity; + +public static class UnityDocParser +{ + public static DocEntry[] ConvertJsToArray(string data, bool isManual) + { + var list = new List(); + string pagesInput; + + if (isManual) + { + pagesInput = data.Split("info = [")[0].Split("pages=")[1]; + pagesInput = pagesInput[2..^2]; + } + else + { + pagesInput = data.Split("info =")[0]; + pagesInput = pagesInput[63..^2]; + } + + foreach (var s in pagesInput.Split("],[")) + { + var ps = s.Split(","); + list.Add(new DocEntry(ps[0].Replace("\"", ""), ps[1].Replace("\"", ""))); + } + + return list.ToArray(); + } +} diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs similarity index 97% rename from DiscordBot/Services/UnityHelp/CannedResponseService.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs index ca826aba..7ea74154 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs @@ -1,11 +1,9 @@ -namespace DiscordBot.Service; +namespace DiscordBot.Services.Code.Unity.UnityHelp; public class CannedResponseService { - private const string ServiceName = "CannedResponseService"; - #region Configuration - + public enum CannedResponseType { HowToAsk, @@ -55,7 +53,7 @@ public enum CannedHelp GameTooBig = CannedResponseType.GameTooBig, HowToGoogle = CannedResponseType.HowToGoogle, } - + public enum CannedResources { Programming = CannedResponseType.Programming, @@ -70,11 +68,11 @@ public enum CannedResources // PerformanceAndOptimization = CannedResponseType.PerformanceAndOptimization, // UIUX = CannedResponseType.UIUX } - + private readonly Color _defaultEmbedColor = new Color(0x00, 0x80, 0xFF); #region Canned Help - + private readonly EmbedBuilder _howToAskEmbed = new EmbedBuilder { Title = "How to Ask", @@ -84,7 +82,7 @@ public enum CannedResources "See: [How to Ask](https://stackoverflow.com/help/how-to-ask)", Url = "https://stackoverflow.com/help/how-to-ask", }; - + private readonly EmbedBuilder _pasteEmbed = new EmbedBuilder { Title = "How to Paste Code", @@ -102,14 +100,14 @@ public enum CannedResources "This will make your code easier to read and copy. If your code is too long, consider using a service like [GitHub Gist](https://gist.github.com/) or [Pastebin](https://pastebin.com/).", Url = "https://pastebin.com/", }; - + private readonly EmbedBuilder _noCodeEmbed = new EmbedBuilder { Title = "No Code Provided", Description = "***Where the code at?*** It appears you're trying to ask something that would benefit from showing what you've tried, but you haven't provided much code. " + "Someone who wants to help you won't be able to do so without seeing the code you're working with." }; - + private readonly EmbedBuilder _xyProblemEmbed = new EmbedBuilder { Title = "XY Problem", @@ -120,7 +118,7 @@ public enum CannedResources "- If you've tried something, tell us what you tried", Url = "https://xyproblem.info/", }; - + private readonly EmbedBuilder _gameTooBigEmbed = new EmbedBuilder { Title = "Game Too Big", @@ -138,7 +136,7 @@ public enum CannedResources "See: [How to Google](https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html)", Url = "https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html", }; - + private readonly EmbedBuilder _deltaTime = new EmbedBuilder { Title = "Frame Independence", @@ -155,11 +153,11 @@ public enum CannedResources "[Update](https://docs.unity3d.com/ScriptReference/MonoBehaviour.Update.html) or " + "`fixedDeltaTime` [FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html) for consistent speed.\n" + "See: [Time Frame Management](https://docs.unity3d.com/Manual/TimeFrameManagement.html), " + - "[FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html), " + + "[FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html), " + "[DeltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html)", Url = "https://docs.unity3d.com/Manual/TimeFrameManagement.html", }; - + private readonly EmbedBuilder _debugging = new EmbedBuilder { Title = "Debugging in Unity", @@ -172,7 +170,7 @@ public enum CannedResources "Debugging improves with practice, enhancing your bug identification and resolution skills.", Url = "https://docs.unity3d.com/Manual/ManagedCodeDebugging.html", }; - + private readonly EmbedBuilder _folderStructure = new EmbedBuilder { Title = "Folder Structure", @@ -185,11 +183,11 @@ public enum CannedResources "See: [Organizing Your Project](https://unity.com/how-to/organizing-your-project)", Url = "https://unity.com/how-to/organizing-your-project", }; - + #endregion #region Canned Resources - + private readonly EmbedBuilder _programmingEmbed = new EmbedBuilder { Title = "Programming Resources", @@ -202,7 +200,7 @@ public enum CannedResources "- Design Patterns: [Game Programming Patterns](https://gameprogrammingpatterns.com/)", Url = "https://learn.unity.com/project/roll-a-ball" }; - + private readonly EmbedBuilder _artEmbed = new EmbedBuilder { Title = "Art Resources", @@ -212,7 +210,7 @@ public enum CannedResources "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-royalty-free)\n" + "- Blender Discord: [Server Invite](https://discord.gg/blender)" }; - + private readonly EmbedBuilder _threeDEmbed = new EmbedBuilder { Title = "3D Resources", @@ -222,7 +220,7 @@ public enum CannedResources "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-3d/tag-royalty-free)\n" + "- Blender Discord: [Server Invite](https://discord.gg/blender)" }; - + private readonly EmbedBuilder _twoDEmbed = new EmbedBuilder { Title = "2D Resources", @@ -231,7 +229,7 @@ public enum CannedResources "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-2d)\n" + "- Blender Discord: [Server Invite](https://discord.gg/blender)" }; - + private readonly EmbedBuilder _audioEmbed = new EmbedBuilder { Title = "Audio Resources", @@ -242,7 +240,7 @@ public enum CannedResources "- Audio Editor: [Audacity](https://www.audacityteam.org/)\n" + "- Sound Design Explained: [PitchBlends](https://www.pitchbends.com/posts/what-is-sound-design)" }; - + private readonly EmbedBuilder _designEmbed = new EmbedBuilder { Title = "Design Resources", @@ -254,25 +252,25 @@ public enum CannedResources "- Iconography: [Flaticon](https://www.flaticon.com/)\n" + "- Free Icons: [Icon Monstr](https://iconmonstr.com/)" }; - + #endregion - + #endregion // Configuration - - public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor = null) + + public EmbedBuilder? GetCannedResponse(CannedResponseType type, IUser? requestor = null) { var embed = GetUnbuiltCannedResponse(type); if (embed == null) return null; - + if (requestor != null) embed.FooterRequestedBy(requestor); embed.WithColor(_defaultEmbedColor); - + return embed; } - - public EmbedBuilder GetUnbuiltCannedResponse(CannedResponseType type) + + public EmbedBuilder? GetUnbuiltCannedResponse(CannedResponseType type) { return type switch { diff --git a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs b/DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs similarity index 88% rename from DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs index 48c7677a..2ab19a3b 100644 --- a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs @@ -1,4 +1,4 @@ -namespace DiscordBot.Services.UnityHelp; +namespace DiscordBot.Services.Code.Unity.UnityHelp; public enum HelpMessageType { @@ -12,7 +12,7 @@ public class HelpBotMessage { public ulong MessageId { get; set; } public HelpMessageType Type { get; set; } - + public HelpBotMessage(ulong messageId, HelpMessageType type) { MessageId = messageId; diff --git a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs b/DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs similarity index 89% rename from DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs index a3779b3a..de98d804 100644 --- a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs @@ -1,4 +1,4 @@ -namespace DiscordBot.Services.UnityHelp; +namespace DiscordBot.Services.Code.Unity.UnityHelp; public class ThreadContainer { @@ -10,17 +10,17 @@ public class ThreadContainer public bool IsResolved { get; set; } = false; public bool HasInteraction { get; set; } = false; - - + + public ulong BotsLastMessage { get; set; } - public CancellationTokenSource CancellationToken { get; set; } + public CancellationTokenSource? CancellationToken { get; set; } public DateTime ExpectedShutdownTime { get; set; } - + /// /// Any message the bot sends that could need to be tracked/deleted later is stored here. /// public Dictionary HelpMessages { get; set; } = new(); - + public bool HasMessage(HelpMessageType type) => HelpMessages.ContainsKey(type); public ulong GetMessageId(HelpMessageType type) => HelpMessages[type].MessageId; public void AddMessage(HelpMessageType type, ulong messageId) => HelpMessages.Add(type, new HelpBotMessage(messageId, type)); diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs similarity index 91% rename from DiscordBot/Services/UnityHelp/UnityHelpService.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs index 2ae6b3c4..18f6d6a6 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs @@ -1,8 +1,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -using DiscordBot.Services.UnityHelp; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Code.Unity.UnityHelp; // TODO : (James) Better Slash Command Support @@ -11,13 +10,12 @@ public class UnityHelpService private const string ServiceName = "UnityHelpService"; private readonly DiscordSocketClient _client; - private readonly ILoggingService _logging; private SocketRole ModeratorRole { get; set; } - + #region Configuration - + private static readonly Emoji ThumbUpEmoji = new Emoji("👍"); - + private const int TimeBeforeClosedForResolvedTag = 10; private readonly Embed _resolvedWarnOfPendingCloseEmbedHasPin = new EmbedBuilder() .WithTitle($"Issue Resolved") @@ -46,7 +44,7 @@ public class UnityHelpService .WithColor(Color.LightOrange) .Build(); private const int StealthDeleteTime = 60 * 5; - + private readonly Embed _noAppliedTagsEmbed = new EmbedBuilder() .WithTitle("Warning: No Tags Applied") .WithDescription($"Consider adding tags to your question to help others find it!\n" + @@ -66,37 +64,36 @@ public class UnityHelpService .WithFooter("Be descriptive of the problem!") .WithColor(Color.LightOrange) .Build(); - + #endregion // Configuration #region Extra Details - - private readonly IForumChannel _helpChannel; - + + private readonly IForumChannel _helpChannel = null!; + private readonly ForumTag _resolvedForumTag; #endregion // Extra Details - public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggingService logging) + public UnityHelpService(DiscordSocketClient client, BotSettings settings) { _client = client; - _logging = logging; - - ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); - if (!settings.UnityHelpBabySitterEnabled) + ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.Roles.Moderator); + + if (!settings.UnityHelp.BabySitterEnabled) { - LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelpBabySitterEnabled)); + LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelp.BabySitterEnabled)); return; } - - // get the help channel settings.GenericHelpChannel - _helpChannel = _client.GetChannel(settings.GenericHelpChannel.Id) as IForumChannel; + + // get the help channel settings.Channels.GenericHelp + _helpChannel = (_client.GetChannel(settings.Channels.GenericHelp.Id) as IForumChannel)!; if (_helpChannel == null) { LoggingService.LogToConsole($"[{ServiceName}] Help channel not found", LogSeverity.Error); } - var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.TagUnitHelpResolvedTag)); + var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.UnityHelp.TagResolved)); if (resolvedTag == null || resolvedTag.Id <= 0) LoggingService.LogToConsole($"[{ServiceName}] Resolved tag not found", LogSeverity.Error); _resolvedForumTag = resolvedTag; @@ -107,15 +104,15 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggi _client.ThreadCreated += GatewayOnThreadCreated; _client.ThreadUpdated += GatewayOnThreadUpdated; _client.ThreadDeleted += GatewayOnThreadDeleted; - + _client.ThreadMemberJoined += GatewayOnThreadMemberJoinedThread; _client.ThreadMemberLeft += GatewayOnThreadMemberLeftThread; - + _client.MessageReceived += GatewayOnMessageReceived; _client.MessageUpdated += GatewayOnMessageUpdated; Task.Run(LoadActiveThreads); - + LoggingService.LogServiceEnabled(ServiceName); } @@ -139,13 +136,11 @@ private async Task LoadActiveThreads() if (threadContainer.IsResolved) { // Run in new task so we don't block the other threads from being processed -#pragma warning disable CS4014 - Task.Run(() => CloseThreadInTime(threadContainer, string.Empty, + CloseThreadInTime(threadContainer, string.Empty, TimeBeforeClosedForResolvedTag, (threadContainer.PinnedAnswer != 0 ? _resolvedWarnOfPendingCloseEmbedHasPin - : _resolvedWarnOfPendingCloseEmbedNoPin))); -#pragma warning restore CS4014 + : _resolvedWarnOfPendingCloseEmbedNoPin)).SafeFireAndForget(ServiceName); } else { @@ -155,12 +150,12 @@ private async Task LoadActiveThreads() } #region Thread Tracking - + // Threads we're currently tracking private readonly Dictionary _activeThreads = new(); #region Thread Creation - + private async Task OnThreadCreated(SocketThreadChannel thread) { ThreadContainer container = new() @@ -170,9 +165,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread) Owner = thread.Owner.Id, }; _activeThreads.Add(thread.Id, container); - - bool warnHelpTitle = false; - + // Check message length and inform user if too short var firstMessage = (await thread.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); container.FirstUserMessage = firstMessage!.Id; @@ -182,7 +175,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread) container.AddMessage(HelpMessageType.QuestionLength, botResponse.Id); // container.WarningMessage = botResponse.Id; } - + var threadTitle = thread.Name; if (threadTitle.IsAllCaps()) { @@ -190,9 +183,6 @@ private async Task OnThreadCreated(SocketThreadChannel thread) } await thread.ModifyAsync(x => x.Name = threadTitle.ToCapitalizeFirstLetter()); - if (thread.Name.Contains(" help", StringComparison.CurrentCultureIgnoreCase)) - warnHelpTitle = true; - // If not tags attached, let them know they should add some if (thread.AppliedTags.Count == 0) { @@ -205,7 +195,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread) // Sets up the thread to be closed after a certain amount of time (This will quickly be removed if anyone interacts with the thread) await StealthDeleteThreadInTime(container); } - + private Task GatewayOnThreadCreated(SocketThreadChannel thread) { if (!thread.IsThreadInChannel(_helpChannel.Id)) @@ -220,13 +210,13 @@ private Task GatewayOnThreadCreated(SocketThreadChannel thread) // Ignore new thread if age is over, 5 mins? if (thread.CreatedAt < DateTime.Now.AddMinutes(-5)) return Task.CompletedTask; - + LoggingService.DebugLog($"[{ServiceName}] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); - Task.Run(() => OnThreadCreated(thread)); - + OnThreadCreated(thread).SafeFireAndForget(ServiceName); + return Task.CompletedTask; } - + #endregion // Thread Creation #region Thread Update @@ -278,7 +268,7 @@ private async Task OnThreadUpdated(SocketThreadChannel before, SocketThreadChann // // } } - + private async Task GatewayOnThreadUpdated(Cacheable before, SocketThreadChannel after) { if (!after.IsThreadInChannel(_helpChannel.Id)) @@ -296,16 +286,14 @@ private async Task GatewayOnThreadUpdated(Cacheable } LoggingService.DebugLog($"[{ServiceName}] Thread Updated: {after.Id} - {after.Name}", LogSeverity.Debug); - -#pragma warning disable CS4014 - Task.Run(() => OnThreadUpdated(beforeThread, afterThread)); -#pragma warning restore CS4014 + + OnThreadUpdated(beforeThread, afterThread).SafeFireAndForget(ServiceName); } - + #endregion // Thread Update #region Thread Deleted - + private async Task OnThreadDeleted(SocketThreadChannel channel) { await EndThreadTracking(channel.Id); @@ -315,24 +303,22 @@ private async Task GatewayOnThreadDeleted(Cacheable { if (!_activeThreads.ContainsKey(threadId.Id)) return; - + LoggingService.DebugLog($"[{ServiceName}] Thread Deleted: {threadId.Id}", LogSeverity.Debug); var thread = await threadId.GetOrDownloadAsync(); -#pragma warning disable CS4014 - Task.Run(() => OnThreadDeleted(thread)); -#pragma warning restore CS4014 + OnThreadDeleted(thread).SafeFireAndForget(ServiceName); } - + #endregion // Thread Deleted #region User Joins/Leaves Thread - + private Task GatewayOnThreadMemberJoinedThread(SocketThreadUser user) { if (user.IsUserBotOrWebhook()) return Task.CompletedTask; - + if (!user.Thread.IsThreadInChannel(_helpChannel.Id)) return Task.CompletedTask; if (!_activeThreads.TryGetValue(user.Thread.Id, out var thread)) @@ -346,27 +332,25 @@ private Task GatewayOnThreadMemberLeftThread(SocketThreadUser user) { if (!user.Thread.IsThreadInChannel(_helpChannel.Id)) return Task.CompletedTask; - + return Task.CompletedTask; // TODO : (James) Check if user was author? If so, close thread? } - + #endregion // User Joins/Leaves Thread #region Message Received - + private async Task OnMessageReceived(SocketMessage message) { var thread = _activeThreads[message.Channel.Id]; - + thread.LatestUserMessage = message.Id; // If Author is only one who has interacted with the thread, we don't need to update anything else if (!thread.HasInteraction && message.Author.Id == thread.Owner) { -#pragma warning disable CS4014 - Task.Run(() => StealthDeleteThreadInTime(thread)); -#pragma warning restore CS4014 + StealthDeleteThreadInTime(thread).SafeFireAndForget(ServiceName); return; } @@ -382,7 +366,7 @@ private async Task OnMessageReceived(SocketMessage message) await RequestThreadShutdownInTime(thread, HasResponseMessageRequestClose + HasResponseExtraMessage, HasResponseIdleTimeOtherUser); } } - + private Task GatewayOnMessageReceived(SocketMessage message) { if (!message.Channel.IsThreadInChannel(_helpChannel.Id)) @@ -391,16 +375,16 @@ private Task GatewayOnMessageReceived(SocketMessage message) return Task.CompletedTask; if (!_activeThreads.TryGetValue(message.Channel.Id, out var thread)) return Task.CompletedTask; - + LoggingService.DebugLog($"[{ServiceName}] Help Message Received: {message.Id} - {message.Content}", LogSeverity.Debug); - Task.Run(() => OnMessageReceived(message)); + OnMessageReceived(message).SafeFireAndForget(ServiceName); return Task.CompletedTask; } private async Task OnMessageUpdated(IMessage before, IMessage after, SocketThreadChannel channel) { var thread = _activeThreads[channel.Id]; - + if (thread.HasMessage(HelpMessageType.QuestionLength) && before.Id == thread.FirstUserMessage) { if (after.Content.Length > MinimumLengthMessage) @@ -412,7 +396,7 @@ private async Task OnMessageUpdated(IMessage before, IMessage after, SocketThrea } } } - + private async Task GatewayOnMessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) { if (channel is not SocketThreadChannel threadChannel) @@ -421,22 +405,20 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So return; if (after.Author.IsUserBotOrWebhook()) return; - + if (!_activeThreads.TryGetValue(channel.Id, out var thread)) return; - + // This is done a bit late as we may need to check message from other authors if (thread.Owner != after.Author.Id) return; - + var beforeMsg = await before.GetOrDownloadAsync(); if (beforeMsg == null) return; LoggingService.DebugLog($"[{ServiceName}] Help Message Updated: {after.Id} - {after.Content}", LogSeverity.Debug); -#pragma warning disable CS4014 - Task.Run(() => OnMessageUpdated(beforeMsg, after, channel as SocketThreadChannel)); -#pragma warning restore CS4014 + OnMessageUpdated(beforeMsg, after, (channel as SocketThreadChannel)!).SafeFireAndForget(ServiceName); if (after.Reactions.ContainsKey(CloseEmoji)) { @@ -450,7 +432,7 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So } #endregion // Message Received - + #endregion // Thread Tracking #region Event Handlers @@ -465,29 +447,22 @@ private async Task OnReactionAdded(Cacheable messageCache, if (message == null || message.Author.Id != _client.CurrentUser.Id) return; -#pragma warning disable CS4014 - Task.Run(async () => -#pragma warning restore CS4014 - { - // Check the owner is the one reacting - var threadOwner = channel.Owner.Id; - if (reaction.UserId != threadOwner) - return; + if (reaction.UserId != channel.Owner.Id) + return; - await CloseThread(channel, true); - }); + CloseThread(channel, true).SafeFireAndForget(ServiceName); } - + public async Task OnUserRequestChannelClose(IUser user, SocketThreadChannel channel) { if (channel.ParentChannel.Id != _helpChannel.Id) return string.Empty; if (!_activeThreads.TryGetValue(channel.Id, out var thread)) return string.Empty; - + if (thread.Owner != user.Id) return string.Empty; - + await CloseThread(channel, true); return "Your thread has been closed."; } @@ -496,16 +471,16 @@ public async Task OnUserRequestChannelClose(IUser user, SocketThreadChan #region Bulk Behaviour Handler - private async Task CloseThreadInTime(ThreadContainer thread, string message, int minutes, Embed embed = null) + private async Task CloseThreadInTime(ThreadContainer thread, string message, int minutes, Embed? embed = null) { await Task.Delay(TimeSpan.FromMinutes(minutes)); if (thread.HasInteraction) return; - + var channel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; if (channel == null) return; - + if (!string.IsNullOrEmpty(message)) await channel.SendMessageAsync(message); else @@ -516,19 +491,23 @@ private async Task CloseThreadInTime(ThreadContainer thread, string message, int if (!(await IsValidThread(thread))) return; - + var expectedShutdownTime = DateTime.Now.AddMinutes(minutes); var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; // Check if token already created, each thread shares its own token with any relevant action (close, delete, etc) await CancelPreviousWarning(thread, expectedShutdownTime); - + thread.CancellationToken ??= new CancellationTokenSource(); + if (threadChannel == null) + return; + // Send our message if (!string.IsNullOrEmpty(message)) { await threadChannel.SendMessageAsync(message); } thread.ExpectedShutdownTime = expectedShutdownTime; + // Wait for the time to pass await Task.Delay(minutes * 60 * 1000, thread.CancellationToken.Token); if (await IsTaskCancelled(thread)) return; @@ -540,25 +519,27 @@ private async Task RequestThreadShutdownInTime(ThreadContainer thread, string ms { if (!(await IsValidThread(thread))) return; - + var expectedWarnTime = DateTime.Now.AddMinutes(minutes); var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; // Check if token already created, each thread shares its own token with any relevant action (close, delete, etc) await CancelPreviousWarning(thread, expectedWarnTime); thread.CancellationToken ??= new CancellationTokenSource(); - + if (threadChannel == null) + return; + thread.ExpectedShutdownTime = expectedWarnTime; await Task.Delay(minutes * 60 * 1000, thread.CancellationToken.Token); if (await IsTaskCancelled(thread)) return; - + msgString = string.Format(msgString, threadChannel.Owner.Mention); var sentMessage = await threadChannel.SendMessageAsync(msgString); // add the lock reaction await sentMessage.AddReactionAsync(CloseEmoji); thread.LatestUserMessage = sentMessage.Id; } - + /// /// When a thread is first started, this is called first to set it up to be closed after a certain amount of time /// This will quickly be canceled if the thread is interacted with. @@ -569,13 +550,15 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) return; var expectedShutdownTime = DateTime.Now.AddMinutes(NoResponseNotResolvedIdleTime); - + await CancelPreviousWarning(thread, expectedShutdownTime); var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; thread.CancellationToken ??= new CancellationTokenSource(); + if (threadChannel == null) + return; + thread.ExpectedShutdownTime = expectedShutdownTime; - // Wait for the time to pass await Task.Delay(NoResponseNotResolvedIdleTime * 60 * 1000, thread.CancellationToken.Token); if (await IsTaskCancelled(thread)) return; @@ -583,7 +566,7 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) // We prompt chat that the thread is going to be deleted in x number of hours, which will double as a bump. var botResponse = await threadChannel.SendMessageAsync(embed: _stealthDeleteEmbed); thread.BotsLastMessage = botResponse.Id; - + // Wait for the next set of time to pass thread.ExpectedShutdownTime = DateTime.Now.AddMinutes(StealthDeleteTime); await Task.Delay(StealthDeleteTime * 60 * 1000, thread.CancellationToken.Token); @@ -594,10 +577,10 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) } #endregion // Bulk Behaviour Handler - - + + #region Generic Methods - + private async Task CloseThread(IThreadChannel channel, bool includeResolvedTag = false) { var appliedTags = channel.AppliedTags.ToList(); @@ -639,14 +622,14 @@ private async Task CancelPreviousWarning(ThreadContainer thread, DateTime newShu await RemoveContainerPreviousComment(thread); } } - + private async Task> GetHelpActiveThreads() { var messages = await _helpChannel.GetActiveThreadsAsync(); var helpThreads = messages.Where(x => x.CategoryId == _helpChannel.Id).ToList(); return helpThreads; } - + public async Task MarkResponseAsAnswer(IUser requester, IMessage message) { if (message.Channel is not IThreadChannel channel) @@ -681,7 +664,7 @@ public async Task MarkResponseAsAnswer(IUser requester, IMessage message if (!thread.IsResolved) await CloseThread(channel, true); - + thread.PinnedAnswer = message.Id; return "New answer pinned"; } @@ -715,7 +698,7 @@ private async Task IsValidThread(ThreadContainer thread) } return true; } - + private Task IsTaskCancelled(ThreadContainer thread) { if (thread.CancellationToken == null) @@ -727,27 +710,27 @@ private Task IsTaskCancelled(ThreadContainer thread) } return Task.FromResult(false); } - + // Check if the user is the expected id and return true if so, if not then return false (Special: Moderator will return true) - private bool IsValidAuthorUser(SocketGuildUser user, ulong authorId) + private bool IsValidAuthorUser(SocketGuildUser? user, ulong authorId) { if (user == null || user.IsUserBotOrWebhook()) return false; - + if (user.Id == authorId) return true; // If the user is moderator they can act on behalf of the author if (user.HasRoleGroup(ModeratorRole)) return true; - + return false; } - + public int GetTrackedQuestionCount() { return _activeThreads.Count; } #endregion // Utility Methods - + } diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index f2f8b199..7c9cf674 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Text; using Discord.Commands; using Discord.Interactions; @@ -13,19 +13,19 @@ namespace DiscordBot.Services; public class CommandHistoryInfo { - public string Command { get; set; } - public string User { get; set; } + public string Command { get; set; } = null!; + public string User { get; set; } = null!; public ulong UserId { get; set; } - public string Channel { get; set; } + public string Channel { get; set; } = null!; public DateTime Time { get; set; } - public string Error { get; set; } = string.Empty; + public string? Error { get; set; } = string.Empty; } public class CommandHandlingService { private const string ServiceName = "CommandHandlingService"; public bool IsInitialized { get; private set; } - + private readonly DiscordSocketClient _client; private readonly CommandService _commandService; private readonly InteractionService _interactionService; @@ -39,7 +39,7 @@ public class CommandHandlingService // Tuple of string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true for a dictionary private readonly Dictionary<(string moduleName, bool orderByName, bool includeArgs, bool includeModuleName), string> _commandList = new(); private readonly Dictionary<(string moduleName, bool orderByName, bool includeArgs, bool includeModuleName), List> _commandListMessages = new(); - + // A Collection to store the command history private const int MaxCommandHistory = 200; private readonly List _commandHistory = new List(MaxCommandHistory); @@ -60,15 +60,15 @@ ILoggingService loggingService _loggingService = loggingService; // Events - _client.MessageReceived += HandleCommand; - _client.InteractionCreated += HandleInteraction; - + _client.MessageReceived += EventGuard.Guarded(HandleCommand, nameof(HandleCommand)); + _client.InteractionCreated += EventGuard.Guarded(HandleInteraction, nameof(HandleInteraction)); + if (settings.GuildId == default) { _loggingService.Log(LogBehaviour.Console | LogBehaviour.File, $"{ServiceName}: GuildId not set, commands will not be registered.", ExtendedLogSeverity.Critical); return; } - + _commandPrefix = settings.Prefix; if (_commandPrefix == default) { @@ -98,7 +98,7 @@ ILoggingService loggingService await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.AutocompleteCommands.Count)} 'AutoComplete' commands.", ExtendedLogSeverity.Positive); await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.ModalCommands.Count)} 'Modal' commands.", ExtendedLogSeverity.Positive); await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.ComponentCommands.Count)} 'Component' commands.", ExtendedLogSeverity.Positive); - + //TODO Consider global commands? Maybe an attribute? await _interactionService.RegisterCommandsToGuildAsync(settings.GuildId); @@ -110,16 +110,16 @@ ILoggingService loggingService } }); } - + #region Command Lists - + /// Generates a command list that can provide users with information. Commands require [Command][Summary] and [Priority](If not ordering by name) /// The results are cached, so this method can be called frequently without performance issues. /// List of strings that can be sent to the user without worry of being over the message length limit. public List GetCommandListMessages(string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true) { var tupleKey = (moduleName, orderByName, includeArgs, includeModuleName); - if (!_commandListMessages.TryGetValue(tupleKey, out List commandResults)) + if (!_commandListMessages.TryGetValue(tupleKey, out List? commandResults)) { GenerateCommandListOutputs(tupleKey); commandResults = _commandListMessages[tupleKey]; @@ -133,31 +133,31 @@ public List
, but we need to strip out tooltips - descriptionNode = descriptionNode.SelectSingleNode("following-sibling::p"); - descriptionNode.Descendants().Where(n => n.GetAttributeValue("class", "").Contains("tooltip")).ToList().ForEach(n => n.Remove()); - var description = descriptionNode.InnerText; - - embedBuilder.WithDescription($"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + $"[Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)"); - await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); - } - else - await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); - } - - [Command("Doc"), Priority(9)] - [Summary("Searches Unity3D API for results. Syntax : !api \"query\"")] - [Alias("ref", "reference", "api", "docs")] - public async Task SearchApi(params string[] queries) - { - // Download Unity3D Documentation Database (lol) - - // Calculate the closest match to the input query - var minimumScore = double.MaxValue; - string[] mostSimilarPage = null; - var pages = await UpdateService.GetApiDatabase(); - var query = string.Join(" ", queries); - foreach (var p in pages) - { - var curScore = CalculateScore(p[1], query); - if (!(curScore < minimumScore)) continue; - - minimumScore = curScore; - mostSimilarPage = p; - } - - // If a page has been found (should be), return the message, else return information - if (mostSimilarPage != null) - { - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = $"Found {mostSimilarPage[0]}"; - embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; - embedBuilder.Color = new Color(81, 50, 169); - embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); - var message = await ReplyAsync(embed: embedBuilder.Build()); - - // Load the page, and look for a
tag - var doc = new HtmlWeb().Load($"https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html"); - var descriptionNode = doc.DocumentNode.SelectSingleNode("//h3[contains(text(), 'Description')]"); - - string descriptionString = ""; - string manualLinkString = ""; - if (descriptionNode != null) - { - var description = descriptionNode.SelectSingleNode("following-sibling::p").InnerText; - descriptionString = - $"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + - $"[Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; - - } - - // We check the page for the first "switch-link" class, which will be a link to a Manual page - var manualLink = doc.DocumentNode.SelectSingleNode("//a[contains(@class, 'switch-link')]"); - if (manualLink != null && manualLink.Attributes.Contains("title")) - { - var manualLinkText = manualLink.GetAttributes("title").First().Value; - var manualLinkUrl = "https://docs.unity3d.com/" + manualLink.GetAttributeValue("href", ""); - manualLinkString = $"\n**Manual:** [{manualLinkText}]({manualLinkUrl})"; - } - - embedBuilder.WithDescription(descriptionString + manualLinkString); - await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); - } - else - await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); - } - - private double CalculateScore(string s1, string s2) - { - double curScore = 0; - var i = 0; - - foreach (var q in s1.Split(' ')) - { - foreach (var x in s2.Split(' ')) - { - i++; - if (x.Equals(q)) - curScore -= 50; - else - curScore += x.CalculateLevenshteinDistance(q); - } - } - - curScore /= i; - return curScore; - } - - [Command("FAQ")] - [Summary("Searches UDC FAQs. Syntax : !faq \"query\"")] - public async Task SearchFaqs(params string[] queries) - { - var faqDataList = UpdateService.GetFaqData(); - - // Check if query is faq ID (e.g. "!faq 1") - if (queries.Length == 1 && ParseNumber(queries[0]) > 0) - { - var id = ParseNumber(queries[0]) - 1; - if (id < faqDataList.Count) - await ReplyAsync(embed: GetFaqEmbed(faqDataList[id])); - else - await ReplyAsync("Invalid FAQ ID selected."); - } - // Check if query contains "list" command (i.e. "!faq list") - else if (queries.Length > 0 && !(queries.Length == 1 && queries[0].Equals("list"))) - { - // Calculate the closest match to the input query - var minimumScore = double.MaxValue; - FaqData mostSimilarFaq = null; - var query = string.Join(" ", queries); - - // Go through each FAQ in the list and check the most similar - foreach (var faq in faqDataList) - { - foreach (var keyword in faq.Keywords) - { - var curScore = CalculateScore(keyword, query); - if (curScore < minimumScore) - { - minimumScore = curScore; - mostSimilarFaq = faq; - } - } - } - - // If an FAQ has been found (should be), return the FAQ, else return information msg - if (mostSimilarFaq != null) - await ReplyAsync(embed: GetFaqEmbed(mostSimilarFaq)); - else - await ReplyAsync("No FAQs Found."); - } - else - // List all the FAQs available - await ListFaqs(faqDataList); - } - - private async Task ListFaqs(List faqs) - { - var sb = new StringBuilder(faqs.Count); - var index = 1; - var keywordSb = new StringBuilder(); - foreach (var faq in faqs) - { - sb.Append(FormatFaq(index, faq) + "\n"); - keywordSb.Append("["); - for (var i = 0; i < faq.Keywords.Length; i++) - { - keywordSb.Append(faq.Keywords[i]); - keywordSb.Append(i < faq.Keywords.Length - 1 ? ", " : "]\n\n"); - } - - index++; - sb.Append(keywordSb); - keywordSb.Clear(); - } - - await ReplyAsync(sb.ToString()).DeleteAfterTime(minutes: 3); - } - - private Embed GetFaqEmbed(FaqData faq) - { - var builder = new EmbedBuilder() - .WithTitle($"{faq.Question}") - .WithDescription($"{faq.Answer}") - .WithColor(new Color(0x33CC00)); - return builder.Build(); - } - - private string FormatFaq(int id, FaqData faq) => $"{id}. **{faq.Question}** - {faq.Answer}"; - - [Command("Wiki"), Priority(26)] - [Summary("Searches Wikipedia. Syntax : !wiki \"query\"")] - [Alias("wikipedia")] - public async Task SearchWikipedia([Remainder] string query) - { - var article = await UpdateService.DownloadWikipediaArticle(query); - - // If an article is found return it, else return error message - if (article.url == null) - { - await ReplyAsync($"No Articles for \"{query}\" were found."); - return; - } - - await ReplyAsync(embed: GetWikipediaEmbed(article.name, article.extract, article.url)); - } - - private Embed GetWikipediaEmbed(string subject, string articleExtract, string articleUrl) - { - var builder = new EmbedBuilder() - .WithTitle($"Wikipedia | {subject}") - .WithDescription($"{articleExtract}") - .WithUrl(articleUrl) - .WithColor(new Color(0x33CC00)); - return builder.Build(); - } - - private int ParseNumber(string s) - { - int id; - if (int.TryParse(s, out id)) return id; - - return -1; - } - - #endregion - - #region Birthday - - [Command("Birthday"), HideFromHelp] - [Summary("Display next member birthday.")] - [Alias("bday")] - public async Task Birthday() - { - // URL to cell C15/"Next birthday" cell from Corn's google sheet - const string nextBirthday = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; - - var tableText = await WebUtil.GetHtmlNodeInnerText(nextBirthday, "/html/body/table/tr[2]/td"); - var message = $"**{tableText}**"; - - await ReplyAsync(message).DeleteAfterTime(minutes: 3); - await Context.Message.DeleteAfterTime(minutes: 3); - } - - [Command("Birthday"), Priority(27)] - [Summary("Display birthday of mentioned user. Syntax : !birthday @user")] - [Alias("bday")] - public async Task Birthday(IUser user) - { - var searchName = user.Username; - // URL to columns B to D of Corn's google sheet - const string birthdayTable = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; - var relevantNodes = await WebUtil.GetHtmlNodes(birthdayTable, "/html/body/table/tr"); - - var birthdate = default(DateTime); - - HtmlNode matchedNode = null; - var matchedLength = int.MaxValue; - - // XPath to each table row - foreach (var row in relevantNodes) - { - // XPath to the name column (C) - var nameNode = row.SelectSingleNode("td[2]"); - var name = nameNode.InnerText; - - if (!name.ToLower().Contains(searchName.ToLower()) || name.Length >= matchedLength) - continue; - - // Check for a "Closer" match - matchedNode = row; - matchedLength = name.Length; - // Nothing will match "Better" so we may as well break out - if (name.Length == searchName.Length) break; - } - - if (matchedNode != null) - { - // XPath to the date column (B) - var dateNode = matchedNode.SelectSingleNode("td[1]"); - // XPath to the year column (D) - var yearNode = matchedNode.SelectSingleNode("td[3]"); - - var provider = CultureInfo.InvariantCulture; - var wrongFormat = "M/d/yyyy"; - //string rightFormat = "dd-MMMM-yyyy"; - - var dateString = dateNode.InnerText; - if (!yearNode.InnerText.Contains(" ")) dateString = dateString + "/" + yearNode.InnerText; - - dateString = dateString.Trim(); - - try - { - // Converting the birthdate from the wrong format to the right format WITH year - birthdate = DateTime.ParseExact(dateString, wrongFormat, provider); - } - catch (FormatException) - { - // Converting the birthdate from the wrong format to the right format WITHOUT year - birthdate = DateTime.ParseExact(dateString, "M/d", provider); - } - } - - // Business as usual - if (birthdate == default) - { - await ReplyAsync( - $"Sorry, I couldn't find **{searchName}**'s birthday date. They can add it at https://docs.google.com/forms/d/e/1FAIpQLSfUglZtJ3pyMwhRk5jApYpvqT3EtKmLBXijCXYNwHY-v-lKxQ/viewform !") - .DeleteAfterSeconds(30); - } - else - { - var date = birthdate.ToUnixTimestamp(); - var message = - $"**{searchName}**'s birthdate: __**{birthdate.ToString("dd MMMM yyyy", CultureInfo.InvariantCulture)}**__ " + - $"({(int)((DateTime.Now - birthdate).TotalDays / 365)}yo)"; - - await ReplyAsync(message).DeleteAfterTime(minutes: 3); - } - - await Context.Message.DeleteAfterTime(minutes: 3); - } - - #endregion - - #region Temperatures - - [Command("FtoC"), Priority(28)] - [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")] - public async Task FahrenheitToCelsius(float f) - { - await ReplyAsync($"{Context.User.Mention} {f}°F is {MathUtility.FahrenheitToCelsius(f)}°C."); - } - - [Command("CtoF"), Priority(28)] - [Summary("Converts a temperature in celsius to fahrenheit. Syntax : !ftoc temperature")] - public async Task CelsiusToFahrenheit(float c) - { - await ReplyAsync($"{Context.User.Mention} {c}°C is {MathUtility.CelsiusToFahrenheit(c)}°F"); - } - - #endregion - - #region Translate - - [Command("Translate"), HideFromHelp] - [Summary("Translate a message. Syntax : !translate messageId language")] - public async Task Translate(ulong messageId, string language = "en") - { - await Translate((await Context.Channel.GetMessageAsync(messageId)).Content, language); - } - - [Command("Translate"), HideFromHelp] - [Summary("Translate a message. Syntax : !translate text language")] - public async Task Translate(string text, string language = "en") - { - var msg = await ReplyAsync($"Here: "); - await Context.Message.DeleteAfterSeconds(seconds: 1); - await msg.DeleteAfterSeconds(seconds: 20); - } - - #endregion - - #region Currency - - [Command("CurrencyName"), Priority(29)] - [Summary("Get the name of a currency. Syntax : !currname USD")] - [Alias("currname")] - public async Task CurrencyName(string currency) - { - if (Context.HasAnyPingableMention()) - return; - var name = await CurrencyService.GetCurrencyName(currency); - if (name == string.Empty) - { - await Context.Message.ReplyAsync($"Sorry, I couldn't find the name of the currency **{currency}**."); - return; - } - await Context.Message.ReplyAsync($"The name of the currency **{currency.ToUpper()}** is **{name}**."); - } - - [Command("Currency"), HideFromHelp] - [Summary("Converts a currency. Syntax : !currency fromCurrency toCurrency")] - [Alias("curr")] - public async Task ConvertCurrency(string from, string to = "usd") - { - await ConvertCurrency(1, from, to); - } - - [Command("Currency"), Priority(29)] - [Summary("Converts a currency. Syntax : !currency amount fromCurrency toCurrency")] - [Alias("curr")] - public async Task ConvertCurrency(double amount, string from, string to = "usd") - { - if (Context.HasAnyPingableMention()) - { - // Only continue command if the user is replying to a message - if (!Context.IsReply()) - return; - // And that mention is only the author of the replied message - if (!Context.IsOnlyReplyingToAuthor()) - return; - } - - from = from.ToLower(); - to = to.ToLower(); - - // We check if both currencies are valid - bool fromValid = await CurrencyService.IsCurrency(from.ToLower()); - bool toValid = await CurrencyService.IsCurrency(to.ToLower()); - - // Check if valid - if (!fromValid || !toValid) - { - await Context.Message.ReplyAsync("One of the currencies provided is invalid."); - return; - } - - var response = await CurrencyService.GetConversion(to, from); - if (Math.Abs(response - (-1)) < 0.01) - { - await Context.Message.ReplyAsync("An error occured while converting the currency, the API may be down!"); - return; - } - - var totalAmount = Math.Round(amount * response, 2); - await Context.Message.ReplyAsync($"**{amount} {from.ToUpper()}** = **{totalAmount} {to.ToUpper()}**"); - } - - #endregion -} diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs deleted file mode 100644 index 1d2febd2..00000000 --- a/DiscordBot/Modules/UserSlashModule.cs +++ /dev/null @@ -1,544 +0,0 @@ -using System.Collections.Concurrent; -using Discord.Interactions; -using DiscordBot.Services; -using DiscordBot.Settings; - -namespace DiscordBot.Modules; - -// For commands that only require a single interaction, these can be done automatically and don't require complex setup or configuration. -// ie; A command that might just return the result of a service method such as Ping, or Welcome -public class UserSlashModule : InteractionModuleBase -{ - #region Dependency Injection - - public CommandHandlingService CommandHandlingService { get; set; } - public UserService UserService { get; set; } - public BotSettings BotSettings { get; set; } - public ILoggingService LoggingService { get; set; } - - #endregion - - #region Help - - [SlashCommand("help", "Shows available commands")] - private async Task Help(string search = "") - { - await Context.Interaction.DeferAsync(ephemeral: true); - - var helpEmbed = HelpEmbed(0, search); - if (helpEmbed.Item1 >= 0) - { - ComponentBuilder builder = new(); - builder.WithButton("Next Page", $"user_module_help_next:{0}"); - - await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true, - components: builder.Build()); - } - else - { - await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true); - } - } - - [ComponentInteraction("user_module_help_next:*")] - private async Task InteractionHelp(string pageString) - { - await Context.Interaction.DeferAsync(ephemeral: true); - - int page = int.Parse(pageString); - - var helpEmbed = HelpEmbed(page + 1); - ComponentBuilder builder = new(); - builder.WithButton("Next Page", $"user_module_help_next:{helpEmbed.Item1}"); - - await Context.Interaction.ModifyOriginalResponseAsync(msg => - { - msg.Components = builder.Build(); - msg.Embed = helpEmbed.Item2; - }); - } - - // Returns an embed with the help text for a module, if the page is outside the bounds (high) it will return to the first page. - private (int, Embed) HelpEmbed(int page, string search = "") - { - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = "User Module Commands"; - embedBuilder.Color = Color.LighterGrey; - - List helpMessages = null; - if (search == string.Empty) - { - helpMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); - - if (page >= helpMessages.Count) - page = 0; - else if (page < 0) - page = helpMessages.Count - 1; - - embedBuilder.WithFooter(text: $"Page {page + 1} of {helpMessages.Count}"); - embedBuilder.Description = helpMessages[page]; - } - else - { - // We need search results which we don't cache, so we don't want to provide a page number - page = -1; - helpMessages = CommandHandlingService.SearchForCommand(("UserModule", false, true, false), search); - if (helpMessages[0].Length > 0) - { - embedBuilder.WithFooter(text: $"Search results for {search}"); - embedBuilder.Description = helpMessages[0]; - } - else - { - embedBuilder.WithFooter(text: $"No results for {search}"); - embedBuilder.Description = "No commands found"; - } - } - - return (page, embedBuilder.Build()); - } - - #endregion - - [SlashCommand("welcome", "An introduction to the server!")] - public async Task SlashWelcome() - { - await Context.Interaction.RespondAsync(string.Empty, - embed: UserService.GetWelcomeEmbed(Context.User.Username), ephemeral: true); - } - - [SlashCommand("ping", "Bot latency")] - public async Task Ping() - { - await Context.Interaction.RespondAsync("Bot latency: ...", ephemeral: true); - await Context.Interaction.ModifyOriginalResponseAsync(m => - m.Content = $"Bot latency: {UserService.GetGatewayPing().ToString()}ms"); - } - - [SlashCommand("invite", "Returns the invite link for the server.")] - public async Task ReturnInvite() - { - await Context.Interaction.RespondAsync(text: BotSettings.Invite, ephemeral: true); - } - - #region Moderation - - [MessageCommand("Report Message")] - public async Task ReportMessage(IMessage reportedMessage) - { - if (reportedMessage.Author.Id == Context.User.Id) - { - await Context.Interaction.RespondAsync(text: "You can't report your own messages!", ephemeral: true); - return; - } - if (reportedMessage.Author.IsBot) // Don't report bots - { - await Context.Interaction.RespondAsync(text: "You can't report bot messages!", ephemeral: true); - return; - } - if (reportedMessage.Author.IsWebhook) // Don't report webhooks - { - await Context.Interaction.RespondAsync(text: "You can't report webhook messages!", ephemeral: true); - return; - } - await Context.Interaction.RespondWithModalAsync($"report_{reportedMessage.Id}"); - } - - // Defines the modal that will be sent. - public class ReportMessageModal : IModal - { - public string Title => "Report a message"; - - // Additional parameters can be specified to further customize the input. - [InputLabel("Reason")] - [ModalTextInput("report_reason", TextInputStyle.Paragraph, maxLength: 500)] - public string Reason { get; set; } - } - - // Responds to the modal. - [ModalInteraction("report_*")] - public async Task ModalResponse(ulong id, ReportMessageModal modal) - { - var reportedMessage = await Context.Channel.GetMessageAsync(id); - - var reportedMessageChannel = await Context.Guild.GetTextChannelAsync(BotSettings.ReportedMessageChannel.Id); - if (reportedMessageChannel == null) - return; - - var embed = new EmbedBuilder() - .WithColor(new Color(0xFF0000)) - .WithDescription(reportedMessage.Content) - .WithTimestamp(reportedMessage.Timestamp) - .WithFooter(footer => - { - footer - .WithText($"Reported by {Context.User.GetPreferredAndUsername()} • From channel {reportedMessage.Channel.Name}") - .WithIconUrl(Context.User.GetAvatarUrl()); - }) - .AddAuthor(reportedMessage.Author); - - embed.Description += $"\n\n***[Linkback]({reportedMessage.GetJumpUrl()})***"; - - if (reportedMessage.Attachments.Count > 0) - { - var attachments = reportedMessage.Attachments.Select(a => a.Url).ToList(); - string attachmentString = string.Empty; - for (int i = 0; i < attachments.Count; i++) - { - attachmentString += $"• {attachments[i]}"; - if (i < attachments.Count - 1) - attachmentString += "\n"; - } - embed.AddField("Attachments", attachmentString); - } - embed.AddField("Reason", modal.Reason); - - await reportedMessageChannel.SendMessageAsync(string.Empty, embed: embed.Build()); - await RespondAsync("Message has been reported.", ephemeral: true); - } - - #endregion // Moderation - - #region User Roles - - [SlashCommand("roles", "Give or Remove roles for yourself (Programmer, Artist, Designer, etc)")] - public async Task UserRoles() - { - await Context.Interaction.DeferAsync(ephemeral: true); - - ComponentBuilder builder = new(); - - foreach (var userRole in BotSettings.UserAssignableRoles.Roles) - { - builder.WithButton(userRole, $"user_role_add:{userRole}"); - } - - builder.Build(); - - await Context.Interaction.FollowupAsync(text: "Click any role that applies to you!", embed: null, - ephemeral: true, components: builder.Build()); - } - - [ComponentInteraction("user_role_add:*")] - public async Task UserRoleAdd(string role) - { - await Context.Interaction.DeferAsync(ephemeral: true); - - var user = Context.User as IGuildUser; - var guild = Context.Guild; - - // Try get the role from the guild - var roleObj = guild.Roles.FirstOrDefault(r => r.Name == role); - if (roleObj == null) - { - await Context.Interaction.ModifyOriginalResponseAsync(msg => - msg.Content = $"Failed to add role {role}, role not found."); - return; - } - // We make sure the role is in our UserAssignableRoles just in case - if (BotSettings.UserAssignableRoles.Roles.Contains(roleObj.Name)) - { - if (user.RoleIds.Contains(roleObj.Id)) - { - await user.RemoveRoleAsync(roleObj); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - msg.Content = $"{roleObj.Name} has been removed!"); - } - else - { - await user.AddRoleAsync(roleObj); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - msg.Content = $"You now have the {roleObj.Name} role!"); - } - } - } - - #endregion - - #region Duel System - - private static readonly ConcurrentDictionary _activeDuels = new ConcurrentDictionary(); - private static readonly Random _random = new Random(); - - private static readonly string[] _normalWinMessages = - { - "{winner} lands a solid hit on {loser} and wins the duel!", - "{winner} uses their sword to attack {loser}, but {loser} fails to dodge and {winner} wins!", - "{winner} outmaneuvers {loser} with a swift strike and claims victory!", - "{winner} blocks {loser}'s attack and counters with a decisive blow!", - "{winner} dodges {loser}'s clumsy swing and delivers the winning hit!", - "{winner} parries {loser}'s blade and strikes back to win the duel!", - "{winner} feints left, strikes right, and defeats {loser}!", - "{winner} overwhelms {loser} with superior technique and emerges victorious!" - }; - - [SlashCommand("duel", "Challenge another user to a duel!")] - public async Task Duel( - [Summary(description: "The user you want to duel")] IUser opponent, - [Summary(description: "Type of duel")] - [Choice("Normal", "normal")] - [Choice("Mute", "mute")] - string type = "normal") - { - // Prevent self-dueling - if (opponent.Id == Context.User.Id) - { - await Context.Interaction.RespondAsync("You cannot duel yourself!", ephemeral: true); - return; - } - - // Prevent dueling bots - if (opponent.IsBot) - { - await Context.Interaction.RespondAsync("You cannot duel a bot!", ephemeral: true); - return; - } - - // Check for active duel - string duelKey = $"{Context.User.Id}_{opponent.Id}"; - string reverseDuelKey = $"{opponent.Id}_{Context.User.Id}"; - - if (_activeDuels.ContainsKey(duelKey) || _activeDuels.ContainsKey(reverseDuelKey)) - { - await Context.Interaction.RespondAsync("There's already an active duel between you two!", ephemeral: true); - return; - } - - // Store the duel with both user IDs for timeout tracking - _activeDuels[duelKey] = (Context.User.Id, opponent.Id); - - var embed = new EmbedBuilder() - .WithColor(Color.Orange) - .WithTitle("⚔️ Duel Challenge!") - .WithDescription($"{Context.User.Mention} has challenged {opponent.Mention} to a duel!") - .WithFooter($"This challenge will expire in 60 seconds"); - - if (type == "mute") - { - embed.AddField("Risk", "The loser will be muted for 5 minutes."); - } - - var components = new ComponentBuilder() - .WithButton("⚔️ Accept", $"duel_accept:{duelKey}:{type}", ButtonStyle.Success) - .WithButton("🛡️ Refuse", $"duel_refuse:{duelKey}", ButtonStyle.Danger) - .WithButton("❌ Cancel", $"duel_cancel:{duelKey}", ButtonStyle.Secondary) - .Build(); - - await Context.Interaction.RespondAsync(embed: embed.Build(), components: components); - - // Store the message reference for timeout - var originalResponse = await Context.Interaction.GetOriginalResponseAsync(); - - // Auto-timeout after 60 seconds - _ = Task.Run(async () => - { - await Task.Delay(60000); // 60 seconds - if (_activeDuels.ContainsKey(duelKey)) - { - var (challengerId, opponentId) = _activeDuels[duelKey]; - _activeDuels.TryRemove(duelKey, out _); - - try - { - var challenger = await Context.Guild.GetUserAsync(challengerId); - var challengedUser = await Context.Guild.GetUserAsync(opponentId); - - string timeoutMessage = challengedUser != null - ? $"⏰ Duel challenge to {challengedUser.Mention} expired." - : "⏰ Duel challenge expired."; - - await originalResponse.ModifyAsync(msg => - { - msg.Content = string.Empty; - msg.Embed = new EmbedBuilder() - .WithColor(Color.LightGrey) - .WithDescription(timeoutMessage) - .Build(); - msg.Components = new ComponentBuilder().Build(); - }); - } - catch (Exception ex) - { - await LoggingService.LogChannelAndFile($"Failed to modify duel timeout message: {ex.Message}", ExtendedLogSeverity.Warning); - } - } - }); - } - - [ComponentInteraction("duel_accept:*:*")] - public async Task DuelAccept(string duelKey, string type) - { - // Extract user IDs from the duel key - var userIds = duelKey.Split('_'); - if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) - { - await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); - return; - } - - // Only the challenged user can accept - if (Context.User.Id != opponentId) - { - await Context.Interaction.RespondAsync("Only the challenged user can accept this duel!", ephemeral: true); - return; - } - - // Check if duel is still active - if (!_activeDuels.ContainsKey(duelKey)) - { - await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); - return; - } - - // Remove from active duels - _activeDuels.TryRemove(duelKey, out _); - - await Context.Interaction.DeferAsync(); - - // Get users - var challenger = await Context.Guild.GetUserAsync(challengerId); - var opponent = await Context.Guild.GetUserAsync(opponentId); - - if (challenger == null || opponent == null) - { - await Context.Interaction.FollowupAsync("One of the duel participants is no longer available!"); - return; - } - - // Randomly select winner (50/50) - bool challengerWins = _random.Next(2) == 0; - var winner = challengerWins ? challenger : opponent; - var loser = challengerWins ? opponent : challenger; - if (type == "mute") - { - var isChallengerAdmin = challenger.GuildPermissions.Has(GuildPermission.Administrator); - var isOpponentAdmin = opponent.GuildPermissions.Has(GuildPermission.Administrator); - if (isChallengerAdmin || isOpponentAdmin) - { - // Unfair advantages are unfair. Also, bot can't mute admins. Remove the stakes. - type = "friendly"; - } - } - - // Generate flavor message - string flavorMessage = _normalWinMessages[_random.Next(_normalWinMessages.Length)]; - flavorMessage = flavorMessage.Replace("{winner}", winner.Mention).Replace("{loser}", loser.Mention); - - var resultEmbed = new EmbedBuilder() - .WithColor(Color.Gold) - .WithTitle("⚔️ Duel Results!") - .WithDescription(flavorMessage) - .AddField("Winner", winner.Mention, inline: true) - .Build(); - - await Context.Interaction.ModifyOriginalResponseAsync(msg => - { - msg.Embed = resultEmbed; - msg.Components = new ComponentBuilder().Build(); - }); - - // Handle mute duel using Discord timeout - if (type == "mute") - { - try - { - var guildLoser = loser as IGuildUser; - if (guildLoser != null) - { - // Use Discord's timeout feature for 5 minutes - await guildLoser.SetTimeOutAsync(TimeSpan.FromMinutes(5), new RequestOptions { AuditLogReason = "Lost /duel" }); - await Context.Interaction.FollowupAsync($"💀 {loser.Mention} has been timed out for 5 minutes as the duel loser!", ephemeral: false); - } - } - catch (Exception ex) - { - await LoggingService.LogChannelAndFile($"Failed to timeout the loser of the duel: {ex.Message}", ExtendedLogSeverity.Error); - await Context.Interaction.FollowupAsync("Failed to timeout the loser.", ephemeral: false); - } - } - } - - [ComponentInteraction("duel_refuse:*")] - public async Task DuelRefuse(string duelKey) - { - // Extract user IDs from the duel key - var userIds = duelKey.Split('_'); - if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) - { - await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); - return; - } - - // Only the challenged user can refuse - if (Context.User.Id != opponentId) - { - await Context.Interaction.RespondAsync("Only the challenged user can refuse this duel!", ephemeral: true); - return; - } - - // Check if duel is still active - if (!_activeDuels.ContainsKey(duelKey)) - { - await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); - return; - } - - // Remove from active duels - _activeDuels.TryRemove(duelKey, out _); - - // Edit the embed to show refusal instead of deleting - await Context.Interaction.DeferAsync(); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - { - msg.Content = string.Empty; - msg.Embed = new EmbedBuilder() - .WithColor(Color.LightGrey) - .WithDescription("🛡️ Duel challenge was refused.") - .Build(); - msg.Components = new ComponentBuilder().Build(); - }); - } - - [ComponentInteraction("duel_cancel:*")] - public async Task DuelCancel(string duelKey) - { - // Extract user IDs from the duel key - var userIds = duelKey.Split('_'); - if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) - { - await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); - return; - } - - // Only the challenger can cancel - if (Context.User.Id != challengerId) - { - await Context.Interaction.RespondAsync("Only the challenger can cancel this duel!", ephemeral: true); - return; - } - - // Check if duel is still active - if (!_activeDuels.ContainsKey(duelKey)) - { - await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); - return; - } - - // Remove from active duels - _activeDuels.TryRemove(duelKey, out _); - - // Edit the embed to show cancellation - await Context.Interaction.DeferAsync(); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - { - msg.Content = string.Empty; - msg.Embed = new EmbedBuilder() - .WithColor(Color.LightGrey) - .WithDescription("❌ Duel challenge was cancelled by the challenger.") - .Build(); - msg.Components = new ComponentBuilder().Build(); - }); - } - - #endregion -} diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/Utils/AirportModule.cs similarity index 77% rename from DiscordBot/Modules/AirportModule.cs rename to DiscordBot/Modules/Utils/AirportModule.cs index 5a35ae31..0d543cc8 100644 --- a/DiscordBot/Modules/AirportModule.cs +++ b/DiscordBot/Modules/Utils/AirportModule.cs @@ -1,9 +1,8 @@ using Discord.Commands; -using DiscordBot.Modules.Weather; using DiscordBot.Services; using DiscordBot.Settings; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Utils; // Allows UserModule !help to show commands from this module [Group("UserModule"), Alias("")] @@ -11,25 +10,25 @@ public class AirportModule : ModuleBase { #region Dependency Injection - public AirportService AirportService { get; set; } - public BotSettings Settings { get; set; } + public AirportService AirportService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; // Needed to locate cities lon/lat easier - public WeatherService WeatherService { get; set; } + public WeatherService WeatherService { get; set; } = null!; #endregion // Dependency Injection #region API Results - + public class FlightResults { - public string iata { get; set; } - public string fs { get; set; } - public string name { get; set; } + public string iata { get; set; } = string.Empty; + public string fs { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; } public class FlightRoot { - public List data { get; set; } + public List data { get; set; } = []; } #endregion // API Results @@ -41,19 +40,19 @@ public class FlightRoot public async Task FlyTo(string from, string to) { // Make sure command is in Bot-Commands or OffTopic - if (Context.Channel.Id != Settings.BotCommandsChannel.Id && Context.Channel.Id != Settings.GeneralChannel.Id) + if (Context.Channel.Id != Settings.Channels.BotCommands.Id && Context.Channel.Id != Settings.Channels.General.Id) { - await ReplyAsync($"Command can only be used in <#{Settings.BotCommandsChannel.Id}> or <#{Settings.GeneralChannel.Id}>.").DeleteAfterSeconds(5f); - await Context.Message.DeleteAfterSeconds(2f); + await (ReplyAsync($"Command can only be used in <#{Settings.Channels.BotCommands.Id}> or <#{Settings.Channels.General.Id}>.").DeleteAfterSeconds(5f) ?? Task.CompletedTask); + await (Context.Message.DeleteAfterSeconds(2f) ?? Task.CompletedTask); return; } - + EmbedBuilder embed = new(); embed.Title = "Flight Finder"; embed.Description = "Finding cities"; var msg = await ReplyAsync(string.Empty, false, embed.Build()); - + // Use Weather API to get lon/lat of cities var fromCity = await GetCity(from, embed, msg); if (fromCity == null) @@ -61,7 +60,7 @@ public async Task FlyTo(string from, string to) var toCity = await GetCity(to, embed, msg); if (toCity == null) return; - + // Find closest Airport using AirLabs API embed.Description = "Finding airports"; await msg.ModifyAsync(x => x.Embed = embed.Build()); @@ -72,11 +71,11 @@ public async Task FlyTo(string from, string to) var toAirport = await GetAirport(toCity, embed, msg); if (toAirport == null) return; - + // Find cheapest flight using GetFlightInfo embed.Description = $"Searching {fromAirport.name} to {toAirport.name}"; await msg.ModifyAsync(x => x.Embed = embed.Build()); - + var daysUntilTuesday = (int)DateTime.Now.DayOfWeek - 2; if (daysUntilTuesday < 0) daysUntilTuesday += 7; @@ -86,12 +85,12 @@ public async Task FlyTo(string from, string to) { embed.Description += "\\nNo flights found, sorry."; await msg.ModifyAsync(x => x.Embed = embed.Build()); - await msg.DeleteAfterSeconds(30f); + await (msg.DeleteAfterSeconds(30f) ?? Task.CompletedTask); return; } var flight = flights[0]; - + var itinerary = flight.itineraries.First(); var numberOfStops = itinerary.segments.Count - 1; var departTime = itinerary.segments.First().departure; @@ -106,7 +105,7 @@ public async Task FlyTo(string from, string to) // embed.Description += // $"\nSeats remaining: {flight.numberOfBookableSeats}, Bags: {(flight.pricingOptions.includedCheckedBagsOnly ? "Y" : "N")}, OneWay: {(flight.oneWay ? "Y" : "N")}"; embed.Description += $"\nDepart: {departTime.at:dd/MM/yy HH:MM}, Arrive: {arriveTime.at:dd/MM/yy HH:MM}"; - + // string price = $"Base: {flight.price.@base}"; // foreach (var fee in flight.price.fees) // { @@ -121,35 +120,35 @@ public async Task FlyTo(string from, string to) } #endregion // Commands - + #region Utility Methods - - private async Task GetCity(string city, EmbedBuilder embed, IUserMessage msg) + + private async Task GetCity(string city, EmbedBuilder embed, IUserMessage msg) { var cityResult = await WeatherService.GetWeather(city); if (cityResult == null) { embed.Description += $"\n{city} could not be found."; await msg.ModifyAsync(x => x.Embed = embed.Build()); - await msg.DeleteAfterSeconds(10f); + await (msg.DeleteAfterSeconds(10f) ?? Task.CompletedTask); return null; } return cityResult; } - - private async Task GetAirport(WeatherContainer.Result weather, EmbedBuilder embed, IUserMessage msg) + + private async Task GetAirport(WeatherContainer.Result weather, EmbedBuilder embed, IUserMessage msg) { var airportResult = await AirportService.GetClosestAirport(weather.coord.Lat, weather.coord.Lon); if (airportResult == null) { embed.Description += $"\nAirport near {weather.name} ({weather.sys.country}) could not be found."; await msg.ModifyAsync(x => x.Embed = embed.Build()); - await msg.DeleteAfterSeconds(10f); + await (msg.DeleteAfterSeconds(10f) ?? Task.CompletedTask); return null; } return airportResult; } #endregion // Utility Methods - + } \ No newline at end of file diff --git a/DiscordBot/Modules/Utils/ConvertModule.cs b/DiscordBot/Modules/Utils/ConvertModule.cs new file mode 100644 index 00000000..293bc7e5 --- /dev/null +++ b/DiscordBot/Modules/Utils/ConvertModule.cs @@ -0,0 +1,102 @@ +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Services; +using DiscordBot.Utils; + +namespace DiscordBot.Modules.Utils; + +[Group("UserModule"), Alias("")] +public class ConvertModule : ModuleBase +{ + public CurrencyService CurrencyService { get; set; } = null!; + + [Command("FtoC"), Priority(28)] + [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")] + public async Task FahrenheitToCelsius(float f) + { + await ReplyAsync($"{Context.User.Mention} {f}°F is {MathUtility.FahrenheitToCelsius(f)}°C."); + } + + [Command("CtoF"), Priority(28)] + [Summary("Converts a temperature in celsius to fahrenheit. Syntax : !ftoc temperature")] + public async Task CelsiusToFahrenheit(float c) + { + await ReplyAsync($"{Context.User.Mention} {c}°C is {MathUtility.CelsiusToFahrenheit(c)}°F"); + } + + [Command("Translate"), HideFromHelp] + [Summary("Translate a message. Syntax : !translate messageId language")] + public async Task Translate(ulong messageId, string language = "en") + { + await Translate((await Context.Channel.GetMessageAsync(messageId)).Content, language); + } + + [Command("Translate"), HideFromHelp] + [Summary("Translate a message. Syntax : !translate text language")] + public async Task Translate(string text, string language = "en") + { + var msg = await ReplyAsync($"Here: "); + await Context.Message.DeleteAfterSeconds(seconds: 1)!; + await msg.DeleteAfterSeconds(seconds: 20)!; + } + + [Command("CurrencyName"), Priority(29)] + [Summary("Get the name of a currency. Syntax : !currname USD")] + [Alias("currname")] + public async Task CurrencyName(string currency) + { + if (Context.HasAnyPingableMention()) + return; + var name = await CurrencyService.GetCurrencyName(currency); + if (name == string.Empty) + { + await Context.Message.ReplyAsync($"Sorry, I couldn't find the name of the currency **{currency}**."); + return; + } + await Context.Message.ReplyAsync($"The name of the currency **{currency.ToUpper()}** is **{name}**."); + } + + [Command("Currency"), HideFromHelp] + [Summary("Converts a currency. Syntax : !currency fromCurrency toCurrency")] + [Alias("curr")] + public async Task ConvertCurrency(string from, string to = "usd") + { + await ConvertCurrency(1, from, to); + } + + [Command("Currency"), Priority(29)] + [Summary("Converts a currency. Syntax : !currency amount fromCurrency toCurrency")] + [Alias("curr")] + public async Task ConvertCurrency(double amount, string from, string to = "usd") + { + if (Context.HasAnyPingableMention()) + { + if (!Context.IsReply()) + return; + if (!Context.IsOnlyReplyingToAuthor()) + return; + } + + from = from.ToLower(); + to = to.ToLower(); + + bool fromValid = await CurrencyService.IsCurrency(from.ToLower()); + bool toValid = await CurrencyService.IsCurrency(to.ToLower()); + + if (!fromValid || !toValid) + { + await Context.Message.ReplyAsync("One of the currencies provided is invalid."); + return; + } + + var response = await CurrencyService.GetConversion(to, from); + if (Math.Abs(response - (-1)) < 0.01) + { + await Context.Message.ReplyAsync("An error occured while converting the currency, the API may be down!"); + return; + } + + var totalAmount = Math.Round(amount * response, 2); + await Context.Message.ReplyAsync($"**{amount} {from.ToUpper()}** = **{totalAmount} {to.ToUpper()}**"); + } +} diff --git a/DiscordBot/Modules/Utils/SearchModule.cs b/DiscordBot/Modules/Utils/SearchModule.cs new file mode 100644 index 00000000..2c3a14c0 --- /dev/null +++ b/DiscordBot/Modules/Utils/SearchModule.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Text; +using Discord.Commands; +using DiscordBot.Services; +using DiscordBot.Settings; +using DiscordBot.Attributes; + +namespace DiscordBot.Modules.Utils; + +[Group("UserModule"), Alias("")] +public class SearchModule : ModuleBase +{ + public ILoggingService LoggingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; + public UpdateService UpdateService { get; set; } = null!; + public SearchService SearchService { get; set; } = null!; + + [Command("Search"), Priority(25)] + [Summary("Searches DuckDuckGo for results. Syntax: !search c# lambda help")] + [Alias("s", "ddg")] + public async Task SearchResults(params string[] messages) + { + StringBuilder sb = new(); + foreach (var msg in messages) + sb.Append(msg).Append(" "); + await SearchResults(sb.ToString()); + } + + [Command("Search"), HideFromHelp] + [Summary("Searches DuckDuckGo for web results. Syntax : !search \"query\" resNum site")] + [Alias("s", "ddg")] + public async Task SearchResults(string query, uint resNum = 3, string site = "") + { + var results = SearchService.SearchDuckDuckGo(query, resNum, site); + + var resultTitle = string.Empty; + for (int i = 0; i < results.Count; i++) + { + resultTitle += $"{i + 1}. {results[i].Title} [__Read More__]({results[i].Url})\n"; + } + + var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+'); + if (site != string.Empty) searchQuery += "+site:" + site; + + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = $"Q: {WebUtility.UrlDecode(query)}"; + embedBuilder.AddField("Search Query", searchQuery); + embedBuilder.AddField("Results", resultTitle.Length > 0 ? resultTitle : "No results found.", inline: false); + embedBuilder.Color = new Color(81, 50, 169); + embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from DuckDuckGo."); + + await ReplyAsync(embed: embedBuilder.Build()); + } + + [Command("Manual"), Priority(8)] + [Summary("Searches Unity3D manual for results. Syntax : !manual \"query\"")] + public async Task SearchManual(params string[] queries) + { + var pages = await UpdateService.GetManualDatabase(); + var query = string.Join(" ", queries); + var match = SearchService.FindBestMatch(query, pages!, "https://docs.unity3d.com/Manual"); + + if (match != null) + { + var url = $"{match.BaseUrl}/{match.PageName}.html"; + + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = $"Found {match.PageName}"; + embedBuilder.Description = $"**{match.Title}** - [Read More..]({url})"; + embedBuilder.Color = new Color(81, 50, 169); + embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); + var message = await ReplyAsync(embed: embedBuilder.Build()); + + var description = SearchService.FetchPageDescription(url, "//h1", "following-sibling::p"); + if (description != null) + { + embedBuilder.WithDescription($"**Description:** {description}\n[Read More..]({url})"); + await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); + } + } + else + await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10)!; + } + + [Command("Doc"), Priority(9)] + [Summary("Searches Unity3D API for results. Syntax : !api \"query\"")] + [Alias("ref", "reference", "api", "docs")] + public async Task SearchApi(params string[] queries) + { + var pages = await UpdateService.GetApiDatabase(); + var query = string.Join(" ", queries); + var match = SearchService.FindBestMatch(query, pages!, "https://docs.unity3d.com/ScriptReference"); + + if (match != null) + { + var url = $"{match.BaseUrl}/{match.PageName}.html"; + + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = $"Found {match.PageName}"; + embedBuilder.Description = $"**{match.Title}** - [Read More..]({url})"; + embedBuilder.Color = new Color(81, 50, 169); + embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); + var message = await ReplyAsync(embed: embedBuilder.Build()); + + var description = SearchService.FetchPageDescription(url, "//h3[contains(text(), 'Description')]", "following-sibling::p"); + var manualLink = SearchService.FetchManualLink(url); + + string descriptionString = description != null + ? $"**Description:** {description}\n[Read More..]({url})" + : string.Empty; + string manualLinkString = manualLink != null + ? $"\n**Manual:** {manualLink}" + : string.Empty; + + embedBuilder.WithDescription(descriptionString + manualLinkString); + await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); + } + else + await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10)!; + } + + [Command("Wiki"), Priority(26)] + [Summary("Searches Wikipedia. Syntax : !wiki \"query\"")] + [Alias("wikipedia")] + public async Task SearchWikipedia([Remainder] string query) + { + var article = await UpdateService.DownloadWikipediaArticle(query); + + if (article.url == null) + { + await ReplyAsync($"No Articles for \"{query}\" were found."); + return; + } + + await ReplyAsync(embed: GetWikipediaEmbed(article.name!, article.extract!, article.url!)); + } + + private Embed GetWikipediaEmbed(string subject, string articleExtract, string articleUrl) + { + var builder = new EmbedBuilder() + .WithTitle($"Wikipedia | {subject}") + .WithDescription($"{articleExtract}") + .WithUrl(articleUrl) + .WithColor(new Color(0x33CC00)); + return builder.Build(); + } +} diff --git a/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs b/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs new file mode 100644 index 00000000..dde28698 --- /dev/null +++ b/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs @@ -0,0 +1,129 @@ +using Newtonsoft.Json; + +namespace DiscordBot.Modules.Utils.Weather; + +#region Weather Results + +#pragma warning disable 0649 +// ReSharper disable InconsistentNaming +public class WeatherContainer +{ + public class Coord + { + public double Lon { get; set; } + public double Lat { get; set; } + } + + public class Weather + { + public int id { get; set; } + [JsonProperty("main")] public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + } + + public class Main + { + public float Temp { get; set; } + [JsonProperty("feels_like")] public double Feels { get; set; } + [JsonProperty("temp_min")] public double Min { get; set; } + [JsonProperty("temp_max")] public double Max { get; set; } + public int Pressure { get; set; } + public int Humidity { get; set; } + } + + public class Wind + { + public double Speed { get; set; } + public int Deg { get; set; } + } + + public class Clouds + { + public int all { get; set; } + } + + public class Rain + { + [JsonProperty("1h")] public double Rain1h { get; set; } + [JsonProperty("3h")] public double Rain3h { get; set; } + } + + public class Snow + { + [JsonProperty("1h")] public double Snow1h { get; set; } + [JsonProperty("3h")] public double Snow3h { get; set; } + } + + public class Sys + { + public int type { get; set; } + public int id { get; set; } + public double message { get; set; } + public string country { get; set; } = string.Empty; + public int sunrise { get; set; } + public int sunset { get; set; } + } + + public class Result + { + public Coord coord { get; set; } = null!; + public List weather { get; set; } = []; + public string @base { get; set; } = string.Empty; + public Main main { get; set; } = null!; + public int visibility { get; set; } + public Wind wind { get; set; } = null!; + public Clouds clouds { get; set; } = null!; + public Rain rain { get; set; } = null!; + public Snow snow { get; set; } = null!; + public int dt { get; set; } + public Sys sys { get; set; } = null!; + public int timezone { get; set; } + public int id { get; set; } + public string name { get; set; } = string.Empty; + public int cod { get; set; } + } +} + +#endregion +#region Pollution Results + +public class PollutionContainer +{ + public class Coord + { + public double lon { get; set; } + public double lat { get; set; } + } + public class Main + { + public int aqi { get; set; } + } + public class Components + { + [JsonProperty("co")] public double CarbonMonoxide { get; set; } + [JsonProperty("no")] public double NitrogenMonoxide { get; set; } + [JsonProperty("no2")] public double NitrogenDioxide { get; set; } + [JsonProperty("o3")] public double Ozone { get; set; } + [JsonProperty("so2")] public double SulphurDioxide { get; set; } + [JsonProperty("pm2_5")] public double FineParticles { get; set; } + [JsonProperty("pm10")] public double CoarseParticulate { get; set; } + [JsonProperty("nh3")] public double Ammonia { get; set; } + } + + public class List + { + public Main main { get; set; } = null!; + public Components components { get; set; } = null!; + public int dt { get; set; } + } + public class Result + { + public Coord coord { get; set; } = null!; + public List list { get; set; } = []; + } +} + +// ReSharper restore InconsistentNaming +#pragma warning restore 0649 +#endregion \ No newline at end of file diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Utils/Weather/WeatherModule.cs similarity index 79% rename from DiscordBot/Modules/Weather/WeatherModule.cs rename to DiscordBot/Modules/Utils/Weather/WeatherModule.cs index 4da268fd..a4abfe1f 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Utils/Weather/WeatherModule.cs @@ -1,10 +1,9 @@ using Discord.Commands; using DiscordBot.Attributes; -using DiscordBot.Modules.Weather; using DiscordBot.Services; using Newtonsoft.Json; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Utils.Weather; // https://openweathermap.org/current#call // Allows UserModule !help to show commands from this module @@ -12,12 +11,12 @@ namespace DiscordBot.Modules; public class WeatherModule : ModuleBase { #region Dependency Injection - - public WeatherService WeatherService { get; set; } - public UserExtendedService UserExtendedService { get; set; } - + + public WeatherService WeatherService { get; set; } = null!; + public UserExtendedService UserExtendedService { get; set; } = null!; + #endregion - + private List AQI_Index = new List() {"Invalid", "Good", "Fair", "Moderate", "Poor", "Very Poor"}; @@ -31,15 +30,15 @@ public async Task WeatherHelp() .WithDescription( "If the city isn't correct you will need to include the correct [city codes](https://www.iso.org/obp/ui/#search).\n**Example Usage**: *!Weather Wellington, UK*"); await Context.Message.DeleteAsync(); - await ReplyAsync(embed: builder.Build()).DeleteAfterSeconds(seconds: 30); + await ReplyAsync(embed: builder.Build()).DeleteAfterSeconds(seconds: 30)!; } #region Temperature - - private async Task TemperatureEmbed(string city, string replaceCityWith = "") + + private async Task TemperatureEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); + if (!await IsResultsValid(res) || res is null) return null; EmbedBuilder builder = new EmbedBuilder() @@ -50,16 +49,16 @@ private async Task TemperatureEmbed(string city, string replaceCit return builder; } - + [Command("Temperature"), HideFromHelp] [Summary("Attempts to provide the temperature of the user provided.")] [Alias("temp"), Priority(20)] - public async Task Temperature(IUser user = null) + public async Task Temperature(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await TemperatureEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -68,7 +67,7 @@ public async Task Temperature(IUser user = null) await ReplyAsync(embed: builder.Build()); } - + [Command("Temperature")] [Summary("Attempts to provide the temperature of the city provided.")] [Alias("temp"), Priority(20)] @@ -80,30 +79,30 @@ public async Task Temperature(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Temperature #region Weather - - private async Task WeatherEmbed(string city, string replaceCityWith = "") + + private async Task WeatherEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); + if (!await IsResultsValid(res) || res is null) return null; string extraInfo = string.Empty; - + DateTime sunrise = DateTime.UnixEpoch.AddSeconds(res.sys.sunrise) .AddSeconds(res.timezone); DateTime sunset = DateTime.UnixEpoch.AddSeconds(res.sys.sunset) .AddSeconds(res.timezone); - + // Sun rise/set if (res.sys.sunrise > 0) extraInfo += $"Sunrise **{sunrise:hh\\:mmtt}**, "; - if (res.sys.sunrise > 0) + if (res.sys.sunset > 0) extraInfo += $"Sunset **{sunset:hh\\:mmtt}**\n"; - + if (res.main.Temp > 0 && res.rain != null) { if (res.rain.Rain3h > 0) @@ -128,18 +127,18 @@ private async Task WeatherEmbed(string city, string replaceCityWit .WithFooter( $"{res.clouds.all}% cloud cover with {GetWindDirection((float)res.wind.Deg)} {Math.Round((res.wind.Speed * 60f * 60f) / 1000f, 2)} km/h winds & {res.main.Humidity}% humidity.") .WithColor(GetColour(res.main.Temp)); - + return builder; } - + [Command("Weather"), HideFromHelp, Priority(20)] [Summary("Attempts to provide the weather of the user provided.")] - public async Task CurentWeather(IUser user = null) + public async Task CurentWeather(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await WeatherEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -159,21 +158,23 @@ public async Task CurentWeather(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Weather #region Pollution - private async Task PollutionEmbed(string city, string replaceCityWith = "") + private async Task PollutionEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); + if (!await IsResultsValid(res) || res is null) return null; // We can't really combine the call as having WeatherResults helps with other details - PollutionContainer.Result polResult = + PollutionContainer.Result? polResult = await WeatherService.GetPollution(Math.Round(res.coord.Lon, 4), Math.Round(res.coord.Lat, 4)); + if (polResult is null) + return null; var comp = polResult.list[0].components; double combined = comp.CarbonMonoxide + comp.NitrogenMonoxide + comp.NitrogenDioxide + comp.Ozone + @@ -211,12 +212,12 @@ private async Task PollutionEmbed(string city, string replaceCityW [Command("Pollution"), HideFromHelp, Priority(21)] [Summary("Attempts to provide the pollution conditions of the user provided.")] - public async Task Pollution(IUser user = null) + public async Task Pollution(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await PollutionEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -225,7 +226,7 @@ public async Task Pollution(IUser user = null) await ReplyAsync(embed: builder.Build()); } - + [Command("Pollution"), Priority(21)] [Summary("Attempts to provide the pollution conditions of the city provided.")] public async Task Pollution(params string[] city) @@ -236,15 +237,15 @@ public async Task Pollution(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Pollution #region Time - - private async Task TimeEmbed(string city, string replaceCityWith = "") + + private async Task TimeEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); + if (!await IsResultsValid(res) || res is null) return null; var timezone = res.timezone / 3600; @@ -256,15 +257,15 @@ private async Task TimeEmbed(string city, string replaceCityWith = return builder; } - + [Command("Time"), HideFromHelp, Priority(22)] [Summary("Attempts to provide the time of the user provided.")] - public async Task Time(IUser user = null) + public async Task Time(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await TimeEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -273,7 +274,7 @@ public async Task Time(IUser user = null) await ReplyAsync(embed: builder.Build()); } - + [Command("Time"), Priority(22)] [Summary("Attempts to provide the time of the city/location provided.")] public async Task Time(params string[] city) @@ -284,9 +285,9 @@ public async Task Time(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Time - + #region Utility Methods private async Task IsResultsValid(T res) @@ -313,18 +314,18 @@ private Color GetColour(float temp) _ => new Color(255, 0, 0) }; } - + private async Task DoesUserHaveDefaultCity(IUser user) { // If they do, return true if (await UserExtendedService.DoesUserHaveDefaultCity(user)) return true; - + // Otherwise respond and return false var uname = user.GetUserPreferredName(); await ReplyAsync($"User {uname} does not have a default city set."); return false; } - + private static string GetWindDirection(float windDeg) { if (windDeg < 22.5) @@ -345,6 +346,44 @@ private static string GetWindDirection(float windDeg) return "NW"; return "N"; } - + #endregion Utility Methods + + #region City Settings + + [Command("SetCity"), Priority(100)] + [Alias("SetDefaultCity")] + [Summary("Set 'Default City' which can be used by various commands.")] + public async Task SetDefaultCity(params string[] city) + { + var uname = Context.User.GetUserPreferredName(); + var fullCityName = string.Join(" ", city); + var (exists, result) = await WeatherService.CityExists(fullCityName); + if (!exists || result is null) + { + await ReplyAsync($"Sorry, {uname}, but I couldn't find a city with that name.").DeleteAfterSeconds(30)!; + await Context.Message.DeleteAsync(); + return; + } + await UserExtendedService.SetUserDefaultCity(Context.User, result.name); + await ReplyAsync($"{uname}, your default city has been set to {result.name}."); + } + + [Command("RemoveCity"), Priority(100)] + [Alias("RemoveDefaultCity")] + [Summary("Remove 'Default City' which can be used by various commands.")] + public async Task RemoveDefaultCity() + { + var uname = Context.User.GetUserPreferredName(); + if (!await UserExtendedService.DoesUserHaveDefaultCity(Context.User)) + { + await ReplyAsync($"{uname}, you don't have a default city set.").DeleteAfterSeconds(30)!; + await Context.Message.DeleteAsync(); + return; + } + await UserExtendedService.RemoveUserDefaultCity(Context.User); + await ReplyAsync($"{uname}, your default city has been removed."); + } + + #endregion City Settings } diff --git a/DiscordBot/Modules/Weather/WeatherContainers.cs b/DiscordBot/Modules/Weather/WeatherContainers.cs deleted file mode 100644 index 1f5d351c..00000000 --- a/DiscordBot/Modules/Weather/WeatherContainers.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Newtonsoft.Json; - -namespace DiscordBot.Modules.Weather; - - #region Weather Results - -#pragma warning disable 0649 - // ReSharper disable InconsistentNaming - public class WeatherContainer - { - public class Coord - { - public double Lon { get; set; } - public double Lat { get; set; } - } - - public class Weather - { - public int id { get; set; } - [JsonProperty("main")] public string Name { get; set; } - public string Description { get; set; } - public string Icon { get; set; } - } - - public class Main - { - public float Temp { get; set; } - [JsonProperty("feels_like")] public double Feels { get; set; } - [JsonProperty("temp_min")] public double Min { get; set; } - [JsonProperty("temp_max")] public double Max { get; set; } - public int Pressure { get; set; } - public int Humidity { get; set; } - } - - public class Wind - { - public double Speed { get; set; } - public int Deg { get; set; } - } - - public class Clouds - { - public int all { get; set; } - } - - public class Rain - { - [JsonProperty("1h")] public double Rain1h { get; set; } - [JsonProperty("3h")] public double Rain3h { get; set; } - } - - public class Snow - { - [JsonProperty("1h")] public double Snow1h { get; set; } - [JsonProperty("3h")] public double Snow3h { get; set; } - } - - public class Sys - { - public int type { get; set; } - public int id { get; set; } - public double message { get; set; } - public string country { get; set; } - public int sunrise { get; set; } - public int sunset { get; set; } - } - - public class Result - { - public Coord coord { get; set; } - public List weather { get; set; } - public string @base { get; set; } - public Main main { get; set; } - public int visibility { get; set; } - public Wind wind { get; set; } - public Clouds clouds { get; set; } - public Rain rain { get; set; } - public Snow snow { get; set; } - public int dt { get; set; } - public Sys sys { get; set; } - public int timezone { get; set; } - public int id { get; set; } - public string name { get; set; } - public int cod { get; set; } - } - } - - #endregion - #region Pollution Results - - public class PollutionContainer - { - public class Coord - { - public double lon { get; set; } - public double lat { get; set; } - } - public class Main - { - public int aqi { get; set; } - } - public class Components - { - [JsonProperty("co")] public double CarbonMonoxide { get; set; } - [JsonProperty("no")] public double NitrogenMonoxide { get; set; } - [JsonProperty("no2")] public double NitrogenDioxide { get; set; } - [JsonProperty("o3")] public double Ozone { get; set; } - [JsonProperty("so2")] public double SulphurDioxide { get; set; } - [JsonProperty("pm2_5")] public double FineParticles { get; set; } - [JsonProperty("pm10")] public double CoarseParticulate { get; set; } - [JsonProperty("nh3")] public double Ammonia { get; set; } - } - - public class List - { - public Main main { get; set; } - public Components components { get; set; } - public int dt { get; set; } - } - public class Result - { - public Coord coord { get; set; } - public List list { get; set; } - } - } - - // ReSharper restore InconsistentNaming -#pragma warning restore 0649 - #endregion \ No newline at end of file diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index f0f22093..e5174511 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -2,9 +2,7 @@ using Discord.Commands; using Discord.Interactions; using Discord.WebSocket; -using DiscordBot.Service; using DiscordBot.Services; -using DiscordBot.Services.Tips; using DiscordBot.Settings; using DiscordBot.Utils; using Microsoft.Extensions.DependencyInjection; @@ -14,26 +12,27 @@ namespace DiscordBot; public class Program { - private bool _isInitialized = false; + private int _isInitialized = 0; - private static Rules _rules; - private static BotSettings _settings; - private static UserSettings _userSettings; - private DiscordSocketClient _client; - private CommandHandlingService _commandHandlingService; + private static Rules _rules = null!; + private static BotSettings _settings = null!; + private static UserSettings _userSettings = null!; + private DiscordSocketClient _client = null!; - private CommandService _commandService; - private InteractionService _interactionService; - private IServiceProvider _services; + private CommandService _commandService = null!; + private InteractionService _interactionService = null!; + private IServiceProvider _services = null!; - private UnityHelpService _unityHelpService; - private RecruitService _recruitService; + private readonly CancellationTokenSource _cts = new(); public static void Main(string[] args) => new Program().MainAsync().GetAwaiter().GetResult(); private async Task MainAsync() { + Console.CancelKeyPress += (_, e) => { e.Cancel = true; _cts.Cancel(); }; + AppDomain.CurrentDomain.ProcessExit += (_, _) => _cts.Cancel(); + DeserializeSettings(); _client = new DiscordSocketClient(new DiscordSocketConfig @@ -41,7 +40,12 @@ private async Task MainAsync() LogLevel = LogSeverity.Verbose, AlwaysDownloadUsers = true, MessageCacheSize = 1024, - GatewayIntents = GatewayIntents.All, + GatewayIntents = GatewayIntents.Guilds + | GatewayIntents.GuildMembers + | GatewayIntents.GuildMessages + | GatewayIntents.GuildMessageReactions + | GatewayIntents.DirectMessages + | GatewayIntents.MessageContent, }); _client.Log += LoggingService.DiscordNetLogger; @@ -52,7 +56,7 @@ private async Task MainAsync() { // Ready can be called additional times if the bot disconnects for long enough, // so we need to make sure we only initialize commands and such for the bot once if it manages to re-establish connection - if (_isInitialized) return Task.CompletedTask; + if (Interlocked.CompareExchange(ref _isInitialized, 1, 0) != 0) return Task.CompletedTask; _interactionService = new InteractionService(_client); _commandService = new CommandService(new CommandServiceConfig @@ -62,29 +66,47 @@ private async Task MainAsync() }); _services = ConfigureServices(); - _commandHandlingService = _services.GetRequiredService(); + _services.GetRequiredService(); // Announce, and Log bot started to track issues a bit easier var logger = _services.GetRequiredService(); logger.LogChannelAndFile("Bot Started.", ExtendedLogSeverity.Positive); LoggingService.LogToConsole("Bot is connected.", ExtendedLogSeverity.Positive); - _isInitialized = true; - _unityHelpService = _services.GetRequiredService(); - _recruitService = _services.GetRequiredService(); - _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); _services.GetRequiredService(); return Task.CompletedTask; }; - await Task.Delay(-1); + try + { + await Task.Delay(Timeout.Infinite, _cts.Token); + } + catch (TaskCanceledException) { } + + LoggingService.LogToConsole("Shutdown signal received, stopping...", ExtendedLogSeverity.Warning); + using var shutdownTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + try { await _client.StopAsync().WaitAsync(shutdownTimeout.Token); } + catch (OperationCanceledException) { LoggingService.LogToConsole("Client stop timed out.", ExtendedLogSeverity.Warning); } + LoggingService.LogToConsole("Bot stopped.", ExtendedLogSeverity.Positive); } private IServiceProvider ConfigureServices() => new ServiceCollection() + .AddHttpClient() + .AddSingleton() + .AddSingleton(_cts) .AddSingleton(_settings) .AddSingleton(_rules) .AddSingleton(_userSettings) @@ -94,13 +116,23 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -110,6 +142,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .BuildServiceProvider(); @@ -119,5 +152,17 @@ private static void DeserializeSettings() _settings = SerializeUtil.DeserializeFile(@"Settings/Settings.json"); _rules = SerializeUtil.DeserializeFile(@"Settings/Rules.json"); _userSettings = SerializeUtil.DeserializeFile(@"Settings/UserSettings.json"); + + var (errors, warnings) = _settings.Validate(); + warnings.AddRange(_userSettings.Validate()); + foreach (var warning in warnings) + Console.WriteLine($"[Settings Warning] {warning}"); + if (errors.Count > 0) + { + foreach (var error in errors) + Console.Error.WriteLine($"[Settings Error] {error}"); + throw new InvalidOperationException( + $"Bot settings validation failed with {errors.Count} error(s). See output above."); + } } } diff --git a/DiscordBot/Services/Code/CodeCheckService.cs b/DiscordBot/Services/Code/CodeCheckService.cs new file mode 100644 index 00000000..7dca07ac --- /dev/null +++ b/DiscordBot/Services/Code/CodeCheckService.cs @@ -0,0 +1,140 @@ +using System.Text.RegularExpressions; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Code; + +public class CodeCheckService +{ + private readonly DiscordSocketClient _client; + private readonly BotSettings _settings; + private readonly UpdateService _updateService; + private readonly CancellationToken _shutdownToken; + + private readonly Regex _x3CodeBlock = + new("^(?`{3}((?\\w*?$)|$).+?({.+?}).+?`{3})", RegexOptions.Multiline | RegexOptions.Singleline); + + private readonly Regex _x2CodeBlock = new("^(`{2})[^`].+?([^`]`{2})$", RegexOptions.Multiline); + private readonly List _codeBlockWarnPatterns; + private readonly short _maxCodeBlockLengthWarning = 800; + + public readonly string CodeFormattingExample; + private readonly string _codeReminderFormattingExample; + public Dictionary CodeReminderCooldown { get; private set; } + + public CodeCheckService(DiscordSocketClient client, BotSettings settings, + UpdateService updateService, CancellationTokenSource cts) + { + _client = client; + _settings = settings; + _updateService = updateService; + _shutdownToken = cts.Token; + + CodeReminderCooldown = new Dictionary(); + + CodeFormattingExample = @"\`\`\`cs" + Environment.NewLine + + "Write your code on new line here." + Environment.NewLine + + @"\`\`\`" + Environment.NewLine; + + _codeReminderFormattingExample = CodeFormattingExample + "*To disable these reminders use \"!disablecodetips\"*"; + + _codeBlockWarnPatterns = new List + { + new(".*?({.+?}).*?", RegexOptions.Singleline), + new("(if|else\\sif).?\\(.+\\).?($|\\/{2}|\\s?)", RegexOptions.Multiline), + new("^(\\w*.\\w*)\\(\\w*?\\);($|.?($|.*?\\/{2}))", RegexOptions.Multiline), + new("^.+? =.+?($|.*?\\/\\/)", RegexOptions.Multiline) + }; + + _client.MessageReceived += EventGuard.Guarded(CodeCheck, nameof(CodeCheck)); + + LoadData(); + UpdateLoop(); + } + + private async void UpdateLoop() + { + try + { + while (!_shutdownToken.IsCancellationRequested) + { + await Task.Delay(10000, _shutdownToken); + SaveData(); + } + } + catch (OperationCanceledException) { SaveData(); } + catch (Exception e) + { + LoggingService.LogToConsole($"[CodeCheckService.UpdateLoop] Unhandled exception: {e}", LogSeverity.Error); + } + } + + private void LoadData() + { + var data = _updateService.GetUserData(); + CodeReminderCooldown = data.CodeReminderCooldown ?? new Dictionary(); + } + + private void SaveData() + { + var data = new UserData + { + CodeReminderCooldown = CodeReminderCooldown + }; + _updateService.SetUserData(data); + } + + public async Task CodeCheck(SocketMessage messageParam) + { + if (messageParam.Author.IsBot || messageParam.Channel.Id == _settings.Channels.General.Id) + return; + + if (messageParam.Content.Length < 200) + return; + + var userId = messageParam.Author.Id; + + if (!CodeReminderCooldown.HasUser(userId)) + { + var content = messageParam.Content; + + var foundTrippleCodeBlock = _x3CodeBlock.Match(content); + if (foundTrippleCodeBlock.Groups["CS"].Length > 0) + return; + if (foundTrippleCodeBlock.Groups["CodeBlock"].Success) + { + await (messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{_codeReminderFormattingExample}") + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); + return; + } + + var foundDoubleCodeBlock = _x2CodeBlock.Match(content).Success; + + int hits = 0; + foreach (var regex in _codeBlockWarnPatterns) + { + hits += regex.Match(content).Captures.Count; + } + + if (!foundDoubleCodeBlock && hits >= 3) + { + await (messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} are you sharing C# scripts? Remember to use codeblocks to help readability!\n{_codeReminderFormattingExample}") + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); + if (content.Length > _maxCodeBlockLengthWarning) + { + await (messageParam.Channel.SendMessageAsync( + "The code you're sharing is quite long, maybe use a free service like and share the link here instead.") + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); + } + } + else if (foundDoubleCodeBlock && hits > 0) + { + await (messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} when using code blocks remember to use \\`\\`\\`cs as this will help improve readability for C# scripts.\n{_codeReminderFormattingExample}") + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); + } + } + } +} diff --git a/DiscordBot/Services/Code/Tips/Components/Tip.cs b/DiscordBot/Services/Code/Tips/Components/Tip.cs new file mode 100644 index 00000000..c8032ebf --- /dev/null +++ b/DiscordBot/Services/Code/Tips/Components/Tip.cs @@ -0,0 +1,12 @@ +using Discord; + +namespace DiscordBot.Services.Code.Tips.Components; + +public class Tip : IEntity +{ + public ulong Id { get; set; } + public string Content { get; set; } = string.Empty; + public List Keywords { get; set; } = []; + public List ImagePaths { get; set; } = []; + public int Requests { get; set; } +} diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Code/Tips/TipService.cs similarity index 89% rename from DiscordBot/Services/Tips/TipService.cs rename to DiscordBot/Services/Code/Tips/TipService.cs index 24cd0404..718ac914 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Code/Tips/TipService.cs @@ -5,31 +5,32 @@ using System.Net.Http; using Discord; using Discord.WebSocket; -using DiscordBot.Services.Tips.Components; using DiscordBot.Settings; using Newtonsoft.Json; -namespace DiscordBot.Services.Tips; +namespace DiscordBot.Services.Code.Tips; public class TipService { - private const string ServiceName = "TipService"; + private const string ServiceName = "TipService"; private const string DatabaseName = "tips.json"; private readonly BotSettings _settings; private readonly ILoggingService _loggingService; - private readonly string _imageDirectory; + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _imageDirectory = null!; private ConcurrentDictionary> _tips = new(); private bool _isRunning = false; private bool _readOnly = false; - private Regex keywordPattern = null; + private Regex? keywordPattern = null; - public TipService(BotSettings settings, ILoggingService loggingService) + public TipService(BotSettings settings, ILoggingService loggingService, IHttpClientFactory httpClientFactory) { _settings = settings; _loggingService = loggingService; + _httpClientFactory = httpClientFactory; if (string.IsNullOrEmpty(_settings.ServerRootPath)) { @@ -37,25 +38,25 @@ public TipService(BotSettings settings, ILoggingService loggingService) _isRunning = false; return; } - - if (string.IsNullOrEmpty(_settings.TipImageDirectory)) + + if (string.IsNullOrEmpty(_settings.UnityHelp.TipImageDirectory)) { _loggingService.LogAction($"[{ServiceName}] TipImageDirectory not set, service will not run.", ExtendedLogSeverity.Warning); _isRunning = false; return; } - _imageDirectory = Path.Combine(_settings.ServerRootPath, _settings.TipImageDirectory); + _imageDirectory = Path.Combine(_settings.ServerRootPath, _settings.UnityHelp.TipImageDirectory); Initialize(); } - + private void Initialize() { if (_isRunning) return; _readOnly = false; - var jsonPath = GetTipPath(DatabaseName);; + var jsonPath = GetTipPath(DatabaseName); ; if (!Directory.Exists(_imageDirectory)) { _loggingService.LogAction($"[{ServiceName}] Tip directory {_imageDirectory} did not exist.", ExtendedLogSeverity.Info); @@ -65,14 +66,14 @@ private void Initialize() else { var directorySize = new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Sum(file => file.Length); - if (directorySize > _settings.TipMaxDirectoryFileSize) + if (directorySize > _settings.UnityHelp.TipMaxDirectoryFileSize) { - _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, exceeding the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning); + _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, exceeding the limit of {_settings.UnityHelp.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning); _readOnly = true; } else { - _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, within the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB.", ExtendedLogSeverity.Info); + _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, within the limit of {_settings.UnityHelp.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB.", ExtendedLogSeverity.Info); _loggingService.LogAction($"[{ServiceName}] Tip directory contains {new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Count()} files.", ExtendedLogSeverity.Info); } @@ -102,7 +103,7 @@ private bool IsValidTipKeyword(string keyword) private bool IsValidTipAttachment(IAttachment attachment) { - if (attachment.Size > _settings.TipMaxImageFileSize) + if (attachment.Size > _settings.UnityHelp.TipMaxImageFileSize) return false; // Discord-friendly attachment image file formats only @@ -154,11 +155,11 @@ public async Task AddTip(IUserMessage message, string keywords, string content) attachment.Filename.Substring(attachment.Filename.LastIndexOf('.')); var filePath = GetTipPath(newFileName); - using var client = new HttpClient(); + using var client = _httpClientFactory.CreateClient(); await using var stream = await client.GetStreamAsync(attachment.Url); await using var file = File.Create(filePath); await stream.CopyToAsync(file); - + imagePaths.Add(newFileName); } @@ -258,19 +259,19 @@ public async Task ReplaceTip(IUserMessage message, Tip tip, string content) return; } - RemoveTip(message, tip); - AddTip(message, string.Join(",", tip.Keywords), content); + await RemoveTip(message, tip); + await AddTip(message, string.Join(",", tip.Keywords), content); // REVIEW: causes two CommitTipDatabase calls } public async Task ReloadTipDatabase() { - var jsonPath = GetTipPath(DatabaseName);; + var jsonPath = GetTipPath(DatabaseName); ; if (File.Exists(jsonPath)) { - var json = File.ReadAllText(jsonPath); - _tips = JsonConvert.DeserializeObject>>(json); - _loggingService.LogAction( + var json = File.ReadAllText(jsonPath); + _tips = JsonConvert.DeserializeObject>>(json)!; + _ = _loggingService.LogAction( $"[{ServiceName}] Tip index has {_tips.Count} keywords.", ExtendedLogSeverity.Info); } @@ -302,7 +303,7 @@ public async Task ReloadTipDatabase() if (touched) { - _loggingService.LogAction( + _ = _loggingService.LogAction( $"[{ServiceName}] Tip index was de-duplicated.", ExtendedLogSeverity.Info); await CommitTipDatabase(); @@ -323,12 +324,7 @@ await File.WriteAllTextAsync(jsonPath, settings)); } - public string DumpTipDatabase() - { - return JsonConvert.SerializeObject(_tips); - } - - public Tip GetTip(ulong Id) + public Tip? GetTip(ulong Id) { foreach (var kvp in _tips) foreach (var tip in kvp.Value) diff --git a/DiscordBot/Services/Code/Unity/FeedService.cs b/DiscordBot/Services/Code/Unity/FeedService.cs new file mode 100644 index 00000000..488f6d19 --- /dev/null +++ b/DiscordBot/Services/Code/Unity/FeedService.cs @@ -0,0 +1,221 @@ +using System.IO; +using System.ServiceModel.Syndication; +using System.Xml; +using Discord.WebSocket; +using DiscordBot.Settings; +using DiscordBot.Utils; + +namespace DiscordBot.Services.Code.Unity; + +public class FeedService +{ + private const string ServiceName = "FeedService"; + private readonly DiscordSocketClient _client; + + private readonly BotSettings _settings; + private readonly ILoggingService _logging; + private readonly IWebClient _webClient; + private readonly ReleaseNotesParser _releaseNotesParser; + + #region Configurable Settings + + private const int MaxFeedLengthBuffer = 400; + #region News Feed Config + + private class ForumNewsFeed + { + public string TitleFormat { get; set; } = null!; + public string Url { get; set; } = null!; + public List IncludeTags { get; set; } = null!; + public bool IsRelease { get; set; } = false; + } + + private readonly ForumNewsFeed _betaNews = new() + { + TitleFormat = "Beta Release - {0}", + Url = "https://unity3d.com/unity/beta/latest.xml", + IncludeTags = new() { "Beta Update" }, + IsRelease = true + }; + private readonly ForumNewsFeed _releaseNews = new() + { + TitleFormat = "New Release - {0}", + Url = "https://unity3d.com/unity/releases.xml", + IncludeTags = new() { "New Release" }, + IsRelease = true + }; + private readonly ForumNewsFeed _blogNews = new() + { + TitleFormat = "Blog - {0}", + Url = "https://blogs.unity3d.com/feed/", + IncludeTags = new() { "Unity Blog" }, + IsRelease = false + }; + + #endregion // News Feed Config + + // We store the title of the last 40 posts, and check against them to prevent duplicate posts + private const int MaxHistoryCheck = 40; + private readonly List _postedFeeds = new(MaxHistoryCheck); + + private const int MaximumCheck = 3; + private const ThreadArchiveDuration ForumArchiveDuration = ThreadArchiveDuration.OneWeek; + + #endregion // Configurable Settings + + public FeedService(DiscordSocketClient client, BotSettings settings, ILoggingService logging, IWebClient webClient, ReleaseNotesParser releaseNotesParser) + { + _client = client; + _settings = settings; + _logging = logging; + _webClient = webClient; + _releaseNotesParser = releaseNotesParser; + } + + private async Task GetFeedData(string url) + { + SyndicationFeed? feed = null; + try + { + var content = await _webClient.GetXMLContent(url); + var reader = XmlReader.Create(new StringReader(content)); + feed = SyndicationFeed.Load(reader); + } + catch (Exception e) + { + LoggingService.LogToConsole($"[{ServiceName} Feed failure: {e.ToString()}", ExtendedLogSeverity.LowWarning); + } + + // Return the feed, empty feed if null to prevent additional checks for null on return + return feed ??= new SyndicationFeed(); + } + + #region Feed Handlers + + private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong channelId, ulong? roleId) + { + try + { + var feed = await GetFeedData(newsFeed.Url); + if (_client.GetChannel(channelId) is not IForumChannel channel) + { + await _logging.LogAction($"[{ServiceName}] Error: Channel {channelId} not found", ExtendedLogSeverity.Error); + return; + } + foreach (var item in feed.Items.Take(MaximumCheck)) + { + if (feedData.PostedIds.Contains(item.Id)) + continue; + feedData.PostedIds.Add(item.Id); + + // Title + var newsTitle = string.Format(newsFeed.TitleFormat, item.Title.Text); + if (newsTitle.Length > 90) + newsTitle = newsTitle[..90] + "..."; + + // Confirm we haven't posted this title before + if (_postedFeeds.Contains(newsTitle)) + continue; + _postedFeeds.Add(newsTitle); + if (_postedFeeds.Count > MaxHistoryCheck) + _postedFeeds.RemoveAt(0); + + // Message + var newsContent = string.Empty; + List releaseNotes = new(); + if (!newsFeed.IsRelease) + newsContent = GetSummary(newsFeed, item); + else + { + try + { + releaseNotes = _releaseNotesParser.Parse(item.Summary.Text); + } + catch (Exception e) + { + _ = _logging.LogChannelAndFile($"[{ServiceName}] Error generating release notes: {e}\nLikely updated format.", ExtendedLogSeverity.Warning); + releaseNotes = new List { "No release notes found" }; + } + newsContent = releaseNotes[0]; + } + + // If a role is provided we add to end of title to ping the role + var role = _client.GetGuild(_settings.GuildId).GetRole(roleId ?? 0); + if (role != null) + newsContent += $"\n{role.Mention}"; + // Link to post + if (item.Links.Count > 0) + newsContent += $"\n\n**__Source__**\n{item.Links[0].Uri}"; + + newsContent = newsContent.SanitizeEveryoneHereMentions(); + + // The Post + var post = await channel.CreatePostAsync(newsTitle, ForumArchiveDuration, null, newsContent, null, null, AllowedMentions.All); + await AddTagsToPost(channel, post, newsFeed.IncludeTags); + + if (releaseNotes.Count == 1) + continue; + + // post a new message for each release note after the first + for (int i = 1; i < releaseNotes.Count; i++) + { + if (releaseNotes[i].Length == 0) + continue; + await post.SendMessageAsync(releaseNotes[i].SanitizeEveryoneHereMentions()); + } + } + } + catch (Exception e) + { + await _logging.LogAction($"[{ServiceName}] Error: {e}", ExtendedLogSeverity.Error); + } + } + + private async Task AddTagsToPost(IForumChannel channel, IThreadChannel post, List tags) + { + if (tags.Count <= 0) + return; + + var includedTags = new List(); + foreach (var tag in tags) + { + var tagContainer = channel.Tags.FirstOrDefault(x => x.Name == tag); + if (tagContainer != null) + includedTags.Add(tagContainer.Id); + } + + await post.ModifyAsync(properties => { properties.AppliedTags = includedTags; }); + } + + private string GetSummary(ForumNewsFeed feed, SyndicationItem item) + { + var summary = global::DiscordBot.Utils.Utils.RemoveHtmlTags(item.Summary.Text); + + // If it is too long, we truncate it + var summaryLength = summary.Length; + if (summaryLength > Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) + summary = summary[..(Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer)] + "..."; + return summary; + } + + #endregion // Feed Handlers + + #region Public Feed Actions + + public async Task CheckUnityBetasAsync(FeedData feedData) + { + await HandleFeed(feedData, _betaNews, _settings.Channels.UnityReleases.Id, _settings.Roles.SubsReleases); + } + + public async Task CheckUnityReleasesAsync(FeedData feedData) + { + await HandleFeed(feedData, _releaseNews, _settings.Channels.UnityReleases.Id, _settings.Roles.SubsReleases); + } + + public async Task CheckUnityBlogAsync(FeedData feedData) + { + await HandleFeed(feedData, _blogNews, _settings.Channels.UnityNews.Id, _settings.Roles.SubsNews); + } + + #endregion // Feed Actions +} \ No newline at end of file diff --git a/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs b/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs new file mode 100644 index 00000000..3391c829 --- /dev/null +++ b/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs @@ -0,0 +1,120 @@ +using HtmlAgilityPack; + +namespace DiscordBot.Services.Code.Unity; + +public class ReleaseNotesParser +{ + private const int MaxFeedLengthBuffer = 400; + + public List Parse(string summaryHtml) + { + var htmlDoc = new HtmlDocument(); + summaryHtml = summaryHtml.Replace("→", "->"); + htmlDoc.LoadHtml(summaryHtml); + + var summaryNode = htmlDoc.DocumentNode.SelectSingleNode("//div[@class='release-notes']"); + if (summaryNode == null) + return new List { "No release notes found" }; + + var knownIssueNode = FindH3Sibling(summaryNode, "Known Issues"); + var entriesSinceNode = summaryNode.ChildNodes + .FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Entries since")); + + var featuresNode = FindH4Sibling(summaryNode, "Features"); + var improvementsNode = FindH4Sibling(summaryNode, "Improvements"); + var apiChangesNode = FindH4Sibling(summaryNode, "API Changes"); + var changesNode = FindH4Sibling(summaryNode, "Changes"); + var fixesNode = FindH4Sibling(summaryNode, "Fixes"); + var packagesUpdatedNode = summaryNode.ChildNodes + .FirstOrDefault(x => x.Name == "h4" && x.InnerText.ToLower().Contains("package changes")) + ?.NextSibling?.NextSibling?.NextSibling; + + var summary = "**Summary**\n"; + summary += GetNodeLiCountString("Known Issues", knownIssueNode?.NextSibling); + + if (entriesSinceNode != null) + summary += $"__{entriesSinceNode.InnerText}__\n\n"; + + summary += GetNodeLiCountString("Features", featuresNode?.NextSibling); + summary += GetNodeLiCountString("Improvements", improvementsNode?.NextSibling); + summary += GetNodeLiCountString("API Changes", apiChangesNode?.NextSibling); + summary += GetNodeLiCountString("Changes", changesNode?.NextSibling); + summary += GetNodeLiCountString("Fixes", fixesNode?.NextSibling); + summary += GetNodeLiCountString("Packages Updated", packagesUpdatedNode?.NextSibling); + + var releaseNotes = new List + { + BuildSection("Packages Updated", packagesUpdatedNode, summary), + BuildSection("Features", featuresNode), + BuildSection("Improvements", improvementsNode, "", 1000), + BuildSection("API Changes", apiChangesNode), + BuildSection("Changes", changesNode), + BuildSection("Fixes", fixesNode, ""), + BuildSection("Known Issues", knownIssueNode, "", 1200) + }; + + return releaseNotes; + } + + private static HtmlNode? FindH3Sibling(HtmlNode parent, string text) + { + return parent.ChildNodes + .FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains(text)) + ?.NextSibling; + } + + private static HtmlNode? FindH4Sibling(HtmlNode parent, string text) + { + return parent.ChildNodes + .FirstOrDefault(x => x.Name == "h4" && x.InnerText == text) + ?.NextSibling; + } + + private string BuildSection(string title, HtmlNode? node, string contents = "", + int maxLength = Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) + { + if (node == null) + return string.Empty; + + var summary = $"{(contents.Length > 0 ? $"{contents}\n" : string.Empty)}**{node.PreviousSibling.InnerText}**\n"; + + bool needsExtraProcessing = title is "Fixes" or "Known Issues" or "API Changes"; + + foreach (var feature in node.NextSibling.ChildNodes.Where(x => x.Name == "li")) + { + var extraText = string.Empty; + if (needsExtraProcessing) + { + var nodeContents = feature.ChildNodes[0]; + nodeContents.InnerHtml = nodeContents.InnerHtml.Replace("\n", " "); + + var linkNode = nodeContents.SelectSingleNode("a"); + if (linkNode != null) + { + nodeContents = nodeContents.RemoveChild(linkNode); + feature.InnerHtml = feature.InnerHtml.Replace("()", ""); + extraText = $" ([{linkNode.InnerText}](<{linkNode.Attributes["href"].Value}>))"; + } + } + + summary += $"- {feature.InnerText}{extraText}\n"; + if (summary.Length > maxLength) + { + var lastLine = summary[..maxLength].LastIndexOf('\n'); + summary = summary[..lastLine] + $"\n{title} truncated...\n"; + return summary; + } + } + + return summary; + } + + private static string GetNodeLiCountString(string title, HtmlNode? node) + { + if (node == null) + return string.Empty; + + var count = node.ChildNodes.Count(x => x.Name == "li"); + return $"{title}: {count}\n"; + } +} diff --git a/DiscordBot/Services/Code/Unity/UnityDocParser.cs b/DiscordBot/Services/Code/Unity/UnityDocParser.cs new file mode 100644 index 00000000..6630edda --- /dev/null +++ b/DiscordBot/Services/Code/Unity/UnityDocParser.cs @@ -0,0 +1,32 @@ +using DiscordBot.Domain; +using HtmlAgilityPack; + +namespace DiscordBot.Services.Code.Unity; + +public static class UnityDocParser +{ + public static DocEntry[] ConvertJsToArray(string data, bool isManual) + { + var list = new List(); + string pagesInput; + + if (isManual) + { + pagesInput = data.Split("info = [")[0].Split("pages=")[1]; + pagesInput = pagesInput[2..^2]; + } + else + { + pagesInput = data.Split("info =")[0]; + pagesInput = pagesInput[63..^2]; + } + + foreach (var s in pagesInput.Split("],[")) + { + var ps = s.Split(","); + list.Add(new DocEntry(ps[0].Replace("\"", ""), ps[1].Replace("\"", ""))); + } + + return list.ToArray(); + } +} diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs similarity index 97% rename from DiscordBot/Services/UnityHelp/CannedResponseService.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs index ca826aba..7ea74154 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs @@ -1,11 +1,9 @@ -namespace DiscordBot.Service; +namespace DiscordBot.Services.Code.Unity.UnityHelp; public class CannedResponseService { - private const string ServiceName = "CannedResponseService"; - #region Configuration - + public enum CannedResponseType { HowToAsk, @@ -55,7 +53,7 @@ public enum CannedHelp GameTooBig = CannedResponseType.GameTooBig, HowToGoogle = CannedResponseType.HowToGoogle, } - + public enum CannedResources { Programming = CannedResponseType.Programming, @@ -70,11 +68,11 @@ public enum CannedResources // PerformanceAndOptimization = CannedResponseType.PerformanceAndOptimization, // UIUX = CannedResponseType.UIUX } - + private readonly Color _defaultEmbedColor = new Color(0x00, 0x80, 0xFF); #region Canned Help - + private readonly EmbedBuilder _howToAskEmbed = new EmbedBuilder { Title = "How to Ask", @@ -84,7 +82,7 @@ public enum CannedResources "See: [How to Ask](https://stackoverflow.com/help/how-to-ask)", Url = "https://stackoverflow.com/help/how-to-ask", }; - + private readonly EmbedBuilder _pasteEmbed = new EmbedBuilder { Title = "How to Paste Code", @@ -102,14 +100,14 @@ public enum CannedResources "This will make your code easier to read and copy. If your code is too long, consider using a service like [GitHub Gist](https://gist.github.com/) or [Pastebin](https://pastebin.com/).", Url = "https://pastebin.com/", }; - + private readonly EmbedBuilder _noCodeEmbed = new EmbedBuilder { Title = "No Code Provided", Description = "***Where the code at?*** It appears you're trying to ask something that would benefit from showing what you've tried, but you haven't provided much code. " + "Someone who wants to help you won't be able to do so without seeing the code you're working with." }; - + private readonly EmbedBuilder _xyProblemEmbed = new EmbedBuilder { Title = "XY Problem", @@ -120,7 +118,7 @@ public enum CannedResources "- If you've tried something, tell us what you tried", Url = "https://xyproblem.info/", }; - + private readonly EmbedBuilder _gameTooBigEmbed = new EmbedBuilder { Title = "Game Too Big", @@ -138,7 +136,7 @@ public enum CannedResources "See: [How to Google](https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html)", Url = "https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html", }; - + private readonly EmbedBuilder _deltaTime = new EmbedBuilder { Title = "Frame Independence", @@ -155,11 +153,11 @@ public enum CannedResources "[Update](https://docs.unity3d.com/ScriptReference/MonoBehaviour.Update.html) or " + "`fixedDeltaTime` [FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html) for consistent speed.\n" + "See: [Time Frame Management](https://docs.unity3d.com/Manual/TimeFrameManagement.html), " + - "[FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html), " + + "[FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html), " + "[DeltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html)", Url = "https://docs.unity3d.com/Manual/TimeFrameManagement.html", }; - + private readonly EmbedBuilder _debugging = new EmbedBuilder { Title = "Debugging in Unity", @@ -172,7 +170,7 @@ public enum CannedResources "Debugging improves with practice, enhancing your bug identification and resolution skills.", Url = "https://docs.unity3d.com/Manual/ManagedCodeDebugging.html", }; - + private readonly EmbedBuilder _folderStructure = new EmbedBuilder { Title = "Folder Structure", @@ -185,11 +183,11 @@ public enum CannedResources "See: [Organizing Your Project](https://unity.com/how-to/organizing-your-project)", Url = "https://unity.com/how-to/organizing-your-project", }; - + #endregion #region Canned Resources - + private readonly EmbedBuilder _programmingEmbed = new EmbedBuilder { Title = "Programming Resources", @@ -202,7 +200,7 @@ public enum CannedResources "- Design Patterns: [Game Programming Patterns](https://gameprogrammingpatterns.com/)", Url = "https://learn.unity.com/project/roll-a-ball" }; - + private readonly EmbedBuilder _artEmbed = new EmbedBuilder { Title = "Art Resources", @@ -212,7 +210,7 @@ public enum CannedResources "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-royalty-free)\n" + "- Blender Discord: [Server Invite](https://discord.gg/blender)" }; - + private readonly EmbedBuilder _threeDEmbed = new EmbedBuilder { Title = "3D Resources", @@ -222,7 +220,7 @@ public enum CannedResources "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-3d/tag-royalty-free)\n" + "- Blender Discord: [Server Invite](https://discord.gg/blender)" }; - + private readonly EmbedBuilder _twoDEmbed = new EmbedBuilder { Title = "2D Resources", @@ -231,7 +229,7 @@ public enum CannedResources "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-2d)\n" + "- Blender Discord: [Server Invite](https://discord.gg/blender)" }; - + private readonly EmbedBuilder _audioEmbed = new EmbedBuilder { Title = "Audio Resources", @@ -242,7 +240,7 @@ public enum CannedResources "- Audio Editor: [Audacity](https://www.audacityteam.org/)\n" + "- Sound Design Explained: [PitchBlends](https://www.pitchbends.com/posts/what-is-sound-design)" }; - + private readonly EmbedBuilder _designEmbed = new EmbedBuilder { Title = "Design Resources", @@ -254,25 +252,25 @@ public enum CannedResources "- Iconography: [Flaticon](https://www.flaticon.com/)\n" + "- Free Icons: [Icon Monstr](https://iconmonstr.com/)" }; - + #endregion - + #endregion // Configuration - - public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor = null) + + public EmbedBuilder? GetCannedResponse(CannedResponseType type, IUser? requestor = null) { var embed = GetUnbuiltCannedResponse(type); if (embed == null) return null; - + if (requestor != null) embed.FooterRequestedBy(requestor); embed.WithColor(_defaultEmbedColor); - + return embed; } - - public EmbedBuilder GetUnbuiltCannedResponse(CannedResponseType type) + + public EmbedBuilder? GetUnbuiltCannedResponse(CannedResponseType type) { return type switch { diff --git a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs b/DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs similarity index 88% rename from DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs index 48c7677a..2ab19a3b 100644 --- a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs @@ -1,4 +1,4 @@ -namespace DiscordBot.Services.UnityHelp; +namespace DiscordBot.Services.Code.Unity.UnityHelp; public enum HelpMessageType { @@ -12,7 +12,7 @@ public class HelpBotMessage { public ulong MessageId { get; set; } public HelpMessageType Type { get; set; } - + public HelpBotMessage(ulong messageId, HelpMessageType type) { MessageId = messageId; diff --git a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs b/DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs similarity index 89% rename from DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs index a3779b3a..de98d804 100644 --- a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs @@ -1,4 +1,4 @@ -namespace DiscordBot.Services.UnityHelp; +namespace DiscordBot.Services.Code.Unity.UnityHelp; public class ThreadContainer { @@ -10,17 +10,17 @@ public class ThreadContainer public bool IsResolved { get; set; } = false; public bool HasInteraction { get; set; } = false; - - + + public ulong BotsLastMessage { get; set; } - public CancellationTokenSource CancellationToken { get; set; } + public CancellationTokenSource? CancellationToken { get; set; } public DateTime ExpectedShutdownTime { get; set; } - + /// /// Any message the bot sends that could need to be tracked/deleted later is stored here. /// public Dictionary HelpMessages { get; set; } = new(); - + public bool HasMessage(HelpMessageType type) => HelpMessages.ContainsKey(type); public ulong GetMessageId(HelpMessageType type) => HelpMessages[type].MessageId; public void AddMessage(HelpMessageType type, ulong messageId) => HelpMessages.Add(type, new HelpBotMessage(messageId, type)); diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs similarity index 91% rename from DiscordBot/Services/UnityHelp/UnityHelpService.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs index 2ae6b3c4..18f6d6a6 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs @@ -1,8 +1,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -using DiscordBot.Services.UnityHelp; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Code.Unity.UnityHelp; // TODO : (James) Better Slash Command Support @@ -11,13 +10,12 @@ public class UnityHelpService private const string ServiceName = "UnityHelpService"; private readonly DiscordSocketClient _client; - private readonly ILoggingService _logging; private SocketRole ModeratorRole { get; set; } - + #region Configuration - + private static readonly Emoji ThumbUpEmoji = new Emoji("👍"); - + private const int TimeBeforeClosedForResolvedTag = 10; private readonly Embed _resolvedWarnOfPendingCloseEmbedHasPin = new EmbedBuilder() .WithTitle($"Issue Resolved") @@ -46,7 +44,7 @@ public class UnityHelpService .WithColor(Color.LightOrange) .Build(); private const int StealthDeleteTime = 60 * 5; - + private readonly Embed _noAppliedTagsEmbed = new EmbedBuilder() .WithTitle("Warning: No Tags Applied") .WithDescription($"Consider adding tags to your question to help others find it!\n" + @@ -66,37 +64,36 @@ public class UnityHelpService .WithFooter("Be descriptive of the problem!") .WithColor(Color.LightOrange) .Build(); - + #endregion // Configuration #region Extra Details - - private readonly IForumChannel _helpChannel; - + + private readonly IForumChannel _helpChannel = null!; + private readonly ForumTag _resolvedForumTag; #endregion // Extra Details - public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggingService logging) + public UnityHelpService(DiscordSocketClient client, BotSettings settings) { _client = client; - _logging = logging; - - ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); - if (!settings.UnityHelpBabySitterEnabled) + ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.Roles.Moderator); + + if (!settings.UnityHelp.BabySitterEnabled) { - LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelpBabySitterEnabled)); + LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelp.BabySitterEnabled)); return; } - - // get the help channel settings.GenericHelpChannel - _helpChannel = _client.GetChannel(settings.GenericHelpChannel.Id) as IForumChannel; + + // get the help channel settings.Channels.GenericHelp + _helpChannel = (_client.GetChannel(settings.Channels.GenericHelp.Id) as IForumChannel)!; if (_helpChannel == null) { LoggingService.LogToConsole($"[{ServiceName}] Help channel not found", LogSeverity.Error); } - var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.TagUnitHelpResolvedTag)); + var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.UnityHelp.TagResolved)); if (resolvedTag == null || resolvedTag.Id <= 0) LoggingService.LogToConsole($"[{ServiceName}] Resolved tag not found", LogSeverity.Error); _resolvedForumTag = resolvedTag; @@ -107,15 +104,15 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggi _client.ThreadCreated += GatewayOnThreadCreated; _client.ThreadUpdated += GatewayOnThreadUpdated; _client.ThreadDeleted += GatewayOnThreadDeleted; - + _client.ThreadMemberJoined += GatewayOnThreadMemberJoinedThread; _client.ThreadMemberLeft += GatewayOnThreadMemberLeftThread; - + _client.MessageReceived += GatewayOnMessageReceived; _client.MessageUpdated += GatewayOnMessageUpdated; Task.Run(LoadActiveThreads); - + LoggingService.LogServiceEnabled(ServiceName); } @@ -139,13 +136,11 @@ private async Task LoadActiveThreads() if (threadContainer.IsResolved) { // Run in new task so we don't block the other threads from being processed -#pragma warning disable CS4014 - Task.Run(() => CloseThreadInTime(threadContainer, string.Empty, + CloseThreadInTime(threadContainer, string.Empty, TimeBeforeClosedForResolvedTag, (threadContainer.PinnedAnswer != 0 ? _resolvedWarnOfPendingCloseEmbedHasPin - : _resolvedWarnOfPendingCloseEmbedNoPin))); -#pragma warning restore CS4014 + : _resolvedWarnOfPendingCloseEmbedNoPin)).SafeFireAndForget(ServiceName); } else { @@ -155,12 +150,12 @@ private async Task LoadActiveThreads() } #region Thread Tracking - + // Threads we're currently tracking private readonly Dictionary _activeThreads = new(); #region Thread Creation - + private async Task OnThreadCreated(SocketThreadChannel thread) { ThreadContainer container = new() @@ -170,9 +165,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread) Owner = thread.Owner.Id, }; _activeThreads.Add(thread.Id, container); - - bool warnHelpTitle = false; - + // Check message length and inform user if too short var firstMessage = (await thread.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); container.FirstUserMessage = firstMessage!.Id; @@ -182,7 +175,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread) container.AddMessage(HelpMessageType.QuestionLength, botResponse.Id); // container.WarningMessage = botResponse.Id; } - + var threadTitle = thread.Name; if (threadTitle.IsAllCaps()) { @@ -190,9 +183,6 @@ private async Task OnThreadCreated(SocketThreadChannel thread) } await thread.ModifyAsync(x => x.Name = threadTitle.ToCapitalizeFirstLetter()); - if (thread.Name.Contains(" help", StringComparison.CurrentCultureIgnoreCase)) - warnHelpTitle = true; - // If not tags attached, let them know they should add some if (thread.AppliedTags.Count == 0) { @@ -205,7 +195,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread) // Sets up the thread to be closed after a certain amount of time (This will quickly be removed if anyone interacts with the thread) await StealthDeleteThreadInTime(container); } - + private Task GatewayOnThreadCreated(SocketThreadChannel thread) { if (!thread.IsThreadInChannel(_helpChannel.Id)) @@ -220,13 +210,13 @@ private Task GatewayOnThreadCreated(SocketThreadChannel thread) // Ignore new thread if age is over, 5 mins? if (thread.CreatedAt < DateTime.Now.AddMinutes(-5)) return Task.CompletedTask; - + LoggingService.DebugLog($"[{ServiceName}] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); - Task.Run(() => OnThreadCreated(thread)); - + OnThreadCreated(thread).SafeFireAndForget(ServiceName); + return Task.CompletedTask; } - + #endregion // Thread Creation #region Thread Update @@ -278,7 +268,7 @@ private async Task OnThreadUpdated(SocketThreadChannel before, SocketThreadChann // // } } - + private async Task GatewayOnThreadUpdated(Cacheable before, SocketThreadChannel after) { if (!after.IsThreadInChannel(_helpChannel.Id)) @@ -296,16 +286,14 @@ private async Task GatewayOnThreadUpdated(Cacheable } LoggingService.DebugLog($"[{ServiceName}] Thread Updated: {after.Id} - {after.Name}", LogSeverity.Debug); - -#pragma warning disable CS4014 - Task.Run(() => OnThreadUpdated(beforeThread, afterThread)); -#pragma warning restore CS4014 + + OnThreadUpdated(beforeThread, afterThread).SafeFireAndForget(ServiceName); } - + #endregion // Thread Update #region Thread Deleted - + private async Task OnThreadDeleted(SocketThreadChannel channel) { await EndThreadTracking(channel.Id); @@ -315,24 +303,22 @@ private async Task GatewayOnThreadDeleted(Cacheable { if (!_activeThreads.ContainsKey(threadId.Id)) return; - + LoggingService.DebugLog($"[{ServiceName}] Thread Deleted: {threadId.Id}", LogSeverity.Debug); var thread = await threadId.GetOrDownloadAsync(); -#pragma warning disable CS4014 - Task.Run(() => OnThreadDeleted(thread)); -#pragma warning restore CS4014 + OnThreadDeleted(thread).SafeFireAndForget(ServiceName); } - + #endregion // Thread Deleted #region User Joins/Leaves Thread - + private Task GatewayOnThreadMemberJoinedThread(SocketThreadUser user) { if (user.IsUserBotOrWebhook()) return Task.CompletedTask; - + if (!user.Thread.IsThreadInChannel(_helpChannel.Id)) return Task.CompletedTask; if (!_activeThreads.TryGetValue(user.Thread.Id, out var thread)) @@ -346,27 +332,25 @@ private Task GatewayOnThreadMemberLeftThread(SocketThreadUser user) { if (!user.Thread.IsThreadInChannel(_helpChannel.Id)) return Task.CompletedTask; - + return Task.CompletedTask; // TODO : (James) Check if user was author? If so, close thread? } - + #endregion // User Joins/Leaves Thread #region Message Received - + private async Task OnMessageReceived(SocketMessage message) { var thread = _activeThreads[message.Channel.Id]; - + thread.LatestUserMessage = message.Id; // If Author is only one who has interacted with the thread, we don't need to update anything else if (!thread.HasInteraction && message.Author.Id == thread.Owner) { -#pragma warning disable CS4014 - Task.Run(() => StealthDeleteThreadInTime(thread)); -#pragma warning restore CS4014 + StealthDeleteThreadInTime(thread).SafeFireAndForget(ServiceName); return; } @@ -382,7 +366,7 @@ private async Task OnMessageReceived(SocketMessage message) await RequestThreadShutdownInTime(thread, HasResponseMessageRequestClose + HasResponseExtraMessage, HasResponseIdleTimeOtherUser); } } - + private Task GatewayOnMessageReceived(SocketMessage message) { if (!message.Channel.IsThreadInChannel(_helpChannel.Id)) @@ -391,16 +375,16 @@ private Task GatewayOnMessageReceived(SocketMessage message) return Task.CompletedTask; if (!_activeThreads.TryGetValue(message.Channel.Id, out var thread)) return Task.CompletedTask; - + LoggingService.DebugLog($"[{ServiceName}] Help Message Received: {message.Id} - {message.Content}", LogSeverity.Debug); - Task.Run(() => OnMessageReceived(message)); + OnMessageReceived(message).SafeFireAndForget(ServiceName); return Task.CompletedTask; } private async Task OnMessageUpdated(IMessage before, IMessage after, SocketThreadChannel channel) { var thread = _activeThreads[channel.Id]; - + if (thread.HasMessage(HelpMessageType.QuestionLength) && before.Id == thread.FirstUserMessage) { if (after.Content.Length > MinimumLengthMessage) @@ -412,7 +396,7 @@ private async Task OnMessageUpdated(IMessage before, IMessage after, SocketThrea } } } - + private async Task GatewayOnMessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) { if (channel is not SocketThreadChannel threadChannel) @@ -421,22 +405,20 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So return; if (after.Author.IsUserBotOrWebhook()) return; - + if (!_activeThreads.TryGetValue(channel.Id, out var thread)) return; - + // This is done a bit late as we may need to check message from other authors if (thread.Owner != after.Author.Id) return; - + var beforeMsg = await before.GetOrDownloadAsync(); if (beforeMsg == null) return; LoggingService.DebugLog($"[{ServiceName}] Help Message Updated: {after.Id} - {after.Content}", LogSeverity.Debug); -#pragma warning disable CS4014 - Task.Run(() => OnMessageUpdated(beforeMsg, after, channel as SocketThreadChannel)); -#pragma warning restore CS4014 + OnMessageUpdated(beforeMsg, after, (channel as SocketThreadChannel)!).SafeFireAndForget(ServiceName); if (after.Reactions.ContainsKey(CloseEmoji)) { @@ -450,7 +432,7 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So } #endregion // Message Received - + #endregion // Thread Tracking #region Event Handlers @@ -465,29 +447,22 @@ private async Task OnReactionAdded(Cacheable messageCache, if (message == null || message.Author.Id != _client.CurrentUser.Id) return; -#pragma warning disable CS4014 - Task.Run(async () => -#pragma warning restore CS4014 - { - // Check the owner is the one reacting - var threadOwner = channel.Owner.Id; - if (reaction.UserId != threadOwner) - return; + if (reaction.UserId != channel.Owner.Id) + return; - await CloseThread(channel, true); - }); + CloseThread(channel, true).SafeFireAndForget(ServiceName); } - + public async Task OnUserRequestChannelClose(IUser user, SocketThreadChannel channel) { if (channel.ParentChannel.Id != _helpChannel.Id) return string.Empty; if (!_activeThreads.TryGetValue(channel.Id, out var thread)) return string.Empty; - + if (thread.Owner != user.Id) return string.Empty; - + await CloseThread(channel, true); return "Your thread has been closed."; } @@ -496,16 +471,16 @@ public async Task OnUserRequestChannelClose(IUser user, SocketThreadChan #region Bulk Behaviour Handler - private async Task CloseThreadInTime(ThreadContainer thread, string message, int minutes, Embed embed = null) + private async Task CloseThreadInTime(ThreadContainer thread, string message, int minutes, Embed? embed = null) { await Task.Delay(TimeSpan.FromMinutes(minutes)); if (thread.HasInteraction) return; - + var channel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; if (channel == null) return; - + if (!string.IsNullOrEmpty(message)) await channel.SendMessageAsync(message); else @@ -516,19 +491,23 @@ private async Task CloseThreadInTime(ThreadContainer thread, string message, int if (!(await IsValidThread(thread))) return; - + var expectedShutdownTime = DateTime.Now.AddMinutes(minutes); var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; // Check if token already created, each thread shares its own token with any relevant action (close, delete, etc) await CancelPreviousWarning(thread, expectedShutdownTime); - + thread.CancellationToken ??= new CancellationTokenSource(); + if (threadChannel == null) + return; + // Send our message if (!string.IsNullOrEmpty(message)) { await threadChannel.SendMessageAsync(message); } thread.ExpectedShutdownTime = expectedShutdownTime; + // Wait for the time to pass await Task.Delay(minutes * 60 * 1000, thread.CancellationToken.Token); if (await IsTaskCancelled(thread)) return; @@ -540,25 +519,27 @@ private async Task RequestThreadShutdownInTime(ThreadContainer thread, string ms { if (!(await IsValidThread(thread))) return; - + var expectedWarnTime = DateTime.Now.AddMinutes(minutes); var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; // Check if token already created, each thread shares its own token with any relevant action (close, delete, etc) await CancelPreviousWarning(thread, expectedWarnTime); thread.CancellationToken ??= new CancellationTokenSource(); - + if (threadChannel == null) + return; + thread.ExpectedShutdownTime = expectedWarnTime; await Task.Delay(minutes * 60 * 1000, thread.CancellationToken.Token); if (await IsTaskCancelled(thread)) return; - + msgString = string.Format(msgString, threadChannel.Owner.Mention); var sentMessage = await threadChannel.SendMessageAsync(msgString); // add the lock reaction await sentMessage.AddReactionAsync(CloseEmoji); thread.LatestUserMessage = sentMessage.Id; } - + /// /// When a thread is first started, this is called first to set it up to be closed after a certain amount of time /// This will quickly be canceled if the thread is interacted with. @@ -569,13 +550,15 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) return; var expectedShutdownTime = DateTime.Now.AddMinutes(NoResponseNotResolvedIdleTime); - + await CancelPreviousWarning(thread, expectedShutdownTime); var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; thread.CancellationToken ??= new CancellationTokenSource(); + if (threadChannel == null) + return; + thread.ExpectedShutdownTime = expectedShutdownTime; - // Wait for the time to pass await Task.Delay(NoResponseNotResolvedIdleTime * 60 * 1000, thread.CancellationToken.Token); if (await IsTaskCancelled(thread)) return; @@ -583,7 +566,7 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) // We prompt chat that the thread is going to be deleted in x number of hours, which will double as a bump. var botResponse = await threadChannel.SendMessageAsync(embed: _stealthDeleteEmbed); thread.BotsLastMessage = botResponse.Id; - + // Wait for the next set of time to pass thread.ExpectedShutdownTime = DateTime.Now.AddMinutes(StealthDeleteTime); await Task.Delay(StealthDeleteTime * 60 * 1000, thread.CancellationToken.Token); @@ -594,10 +577,10 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) } #endregion // Bulk Behaviour Handler - - + + #region Generic Methods - + private async Task CloseThread(IThreadChannel channel, bool includeResolvedTag = false) { var appliedTags = channel.AppliedTags.ToList(); @@ -639,14 +622,14 @@ private async Task CancelPreviousWarning(ThreadContainer thread, DateTime newShu await RemoveContainerPreviousComment(thread); } } - + private async Task> GetHelpActiveThreads() { var messages = await _helpChannel.GetActiveThreadsAsync(); var helpThreads = messages.Where(x => x.CategoryId == _helpChannel.Id).ToList(); return helpThreads; } - + public async Task MarkResponseAsAnswer(IUser requester, IMessage message) { if (message.Channel is not IThreadChannel channel) @@ -681,7 +664,7 @@ public async Task MarkResponseAsAnswer(IUser requester, IMessage message if (!thread.IsResolved) await CloseThread(channel, true); - + thread.PinnedAnswer = message.Id; return "New answer pinned"; } @@ -715,7 +698,7 @@ private async Task IsValidThread(ThreadContainer thread) } return true; } - + private Task IsTaskCancelled(ThreadContainer thread) { if (thread.CancellationToken == null) @@ -727,27 +710,27 @@ private Task IsTaskCancelled(ThreadContainer thread) } return Task.FromResult(false); } - + // Check if the user is the expected id and return true if so, if not then return false (Special: Moderator will return true) - private bool IsValidAuthorUser(SocketGuildUser user, ulong authorId) + private bool IsValidAuthorUser(SocketGuildUser? user, ulong authorId) { if (user == null || user.IsUserBotOrWebhook()) return false; - + if (user.Id == authorId) return true; // If the user is moderator they can act on behalf of the author if (user.HasRoleGroup(ModeratorRole)) return true; - + return false; } - + public int GetTrackedQuestionCount() { return _activeThreads.Count; } #endregion // Utility Methods - + } diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index f2f8b199..7c9cf674 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Text; using Discord.Commands; using Discord.Interactions; @@ -13,19 +13,19 @@ namespace DiscordBot.Services; public class CommandHistoryInfo { - public string Command { get; set; } - public string User { get; set; } + public string Command { get; set; } = null!; + public string User { get; set; } = null!; public ulong UserId { get; set; } - public string Channel { get; set; } + public string Channel { get; set; } = null!; public DateTime Time { get; set; } - public string Error { get; set; } = string.Empty; + public string? Error { get; set; } = string.Empty; } public class CommandHandlingService { private const string ServiceName = "CommandHandlingService"; public bool IsInitialized { get; private set; } - + private readonly DiscordSocketClient _client; private readonly CommandService _commandService; private readonly InteractionService _interactionService; @@ -39,7 +39,7 @@ public class CommandHandlingService // Tuple of string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true for a dictionary private readonly Dictionary<(string moduleName, bool orderByName, bool includeArgs, bool includeModuleName), string> _commandList = new(); private readonly Dictionary<(string moduleName, bool orderByName, bool includeArgs, bool includeModuleName), List> _commandListMessages = new(); - + // A Collection to store the command history private const int MaxCommandHistory = 200; private readonly List _commandHistory = new List(MaxCommandHistory); @@ -60,15 +60,15 @@ ILoggingService loggingService _loggingService = loggingService; // Events - _client.MessageReceived += HandleCommand; - _client.InteractionCreated += HandleInteraction; - + _client.MessageReceived += EventGuard.Guarded(HandleCommand, nameof(HandleCommand)); + _client.InteractionCreated += EventGuard.Guarded(HandleInteraction, nameof(HandleInteraction)); + if (settings.GuildId == default) { _loggingService.Log(LogBehaviour.Console | LogBehaviour.File, $"{ServiceName}: GuildId not set, commands will not be registered.", ExtendedLogSeverity.Critical); return; } - + _commandPrefix = settings.Prefix; if (_commandPrefix == default) { @@ -98,7 +98,7 @@ ILoggingService loggingService await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.AutocompleteCommands.Count)} 'AutoComplete' commands.", ExtendedLogSeverity.Positive); await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.ModalCommands.Count)} 'Modal' commands.", ExtendedLogSeverity.Positive); await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.ComponentCommands.Count)} 'Component' commands.", ExtendedLogSeverity.Positive); - + //TODO Consider global commands? Maybe an attribute? await _interactionService.RegisterCommandsToGuildAsync(settings.GuildId); @@ -110,16 +110,16 @@ ILoggingService loggingService } }); } - + #region Command Lists - + /// Generates a command list that can provide users with information. Commands require [Command][Summary] and [Priority](If not ordering by name) /// The results are cached, so this method can be called frequently without performance issues. /// List of strings that can be sent to the user without worry of being over the message length limit. public List GetCommandListMessages(string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true) { var tupleKey = (moduleName, orderByName, includeArgs, includeModuleName); - if (!_commandListMessages.TryGetValue(tupleKey, out List commandResults)) + if (!_commandListMessages.TryGetValue(tupleKey, out List? commandResults)) { GenerateCommandListOutputs(tupleKey); commandResults = _commandListMessages[tupleKey]; @@ -133,31 +133,31 @@ public List