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 GetCommandListMessages(string moduleName, bool orderByName = public string GetCommandList(string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true) { var tupleKey = (moduleName, orderByName, includeArgs, includeModuleName); - if (!_commandList.TryGetValue(tupleKey, out string commandResults)) + if (!_commandList.TryGetValue(tupleKey, out string? commandResults)) { GenerateCommandListOutputs(tupleKey); commandResults = _commandList[tupleKey]; } return commandResults; } - + private void GenerateCommandListOutputs( (string moduleName, bool orderByName, bool includeArgs, bool includeModuleName) input) { // If we don't have the command list, we need to build it. var commandList = new StringBuilder(); commandList.Append($"__{input.moduleName} Commands__\n"); - + // Gets all of the commands in the module, and sorts them by priority. var commands = GetOrganizedCommandInfo(input); - + foreach (var c in commands) { commandList.Append($"**{(input.includeModuleName ? input.moduleName + " " : string.Empty)}{c.Name}** : {c.Summary} {GetArguments(input.includeArgs, c.Parameters)}\n"); } - + string commandListString = commandList.ToString(); - _commandList[input] = commandListString; + _commandList[input] = commandListString; _commandListMessages[input] = commandListString.MessageSplitToSize(); } @@ -166,7 +166,7 @@ public List SearchForCommand((string moduleName, bool orderByName, bool { // If we don't have the command list, we need to build it. var commandList = new StringBuilder(); - + // Gets all of the commands in the module, and sorts them by priority. var commands = GetOrganizedCommandInfo(input, search); @@ -180,7 +180,7 @@ public List SearchForCommand((string moduleName, bool orderByName, bool return commandList.ToString().MessageSplitToSize(); } - + private string GetArguments(bool getArgs, IReadOnlyList arguments) { if (!getArgs) return string.Empty; @@ -202,12 +202,12 @@ private IEnumerable GetOrganizedCommandInfo( var hideFromHelp = new HideFromHelpAttribute(); var requireModerator = new RequireModeratorAttribute(); var requireAdmin = new RequireAdminAttribute(); - + // Generates a list of commands that doesn't include any that have the ``HideFromHelp`` attribute. // Adds commands that use the same Module, and contains the search query if given. - var commands = + var commands = _commandService.Commands.Where(x => - x.Module.Name == input.moduleName && + x.Module.Name == input.moduleName && !x.Attributes.Contains(hideFromHelp) && (search == string.Empty || x.Name.Contains(search, StringComparison.CurrentCultureIgnoreCase)) ); @@ -215,7 +215,7 @@ private IEnumerable GetOrganizedCommandInfo( commands = onlyNormalUsers ? commands.Where(x => !x.Preconditions.Any(y => y.TypeId == requireModerator.TypeId || y.TypeId == requireAdmin.TypeId)) : commands; - + // Orders the list either by name or by priority, if no priority is given we push it to the end. commands = input.orderByName ? commands.OrderBy(c => c.Name) @@ -262,9 +262,9 @@ private async Task HandleCommand(SocketMessage messageParam) if (resultString == string.Empty) return; } - + AddToCommandHistory(message, resultString); - await context.Channel.SendMessageAsync(resultString).DeleteAfterSeconds(10); + await context.Channel.SendMessageAsync(resultString).DeleteAfterSeconds(10)!; } private async Task HandleInteraction(SocketInteraction arg) @@ -276,7 +276,7 @@ private async Task HandleInteraction(SocketInteraction arg) // Execute the command and retrieve the result. IResult result = await _interactionService.ExecuteCommandAsync(ctx, _services); //TODO maybe do something if result is anything but success - + // TODO: (James) Need to "AddToCommandHistory" for interactions } catch (Exception ex) @@ -284,8 +284,8 @@ private async Task HandleInteraction(SocketInteraction arg) LoggingService.LogToConsole(ex.ToString(), LogSeverity.Error); } } - - public void AddToCommandHistory(SocketUserMessage message, string error = default) + + public void AddToCommandHistory(SocketUserMessage message, string? error = default) { _commandHistory.Add(new CommandHistoryInfo() { @@ -299,20 +299,5 @@ public void AddToCommandHistory(SocketUserMessage message, string error = defaul if (_commandHistory.Count > MaxCommandHistory) _commandHistory.RemoveAt(0); } - - public async Task GetCommandHistory(int count = 10) - { - if (count > _commandHistory.Count) - count = _commandHistory.Count; - if (count == 0) - count = 10; - - var commandHistory = new StringBuilder(); - for (var i = _commandHistory.Count - 1; i >= 0 && count > 0; i--, count--) - { - var command = _commandHistory[i]; - commandHistory.AppendLine($"{command.Time} - {command.User}[{command.UserId}] used {command.Command} in {command.Channel} {(string.IsNullOrEmpty(command.Error) ? string.Empty : $"Error: {command.Error}")}"); - } - return commandHistory.ToString(); - } -} \ No newline at end of file + +} diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 5fe2a940..d715cf3b 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -15,7 +15,7 @@ public class DatabaseService private readonly ILoggingService _logging; private string ConnectionString { get; } - private ICasinoRepo CreateCasinoQuery() + private ICasinoRepo? CreateCasinoQuery() { try { @@ -29,7 +29,7 @@ private ICasinoRepo CreateCasinoQuery() } } - private IServerUserRepo CreateQuery() + private IServerUserRepo? CreateQuery() { try { @@ -43,8 +43,8 @@ private IServerUserRepo CreateQuery() } } - public IServerUserRepo Query => CreateQuery(); - public ICasinoRepo CasinoQuery => CreateCasinoQuery(); + public IServerUserRepo? Query => CreateQuery(); + public ICasinoRepo? CasinoQuery => CreateCasinoQuery(); public DatabaseService(ILoggingService logging, BotSettings settings) { @@ -53,7 +53,7 @@ public DatabaseService(ILoggingService logging, BotSettings settings) ConnectionString = settings.DbConnectionString; _logging = logging; - DbConnection c = null; + DbConnection? c = null; try { c = new NpgsqlConnection(ConnectionString); @@ -70,7 +70,9 @@ public DatabaseService(ILoggingService logging, BotSettings settings) // Test connection, if it fails we create the table and set keys try { - var userCount = await Query.TestConnection(); + var query = Query; + if (query == null) return; + var userCount = await query.TestConnection(); await _logging.LogAction( $"{ServiceName}: Connected to database successfully. {userCount} users in database.", ExtendedLogSeverity.Positive); @@ -118,7 +120,9 @@ await _logging.LogAction($"DatabaseService: Table '{UserProps.TableName}' genera // Create casino tables if they don't exist try { - var casinoUserCount = await CasinoQuery.TestCasinoConnection(); + var casinoQuery = CasinoQuery; + if (casinoQuery == null) return; + var casinoUserCount = await casinoQuery.TestCasinoConnection(); await _logging.LogAction( $"DatabaseService: Connected to casino tables successfully. {casinoUserCount} casino users in database.", ExtendedLogSeverity.Positive); @@ -185,7 +189,9 @@ await message.ModifyAsync(msg => if (!user.IsBot) { var userIdString = user.Id.ToString(); - var serverUser = await Query.GetUser(userIdString); + var q = Query; + if (q == null) continue; + var serverUser = await q.GetUser(userIdString); if (serverUser == null) { await GetOrAddUser(user as SocketGuildUser); @@ -214,7 +220,7 @@ await _logging.LogChannelAndFile( /// Adds a new user to the database if they don't already exist. /// /// Existing or newly created user. Null on database error. - public async Task GetOrAddUser(SocketGuildUser socketUser) + public async Task GetOrAddUser(SocketGuildUser? socketUser) { if (socketUser == null) { @@ -266,9 +272,11 @@ public async Task DeleteUser(ulong id) { try { - var user = await Query.GetUser(id.ToString()); + var query = Query; + if (query == null) return; + var user = await query.GetUser(id.ToString()); if (user != null) - await Query.RemoveUser(user.UserID); + await query.RemoveUser(user.UserID); } catch (Exception e) { @@ -279,6 +287,8 @@ await _logging.Log(LogBehaviour.Console | LogBehaviour.File, public async Task UserExists(ulong id) { - return (await Query.GetUser(id.ToString()) != null); + var query = Query; + if (query == null) return false; + return (await query.GetUser(id.ToString()) != null); } } \ No newline at end of file diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs deleted file mode 100644 index ebabf0be..00000000 --- a/DiscordBot/Services/FeedService.cs +++ /dev/null @@ -1,333 +0,0 @@ -using System.IO; -using System.ServiceModel.Syndication; -using System.Xml; -using Discord.WebSocket; -using DiscordBot.Settings; -using DiscordBot.Utils; -using HtmlAgilityPack; - -namespace DiscordBot.Services; - -public class FeedService -{ - private const string ServiceName = "FeedService"; - private readonly DiscordSocketClient _client; - - private readonly BotSettings _settings; - private readonly ILoggingService _logging; - - #region Configurable Settings - - private const int MaxFeedLengthBuffer = 400; - #region News Feed Config - - private class ForumNewsFeed - { - public string TitleFormat { get; set; } - public string Url { get; set; } - public List IncludeTags { get; set; } - 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) - { - _client = client; - _settings = settings; - _logging = logging; - } - - private async Task GetFeedData(string url) - { - SyndicationFeed feed = null; - try - { - var content = await Utils.WebUtil.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 - { - releaseNotes = GetReleaseNotes(item); - newsContent = releaseNotes[0]; - } - - // If a role is provided we add to end of title to ping the role - var role = _client.GetGuild(_settings.GuildId).GetRole(roleId ?? 0); - if (role != null) - newsContent += $"\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 = Utils.Utils.RemoveHtmlTags(item.Summary.Text); - - // If it is too long, we truncate it - var summaryLength = summary.Length; - if (summaryLength > Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) - summary = summary[..(Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer)] + "..."; - return summary; - } - - private List GetReleaseNotes(SyndicationItem item) - { - List releaseNotes = new(); - var summary = string.Empty; - - var htmlDoc = new HtmlDocument(); - var summaryText = item.Summary.Text; - - summaryText = summaryText.Replace("→", "->"); - // TODO : (James) Likely other entities we need to replace - - htmlDoc.LoadHtml(summaryText); - - // Find "release-notes" - var summaryNode = htmlDoc.DocumentNode.SelectSingleNode("//div[@class='release-notes']"); - if (summaryNode == null) - return new List() { "No release notes found" }; - - try - { - // Find "Known Issues" - var knownIssueNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Known Issues"))?.NextSibling; - var entriesSinceNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Entries since")); - - // Find the features node which will be a h4 heading with content "Features" - var featuresNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Features")?.NextSibling; - var improvementsNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Improvements")?.NextSibling; - var apiChangesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "API Changes")?.NextSibling; - var changesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Changes")?.NextSibling; - var fixesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Fixes")?.NextSibling; - var packagesUpdatedNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText.ToLower().Contains("package changes"))?.NextSibling.NextSibling.NextSibling; - - // Need to construct the summary which is just a stats summary - summary += $"**Summary**\n"; - summary += GetNodeLiCountString("Known Issues", knownIssueNode?.NextSibling); - - if (entriesSinceNode != null) - summary += $"__{entriesSinceNode.InnerText}__\n\n"; - - // Construct Stat Summary - summary += GetNodeLiCountString("Features", featuresNode?.NextSibling); - summary += GetNodeLiCountString("Improvements", improvementsNode?.NextSibling); - summary += GetNodeLiCountString("API Changes", apiChangesNode?.NextSibling); - summary += GetNodeLiCountString("Changes", changesNode?.NextSibling); - summary += GetNodeLiCountString("Fixes", fixesNode?.NextSibling); - summary += GetNodeLiCountString("Packages Updated", packagesUpdatedNode?.NextSibling); - - // Add Package Updates to Summary - releaseNotes.Add(BuildReleaseNote("Packages Updated", packagesUpdatedNode, summary)); - - // Features, Improvements - releaseNotes.Add(BuildReleaseNote("Features", featuresNode)); - releaseNotes.Add(BuildReleaseNote("Improvements", improvementsNode, "", 1000)); - // API Changes, Changes + Fixes - releaseNotes.Add(BuildReleaseNote("API Changes", apiChangesNode)); - releaseNotes.Add(BuildReleaseNote("Changes", changesNode)); - releaseNotes.Add(BuildReleaseNote("Fixes", fixesNode, "")); - - // Known Issues - releaseNotes.Add(BuildReleaseNote("Known Issues", knownIssueNode, "", 1200)); - - return releaseNotes; - } - catch (Exception e) - { - _logging.LogChannelAndFile($"[{ServiceName}] Error generating release notes: {e}\nLikely updated format.", ExtendedLogSeverity.Warning); - // We ignore anything we've generated and return a "No release notes found" to maintain appearance - return new List() { "No release notes found" }; - } - } - - private string BuildReleaseNote(string title, HtmlNode node, string contents = "", int maxLength = Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) - { - if (node == null) - return string.Empty; - - // If we pass in contents, we prepend it to the summary - var summary = $"{(contents.Length > 0 ? $"{contents}\n" : string.Empty)}**{node.PreviousSibling.InnerText}**\n"; - - bool needsExtraProcessing = title is "Fixes" or "Known Issues" or "API Changes"; - - foreach (var feature in node.NextSibling.ChildNodes.Where(x => x.Name == "li")) - { - var extraText = string.Empty; - if (needsExtraProcessing) - { - var nodeContents = feature.ChildNodes[0]; - // Remove \n if any - nodeContents.InnerHtml = nodeContents.InnerHtml.Replace("\n", " "); - - var linkNode = nodeContents.SelectSingleNode("a"); - if (linkNode != null) - { - nodeContents = nodeContents.RemoveChild(linkNode); - // Need to remove () - feature.InnerHtml = feature.InnerHtml.Replace("()", ""); - - // Add link to extraText, but use the InnerText as the text, and format so discord will use it as link - extraText = $" ([{linkNode.InnerText}](<{linkNode.Attributes["href"].Value}>))"; - } - } - - summary += $"- {feature.InnerText}{extraText}\n"; - if (summary.Length > maxLength) - { - // Trim down to the last full line, that is less than limits - var lastLine = summary[..maxLength].LastIndexOf('\n'); - summary = summary[..lastLine] + $"\n{title} truncated...\n"; - return summary; - } - } - return summary; - } - - private string GetNodeLiCountString(string title, HtmlNode node) - { - if (node == null) - return string.Empty; - - var count = node.ChildNodes.Count(x => x.Name == "li"); - return $"{title}: {count}\n"; - } - - #endregion // Feed Handlers - - #region Public Feed Actions - - public async Task CheckUnityBetasAsync(FeedData feedData) - { - await HandleFeed(feedData, _betaNews, _settings.UnityReleasesChannel.Id, _settings.SubsReleasesRoleId); - } - - public async Task CheckUnityReleasesAsync(FeedData feedData) - { - await HandleFeed(feedData, _releaseNews, _settings.UnityReleasesChannel.Id, _settings.SubsReleasesRoleId); - } - - public async Task CheckUnityBlogAsync(FeedData feedData) - { - await HandleFeed(feedData, _blogNews, _settings.UnityNewsChannel.Id, _settings.SubsNewsRoleId); - } - - #endregion // Feed Actions -} \ No newline at end of file diff --git a/DiscordBot/Services/Casino/CasinoService.cs b/DiscordBot/Services/Fun/Casino/CasinoService.cs similarity index 78% rename from DiscordBot/Services/Casino/CasinoService.cs rename to DiscordBot/Services/Fun/Casino/CasinoService.cs index 653f72a9..b405705d 100644 --- a/DiscordBot/Services/Casino/CasinoService.cs +++ b/DiscordBot/Services/Fun/Casino/CasinoService.cs @@ -1,7 +1,7 @@ using DiscordBot.Domain; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Fun.Casino; public class CasinoService { @@ -24,7 +24,8 @@ public async Task GetOrCreateCasinoUser(string userId) { try { - var user = await _databaseService.CasinoQuery.GetCasinoUser(userId); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var user = await casinoQuery.GetCasinoUser(userId); if (user != null) return user; @@ -32,15 +33,15 @@ public async Task GetOrCreateCasinoUser(string userId) var newUser = new CasinoUser { UserID = userId, - Tokens = _settings.CasinoStartingTokens, + Tokens = _settings.Casino.StartingTokens, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, LastDailyReward = DateTime.UtcNow.AddDays(-1) // Set to a past date so user can claim their first daily reward immediately }; - var createdUser = await _databaseService.CasinoQuery.InsertCasinoUser(newUser); - await RecordTransaction(userId, _settings.CasinoStartingTokens, TransactionKind.TokenInitialisation); - await _loggingService.LogChannelAndFile($"{ServiceName}: Created new casino user {userId} with {_settings.CasinoStartingTokens} starting tokens"); + var createdUser = await casinoQuery.InsertCasinoUser(newUser); + await RecordTransaction(userId, _settings.Casino.StartingTokens, TransactionKind.TokenInitialisation); + await _loggingService.LogChannelAndFile($"{ServiceName}: Created new casino user {userId} with {_settings.Casino.StartingTokens} starting tokens"); return createdUser; } catch (Exception ex) @@ -53,6 +54,7 @@ public async Task GetOrCreateCasinoUser(string userId) public async Task TransferTokens(string fromUserId, string toUserId, long amount) { + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); var fromUser = await GetOrCreateCasinoUser(fromUserId); var toUser = await GetOrCreateCasinoUser(toUserId); @@ -60,8 +62,8 @@ public async Task TransferTokens(string fromUserId, string toUserId, long return false; // Update balances - await _databaseService.CasinoQuery.UpdateTokens(fromUserId, fromUser.Tokens - amount, DateTime.UtcNow); - await _databaseService.CasinoQuery.UpdateTokens(toUserId, toUser.Tokens + amount, DateTime.UtcNow); + await casinoQuery.UpdateTokens(fromUserId, fromUser.Tokens - amount, DateTime.UtcNow); + await casinoQuery.UpdateTokens(toUserId, toUser.Tokens + amount, DateTime.UtcNow); // Record transactions await RecordTransaction(fromUserId, -amount, TransactionKind.Gift, new Dictionary @@ -80,12 +82,13 @@ public async Task UpdateUserTokens(string userId, long deltaTokens, TransactionK { try { + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); var user = await GetOrCreateCasinoUser(userId); var newBalance = user.Tokens + deltaTokens; // Prevent negative balance if (newBalance < 0) newBalance = 0; - await _databaseService.CasinoQuery.UpdateTokens(userId, newBalance, DateTime.UtcNow); + await casinoQuery.UpdateTokens(userId, newBalance, DateTime.UtcNow); await RecordTransaction(userId, deltaTokens, transactionType, details); } catch (Exception ex) @@ -98,7 +101,8 @@ public async Task UpdateUserTokens(string userId, long deltaTokens, TransactionK public async Task SetUserTokens(string userId, long amount, string adminUserId) { - await _databaseService.CasinoQuery.UpdateTokens(userId, amount, DateTime.UtcNow); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + await casinoQuery.UpdateTokens(userId, amount, DateTime.UtcNow); await RecordTransaction(userId, amount, TransactionKind.Admin, new Dictionary { @@ -109,25 +113,29 @@ public async Task SetUserTokens(string userId, long amount, string adminUserId) public async Task> GetLeaderboard(int limit = 10) { - var topUsers = await _databaseService.CasinoQuery.GetTopTokenHolders(limit); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var topUsers = await casinoQuery.GetTopTokenHolders(limit); return topUsers.ToList(); } public async Task> GetUserTransactionHistory(string userId, int limit = 10) { await GetOrCreateCasinoUser(userId); - var transactions = await _databaseService.CasinoQuery.GetUserTransactionHistory(userId, limit); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var transactions = await casinoQuery.GetUserTransactionHistory(userId, limit); return transactions.ToList(); } public async Task> GetAllRecentTransactions(int limit = 10) { - var transactions = await _databaseService.CasinoQuery.GetRecentTransactions(limit); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var transactions = await casinoQuery.GetRecentTransactions(limit); return transactions.ToList(); } private async Task RecordTransaction(string userId, long amount, TransactionKind type, Dictionary? details = null) { + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); var transaction = new TokenTransaction { UserID = userId, @@ -137,7 +145,7 @@ private async Task RecordTransaction(string userId, long amount, TransactionKind Details = details }; - await _databaseService.CasinoQuery.InsertTransaction(transaction); + await casinoQuery.InsertTransaction(transaction); } #endregion @@ -148,7 +156,8 @@ public async Task> GetGameStatistics(IUser user) { try { - var gameTransactions = await _databaseService.CasinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var gameTransactions = await casinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); // Group transactions by game type var gameGroups = gameTransactions @@ -206,7 +215,8 @@ public async Task GetGameLeaderboard(string? gameName = n { try { - var gameTransactions = await _databaseService.CasinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var gameTransactions = await casinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); // Filter by game if specified var filteredTransactions = gameTransactions @@ -302,13 +312,13 @@ public async Task GetGameLeaderboard(string? gameName = n public bool IsChannelAllowed(ulong channelId) { - if (!_settings.CasinoEnabled) + if (!_settings.Casino.Enabled) return false; - if (_settings.CasinoAllowedChannels.Count == 0) + if (_settings.Casino.AllowedChannels.Count == 0) return true; // If no restrictions, allow all channels - return _settings.CasinoAllowedChannels.Contains(channelId); + return _settings.Casino.AllowedChannels.Contains(channelId); } #endregion @@ -319,9 +329,10 @@ public bool IsChannelAllowed(ulong channelId) { try { + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); var user = await GetOrCreateCasinoUser(userId); var now = DateTime.UtcNow; - var nextRewardTime = user.LastDailyReward.AddSeconds(_settings.CasinoDailyRewardIntervalSeconds); + var nextRewardTime = user.LastDailyReward.AddSeconds(_settings.Casino.DailyRewardIntervalSeconds); if (now < nextRewardTime) { @@ -329,13 +340,13 @@ public bool IsChannelAllowed(ulong channelId) } // User can claim daily reward - var tokensAwarded = _settings.CasinoDailyRewardTokens; + var tokensAwarded = _settings.Casino.DailyRewardTokens; var newBalance = user.Tokens + tokensAwarded; - await _databaseService.CasinoQuery.UpdateTokensAndDailyReward(userId, newBalance, now, now); + await casinoQuery.UpdateTokensAndDailyReward(userId, newBalance, now, now); await RecordTransaction(userId, tokensAwarded, TransactionKind.DailyReward); await _loggingService.LogChannelAndFile($"{ServiceName}: User {userId} claimed daily reward of {tokensAwarded} tokens"); - return (true, tokensAwarded, newBalance, now.AddSeconds(_settings.CasinoDailyRewardIntervalSeconds)); + return (true, tokensAwarded, newBalance, now.AddSeconds(_settings.Casino.DailyRewardIntervalSeconds)); } catch (Exception ex) { @@ -348,7 +359,7 @@ public bool IsChannelAllowed(ulong channelId) public async Task GetNextDailyRewardTime(string userId) { var user = await GetOrCreateCasinoUser(userId); - return user.LastDailyReward.AddSeconds(_settings.CasinoDailyRewardIntervalSeconds); + return user.LastDailyReward.AddSeconds(_settings.Casino.DailyRewardIntervalSeconds); } #endregion @@ -357,8 +368,9 @@ public async Task GetNextDailyRewardTime(string userId) public async Task ResetAllCasinoData() { - await _databaseService.CasinoQuery.ClearAllCasinoUsers(); - await _databaseService.CasinoQuery.ClearAllTransactions(); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + await casinoQuery.ClearAllCasinoUsers(); + await casinoQuery.ClearAllTransactions(); await _loggingService.LogChannelAndFile($"{ServiceName}: All casino data has been reset."); } diff --git a/DiscordBot/Services/Casino/GameService.cs b/DiscordBot/Services/Fun/Casino/GameService.cs similarity index 84% rename from DiscordBot/Services/Casino/GameService.cs rename to DiscordBot/Services/Fun/Casino/GameService.cs index 68dc4fa8..97c030bc 100644 --- a/DiscordBot/Services/Casino/GameService.cs +++ b/DiscordBot/Services/Fun/Casino/GameService.cs @@ -1,21 +1,16 @@ using Discord.WebSocket; using DiscordBot.Domain; using DiscordBot.Modules; -using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Fun.Casino; public class GameService { - private readonly ILoggingService _loggingService; - private readonly BotSettings _settings; - private readonly List _activeSessions = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _activeSessions = new(); private readonly CasinoService _casinoService; - public GameService(ILoggingService loggingService, BotSettings settings, CasinoService casinoService) + public GameService(CasinoService casinoService) { - _loggingService = loggingService; - _settings = settings; _casinoService = casinoService; } @@ -45,7 +40,7 @@ public IDiscordGameSession CreateGameSession(CasinoGame game, int maxSeats, Disc { var gameInstance = GetGameInstance(game); var session = CreateDiscordGameSession(game, gameInstance, maxSeats == 0 ? gameInstance.MaxPlayers : maxSeats, client, user, guild); - _activeSessions.Add(session); + _activeSessions[session.Id] = session; return session; } @@ -57,12 +52,14 @@ public IDiscordGameSession PlayAgain(IDiscordGameSession session) public IDiscordGameSession? GetActiveSession(string id) { - return _activeSessions.FirstOrDefault(s => s.Id.ToString() == id); + if (Guid.TryParse(id, out var guid) && _activeSessions.TryGetValue(guid, out var session)) + return session; + return null; } public void RemoveGameSession(IDiscordGameSession session) { - _activeSessions.Remove(session); + _activeSessions.TryRemove(session.Id, out _); } public async Task JoinGame(IDiscordGameSession session, ulong userId) diff --git a/DiscordBot/Services/Fun/Casino/TransactionFormatter.cs b/DiscordBot/Services/Fun/Casino/TransactionFormatter.cs new file mode 100644 index 00000000..59e884f8 --- /dev/null +++ b/DiscordBot/Services/Fun/Casino/TransactionFormatter.cs @@ -0,0 +1,86 @@ +using Discord.WebSocket; +using DiscordBot.Domain; + +namespace DiscordBot.Services.Fun.Casino; + +public class TransactionFormatter +{ + public (string emoji, string title, string description) Format( + TokenTransaction transaction, SocketGuild guild, bool showUserInfo = false) + { + var (emoji, title, description) = transaction.Kind switch + { + TransactionKind.TokenInitialisation => ("🎯", "Account Created", ""), + TransactionKind.DailyReward => ("📅", "Daily Reward", ""), + TransactionKind.Gift => FormatGift(transaction, guild), + TransactionKind.Game => FormatGame(transaction), + TransactionKind.Admin => FormatAdmin(transaction, guild), + _ => ("❓", transaction.TransactionType, "") + }; + + if (showUserInfo) + { + var user = guild.GetUser(ulong.Parse(transaction.UserID)); + var username = user?.DisplayName ?? "Unknown User"; + return (emoji, $"{username}: {title}", description); + } + + return (emoji, title, description); + } + + private static (string emoji, string title, string description) FormatGift( + TokenTransaction transaction, SocketGuild guild) + { + SocketGuildUser? user = null; + var userId = transaction.Details?.GetValueOrDefault(transaction.Amount >= 0 ? "from" : "to"); + if (userId != null) user = 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 static (string emoji, string title, string description) FormatGame(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 static (string emoji, string title, string description) FormatAdmin( + TokenTransaction transaction, SocketGuild guild) + { + var adminId = transaction.Details?.GetValueOrDefault("admin"); + var action = transaction.Details?.GetValueOrDefault("action"); + SocketGuildUser? admin = null; + if (adminId != null) admin = 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 static string CapitalizeFirst(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + return char.ToUpper(input[0]) + input[1..].ToLower(); + } +} diff --git a/DiscordBot/Services/Fun/DuelService.cs b/DiscordBot/Services/Fun/DuelService.cs new file mode 100644 index 00000000..77f3477b --- /dev/null +++ b/DiscordBot/Services/Fun/DuelService.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; +using Discord.WebSocket; + +namespace DiscordBot.Services.Fun; + +public class DuelService +{ + private readonly ConcurrentDictionary _activeDuels = new(); + private readonly Random _random = new(); + + 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!" + }; + + public DuelService() + { + } + + public bool IsDuelActive(string duelKey) => _activeDuels.ContainsKey(duelKey); + + public bool TryStartDuel(string duelKey, ulong challengerId, ulong opponentId) + { + string reverseKey = $"{opponentId}_{challengerId}"; + if (_activeDuels.ContainsKey(duelKey) || _activeDuels.ContainsKey(reverseKey)) + return false; + + _activeDuels[duelKey] = (challengerId, opponentId); + return true; + } + + public bool TryRemoveDuel(string duelKey, out (ulong challengerId, ulong opponentId) duel) + => _activeDuels.TryRemove(duelKey, out duel); + + public (ulong challengerId, ulong opponentId)? GetDuel(string duelKey) + => _activeDuels.TryGetValue(duelKey, out var duel) ? duel : null; + + public bool ChallengerWins() => _random.Next(2) == 0; + + public string GetWinMessage(string winnerMention, string loserMention) + { + var message = NormalWinMessages[_random.Next(NormalWinMessages.Length)]; + return message.Replace("{winner}", winnerMention).Replace("{loser}", loserMention); + } +} diff --git a/DiscordBot/Services/Fun/MikuService.cs b/DiscordBot/Services/Fun/MikuService.cs new file mode 100644 index 00000000..d114c805 --- /dev/null +++ b/DiscordBot/Services/Fun/MikuService.cs @@ -0,0 +1,55 @@ +using System.Text.RegularExpressions; +using Discord.WebSocket; +using DiscordBot.Data; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Fun; + +public class MikuService +{ + private readonly BotSettings _settings; + + private DateTime _mikuMentioned; + private readonly TimeSpan _mikuCooldownTime; + private readonly string _mikuRegex; + private readonly string _mikuReply; + + public MikuService(DiscordSocketClient client, BotSettings settings) + { + _settings = settings; + + _mikuCooldownTime = new TimeSpan(0, 39, 0); // 39min + _mikuMentioned = DateTime.Now - _mikuCooldownTime; + _mikuRegex = @"(?i)\b(miku|hatsune|初音ミク|初音|ミク)\b"; + _mikuReply = + "(:three: :nine:|:microphone:|:notes:|:musical_note:|:musical_keyboard:|:mirror_ball:) " + + "(Oi, mite, mite,|Heya,|Hey, look,|Did someone mention Miku?) " + + "<@358915848515354626> (-chan|)!"; + + // Subscription commented out — enable when ready + //_client.MessageReceived += EventGuard.Guarded(MikuCheck, nameof(MikuCheck)); + } + + public async Task MikuCheck(SocketMessage messageParam) + { + var channel = (SocketGuildChannel)messageParam.Channel; + var guildId = channel.Guild.Id; + + if (guildId != _settings.GuildId) return; + + if (messageParam.Author.IsBot) + return; + + var now = DateTime.Now; + if ((DateTime.Now - _mikuMentioned) < _mikuCooldownTime) + return; + + var match = Regex.Match(messageParam.Content, _mikuRegex); + if (!match.Success) + return; + + _mikuMentioned = now; + var reply = FuzzTable.Evaluate(_mikuReply); + await messageParam.Channel.SendMessageAsync(reply); + } +} diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index d184c22d..447b06e2 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -53,12 +53,12 @@ public static LogSeverity ToLogSeverity(this ExtendedLogSeverity severity) _ => (LogSeverity)severity }; } - + public static ExtendedLogSeverity ToExtended(this LogSeverity severity) { return (ExtendedLogSeverity)severity; } - + } #endregion // Extended Log Severity @@ -66,17 +66,17 @@ public static ExtendedLogSeverity ToExtended(this LogSeverity severity) public class LoggingService : ILoggingService { private const string ServiceName = "LoggingService"; - - private readonly ISocketMessageChannel _logChannel; - + + private readonly ISocketMessageChannel? _logChannel; + // Configuration private const long MaxLogSize = 1024 * 1024 * 2; // 2MB private const long FileCheckInterval = 1000 * 60 * 60 * 1; // 1 Hour private readonly bool _logCommandExecutions; - + // Where backup files go private readonly string _backupLogFilePath; - + private readonly string _logFilePath; // Normal Logs private readonly string _logXpFilePath; // XP Logs @@ -85,7 +85,7 @@ public class LoggingService : ILoggingService public LoggingService(DiscordSocketClient client, BotSettings settings) { _logCommandExecutions = settings.LogCommandExecutions; - + // Paths _backupLogFilePath = settings.ServerRootPath + @"/log_backups/"; _logFilePath = settings.ServerRootPath + @"/log.txt"; @@ -98,19 +98,19 @@ public LoggingService(DiscordSocketClient client, BotSettings settings) } // INIT - if (settings.BotAnnouncementChannel == null) + if (settings.Channels.BotAnnouncement == null) { LogToConsole($"[{ServiceName}] Error: Logging Channel not set in settings.json", LogSeverity.Error); return; } - _logChannel = client.GetChannel(settings.BotAnnouncementChannel.Id) as ISocketMessageChannel; + _logChannel = client.GetChannel(settings.Channels.BotAnnouncement.Id) as ISocketMessageChannel; if (_logChannel == null) { - LogToConsole($"[{ServiceName}] Error: Logging Channel {settings.BotAnnouncementChannel.Id} not found", LogSeverity.Error); + LogToConsole($"[{ServiceName}] Error: Logging Channel {settings.Channels.BotAnnouncement.Id} not found", LogSeverity.Error); } } - - public async Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) + + public async Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null) { if (behaviour.HasFlag(LogBehaviour.Console)) LogToConsole(message, severity); @@ -121,21 +121,21 @@ public async Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverit if (_logCommandExecutions && behaviour.HasFlag(LogBehaviour.CommandFile)) await LogToFile(message, severity); } - - public async Task LogToChannel(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) + + public async Task LogToChannel(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null) { if (_logChannel == null) return; await _logChannel.SendMessageAsync(message, false, embed); } - + public async Task LogToFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) - { + { PrepareLogFile(_logFilePath); await File.AppendAllTextAsync(_logFilePath, $"[{ConsistentDateTimeFormat()}] - [{severity}] - {message} {Environment.NewLine}"); } - + public void LogXp(string channel, string user, float baseXp, float bonusXp, float xpReduce, int totalXp) { PrepareLogFile(_logXpFilePath); @@ -160,7 +160,7 @@ private void PrepareLogFile(string path) { if (DateTime.Now - _lastFileCheck < TimeSpan.FromMilliseconds(FileCheckInterval)) return; - + _lastFileCheck = DateTime.Now; if (new FileInfo(path).Length > MaxLogSize) { @@ -169,7 +169,7 @@ private void PrepareLogFile(string path) File.Move(path, backupPath); LogToConsole($"[{ServiceName}] Log file was backed up to {backupPath}", ExtendedLogSeverity.Info); } - + if (!File.Exists(path)) { File.Create(path).Dispose(); @@ -177,14 +177,15 @@ private void PrepareLogFile(string path) LogToConsole($"[{ServiceName}] Log file was started", ExtendedLogSeverity.Info); } } - + #region Console Messages // Logs message to console without changing the colour - public static void LogConsole(string message) { + public static void LogConsole(string message) + { Console.WriteLine($"[{ConsistentDateTimeFormat()}] {message}"); } - public static void LogToConsole(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) + public static void LogToConsole(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) { ConsoleColor restoreColour = Console.ForegroundColor; SetConsoleColour(severity); @@ -193,14 +194,14 @@ public static void LogToConsole(string message, ExtendedLogSeverity severity = E Console.ForegroundColor = restoreColour; } - + public static void LogToConsole(string message, LogSeverity severity) => LogToConsole(message, severity.ToExtended()); - + public static void LogServiceDisabled(string service, string varName) { LogToConsole($"Service \"{service}\" is Disabled, {varName} is false in settings.json", ExtendedLogSeverity.LowWarning); } - + public static void LogServiceEnabled(string service) { LogToConsole($"Service \"{service}\" is Enabled", ExtendedLogSeverity.Info); @@ -246,7 +247,7 @@ private static void SetConsoleColour(ExtendedLogSeverity severity) } } #endregion -} +} /// /// Interface for the LoggingService, this is only really required if you want to use DI. @@ -258,7 +259,7 @@ private static void SetConsoleColour(ExtendedLogSeverity severity) public interface ILoggingService { void LogXp(string channel, string user, float baseXp, float bonusXp, float xpReduce, int totalXp); - + /// /// Standard logging, this will log to console, channel and file depending on the behaviour. /// @@ -266,19 +267,19 @@ public interface ILoggingService /// Message /// Info, Error, Warn, etc (Included in File and Console logging) /// Embed, only used by Channel Logging - Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null); + Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null); /// /// 'Short hand' for logging to all CURRENT supported behaviours, console, channel and file. /// Same as calling `Log(LogBehaviour.ConsoleChannelAndFile, message, severity, embed);` /// - Task LogAction(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => + Task LogAction(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null) => Log(LogBehaviour.ConsoleChannelAndFile, message, severity, embed); - + /// /// 'Short hand' for logging to channel and file. /// Same as calling `Log(LogBehaviour.ChannelAndFile, message, severity, embed);` /// - Task LogChannelAndFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => + Task LogChannelAndFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null) => Log(LogBehaviour.ChannelAndFile, message, severity, embed); } \ No newline at end of file diff --git a/DiscordBot/Services/Moderation/IntroductionWatcherService.cs b/DiscordBot/Services/Moderation/IntroductionWatcherService.cs deleted file mode 100644 index 6a82aa91..00000000 --- a/DiscordBot/Services/Moderation/IntroductionWatcherService.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Discord.WebSocket; -using DiscordBot.Settings; -using DiscordBot.Services.UnityHelp; - -namespace DiscordBot.Services; - -// Small service to watch users posting new messages in introductions, keeping track of the last 500 messages and deleting any from the same user -public class IntroductionWatcherService -{ - private const string ServiceName = "IntroductionWatcherService"; - - private readonly DiscordSocketClient _client; - private readonly ILoggingService _loggingService; - private readonly SocketChannel _introductionChannel; - - private readonly HashSet _uniqueUsers = new HashSet(MaxMessagesToTrack + 1); - private readonly Queue _orderedUsers = new Queue(MaxMessagesToTrack + 1); - - private SocketRole ModeratorRole { get; set; } - - private const int MaxMessagesToTrack = 1000; - - public IntroductionWatcherService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) - { - _client = client; - _loggingService = loggingService; - - ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); - - if (!settings.IntroductionWatcherServiceEnabled) - { - LoggingService.LogServiceDisabled(ServiceName, nameof(settings.IntroductionWatcherServiceEnabled)); - return; - } - - _introductionChannel = client.GetChannel(settings.IntroductionChannel.Id); - if (_introductionChannel == null) - { - _loggingService.LogAction($"[{ServiceName}] Error: Could not find introduction channel.", ExtendedLogSeverity.Warning); - return; - } - - _client.MessageReceived += MessageReceived; - } - - private async Task MessageReceived(SocketMessage message) - { - // We only watch the introduction channel - if (_introductionChannel == null || message.Channel.Id != _introductionChannel.Id) - return; - - if (message.Author.HasRoleGroup(ModeratorRole)) - return; - - if (_uniqueUsers.Contains(message.Author.Id)) - { - await message.DeleteAsync(); - await _loggingService.LogChannelAndFile( - $"[{ServiceName}]: Duplicate introduction from {message.Author.GetUserLoggingString()} [Message deleted]"); - } - - _uniqueUsers.Add(message.Author.Id); - _orderedUsers.Enqueue(message.Author.Id); - if (_orderedUsers.Count > MaxMessagesToTrack) - { - var oldestUser = _orderedUsers.Dequeue(); - _uniqueUsers.Remove(oldestUser); - } - - await Task.CompletedTask; - } -} diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/Profiles/BirthdayAnnouncementService.cs similarity index 76% rename from DiscordBot/Services/BirthdayAnnouncementService.cs rename to DiscordBot/Services/Profiles/BirthdayAnnouncementService.cs index b1eafbf8..1c528733 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/Profiles/BirthdayAnnouncementService.cs @@ -4,152 +4,157 @@ using DiscordBot.Utils; using HtmlAgilityPack; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Profiles; public class BirthdayAnnouncementService { private const string ServiceName = "BirthdayAnnouncementService"; - + public bool IsRunning { get; private set; } - + private readonly DiscordSocketClient _client; private readonly ILoggingService _loggingService; private readonly BotSettings _settings; - + private readonly IWebClient _webClient; + private readonly CancellationToken _shutdownToken; + // Track birthdays that have been announced today to avoid spam private readonly HashSet _announcedToday = new(); private DateTime _lastAnnouncementDate = DateTime.Today; - - // URLs for birthday data from the existing !bday command - private const string NextBirthdayUrl = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; + + // URL for birthday data from the existing !bday command private const string BirthdayTableUrl = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; - - public BirthdayAnnouncementService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) + + public BirthdayAnnouncementService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings, + IWebClient webClient, CancellationTokenSource cts) { _client = client; _loggingService = loggingService; _settings = settings; - + _webClient = webClient; + _shutdownToken = cts.Token; + Initialize(); } - + private void Initialize() { if (IsRunning) return; - - if (!_settings.BirthdayAnnouncementEnabled) + + if (!_settings.Birthday.Enabled) { _loggingService.LogAction($"[{ServiceName}] Birthday announcement service is disabled in settings.", ExtendedLogSeverity.Info); return; } - - if (_settings.BirthdayAnnouncementChannel?.Id == 0) + + if (_settings.Channels.BirthdayAnnouncement?.Id == 0) { _loggingService.LogAction($"[{ServiceName}] Birthday announcement channel not configured.", ExtendedLogSeverity.Warning); return; } - + IsRunning = true; - _loggingService.LogAction($"[{ServiceName}] Starting birthday announcement service with {_settings.BirthdayCheckIntervalMinutes} minute intervals.", ExtendedLogSeverity.Info); + _loggingService.LogAction($"[{ServiceName}] Starting birthday announcement service with {_settings.Birthday.CheckIntervalMinutes} minute intervals.", ExtendedLogSeverity.Info); Task.Run(CheckBirthdaysLoop); } - + private async Task CheckBirthdaysLoop() { try { - while (IsRunning) + while (!_shutdownToken.IsCancellationRequested) { // Check if it's a new day and reset announced birthdays if (DateTime.Today > _lastAnnouncementDate) { _announcedToday.Clear(); _lastAnnouncementDate = DateTime.Today; - _loggingService.LogAction($"[{ServiceName}] New day detected, reset announced birthdays list.", ExtendedLogSeverity.Info); + _ = _loggingService.LogAction($"[{ServiceName}] New day detected, reset announced birthdays list.", ExtendedLogSeverity.Info); } - + await CheckAndAnnounceBirthdays(); - + // Wait for the configured interval - var intervalMs = _settings.BirthdayCheckIntervalMinutes * 60 * 1000; - await Task.Delay(intervalMs); + var intervalMs = _settings.Birthday.CheckIntervalMinutes * 60 * 1000; + await Task.Delay(intervalMs, _shutdownToken); } } + catch (OperationCanceledException) { } catch (Exception e) { await _loggingService.LogChannelAndFile($"[{ServiceName}] Birthday announcement service has crashed.\nException: {e.Message}", ExtendedLogSeverity.Warning); IsRunning = false; } } - + private async Task CheckAndAnnounceBirthdays() { try { var todaysBirthdays = await GetTodaysBirthdays(); - + if (todaysBirthdays.Count == 0) { return; // No birthdays today } - - var channel = _client.GetChannel(_settings.BirthdayAnnouncementChannel.Id) as SocketTextChannel; + + var channel = _client.GetChannel(_settings.Channels.BirthdayAnnouncement.Id) as SocketTextChannel; if (channel == null) { - _loggingService.LogAction($"[{ServiceName}] Could not find birthday announcement channel with ID {_settings.BirthdayAnnouncementChannel.Id}", ExtendedLogSeverity.Warning); + _ = _loggingService.LogAction($"[{ServiceName}] Could not find birthday announcement channel with ID {_settings.Channels.BirthdayAnnouncement.Id}", ExtendedLogSeverity.Warning); return; } - + foreach (var birthday in todaysBirthdays) { var announcementKey = $"{birthday.Name}-{DateTime.Today:yyyy-MM-dd}"; - + if (_announcedToday.Contains(announcementKey)) { continue; // Already announced this birthday today } - + var message = FormatBirthdayAnnouncement(birthday); await channel.SendMessageAsync(message); - + _announcedToday.Add(announcementKey); - _loggingService.LogAction($"[{ServiceName}] Announced birthday for {birthday.Name}", ExtendedLogSeverity.Info); + _ = _loggingService.LogAction($"[{ServiceName}] Announced birthday for {birthday.Name}", ExtendedLogSeverity.Info); } } catch (Exception e) { - _loggingService.LogAction($"[{ServiceName}] Error checking birthdays: {e.Message}", ExtendedLogSeverity.LowWarning); + _ = _loggingService.LogAction($"[{ServiceName}] Error checking birthdays: {e.Message}", ExtendedLogSeverity.LowWarning); } } - + private async Task> GetTodaysBirthdays() { var birthdays = new List(); - + try { - var relevantNodes = await WebUtil.GetHtmlNodes(BirthdayTableUrl, "/html/body/table/tr"); + var relevantNodes = await _webClient.GetHtmlNodes(BirthdayTableUrl, "/html/body/table/tr"); if (relevantNodes == null) { return birthdays; } - + var today = DateTime.Today; - + foreach (var row in relevantNodes) { var nameNode = row.SelectSingleNode("td[2]"); var dateNode = row.SelectSingleNode("td[1]"); var yearNode = row.SelectSingleNode("td[3]"); - + if (nameNode == null || dateNode == null) continue; - + var name = nameNode.InnerText?.Trim(); if (string.IsNullOrEmpty(name)) continue; - + var dateString = dateNode.InnerText?.Trim(); if (string.IsNullOrEmpty(dateString)) continue; - + // Try to parse the birthday date if (TryParseBirthdayDate(dateString, yearNode?.InnerText, out var birthDate)) { @@ -164,20 +169,20 @@ private async Task> GetTodaysBirthdays() } catch (Exception e) { - _loggingService.LogAction($"[{ServiceName}] Error fetching birthday data: {e.Message}", ExtendedLogSeverity.LowWarning); + _ = _loggingService.LogAction($"[{ServiceName}] Error fetching birthday data: {e.Message}", ExtendedLogSeverity.LowWarning); } - + return birthdays; } - - private bool TryParseBirthdayDate(string dateString, string yearString, out DateTime birthDate) + + private bool TryParseBirthdayDate(string dateString, string? yearString, out DateTime birthDate) { birthDate = default; - + try { var provider = CultureInfo.InvariantCulture; - + // Add year if available and not empty if (!string.IsNullOrEmpty(yearString) && !yearString.Contains(" ")) { @@ -190,7 +195,7 @@ private bool TryParseBirthdayDate(string dateString, string yearString, out Date var tempDate = DateTime.ParseExact(dateString, "M/d", provider); birthDate = new DateTime(DateTime.Today.Year, tempDate.Month, tempDate.Day); } - + return true; } catch (FormatException) @@ -198,27 +203,27 @@ private bool TryParseBirthdayDate(string dateString, string yearString, out Date return false; } } - + private int? CalculateAge(DateTime birthDate, DateTime today) { if (birthDate.Year == today.Year) { return null; // No year information available } - + var age = today.Year - birthDate.Year; if (today.Month < birthDate.Month || (today.Month == birthDate.Month && today.Day < birthDate.Day)) { age--; } - + return age; } - + private string FormatBirthdayAnnouncement(BirthdayInfo birthday) { var message = $"🎉 **Happy Birthday {birthday.Name}!** 🎂"; - + if (birthday.Age.HasValue) { message += $" Hope you have a wonderful {GetAgeOrdinal(birthday.Age.Value)} birthday!"; @@ -227,10 +232,10 @@ private string FormatBirthdayAnnouncement(BirthdayInfo birthday) { message += " Hope you have a wonderful day!"; } - + return message; } - + private string GetAgeOrdinal(int age) { // Handle special cases for 11th, 12th, 13th regardless of tens digit @@ -239,17 +244,17 @@ private string GetAgeOrdinal(int age) { return $"{age}th"; } - + var lastDigit = age % 10; return lastDigit switch { 1 => $"{age}st", - 2 => $"{age}nd", + 2 => $"{age}nd", 3 => $"{age}rd", _ => $"{age}th" }; } - + public async Task RestartService() { IsRunning = false; @@ -261,7 +266,7 @@ public async Task RestartService() public class BirthdayInfo { - public string Name { get; set; } + public string Name { get; set; } = null!; public DateTime BirthDate { get; set; } public int? Age { get; set; } } \ No newline at end of file diff --git a/DiscordBot/Services/KarmaResetService.cs b/DiscordBot/Services/Profiles/KarmaResetService.cs similarity index 87% rename from DiscordBot/Services/KarmaResetService.cs rename to DiscordBot/Services/Profiles/KarmaResetService.cs index 69f8d3ea..71bf4303 100644 --- a/DiscordBot/Services/KarmaResetService.cs +++ b/DiscordBot/Services/Profiles/KarmaResetService.cs @@ -2,7 +2,7 @@ using Insight.Database; using Npgsql; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Profiles; /// /// Replaces MySQL EVENT scheduler — resets weekly/monthly/yearly karma columns on schedule. @@ -14,11 +14,13 @@ public class KarmaResetService private readonly ILoggingService _logging; private readonly string _connectionString; + private readonly CancellationToken _shutdownToken; - public KarmaResetService(ILoggingService logging, BotSettings settings) + public KarmaResetService(ILoggingService logging, BotSettings settings, CancellationTokenSource cts) { _logging = logging; _connectionString = settings.DbConnectionString; + _shutdownToken = cts.Token; Task.Run(RunLoop); } @@ -26,23 +28,24 @@ public KarmaResetService(ILoggingService logging, BotSettings settings) private async Task RunLoop() { // Wait for DatabaseService to finish table creation - await Task.Delay(TimeSpan.FromSeconds(10)); + await Task.Delay(TimeSpan.FromSeconds(10), _shutdownToken); try { await EnsureMetaTable(); await CatchUpMissedResets(); } + catch (OperationCanceledException) { return; } catch (Exception e) { await _logging.LogChannelAndFile($"KarmaResetService: Failed during startup: {e.Message}", ExtendedLogSeverity.Warning); } - while (true) + try { - try + while (!_shutdownToken.IsCancellationRequested) { - await Task.Delay(TimeSpan.FromHours(1)); + await Task.Delay(TimeSpan.FromHours(1), _shutdownToken); var now = DateTime.UtcNow; @@ -57,10 +60,11 @@ private async Task RunLoop() await TryReset("yearly", UserProps.KarmaYearly); } } - catch (Exception e) - { - await _logging.LogChannelAndFile($"KarmaResetService: Error during reset check: {e.Message}", ExtendedLogSeverity.Warning); - } + } + catch (OperationCanceledException) { } + catch (Exception e) + { + await _logging.LogChannelAndFile($"KarmaResetService: Error during reset check: {e.Message}", ExtendedLogSeverity.Warning); } } diff --git a/DiscordBot/Services/Profiles/KarmaService.cs b/DiscordBot/Services/Profiles/KarmaService.cs new file mode 100644 index 00000000..cf731cd8 --- /dev/null +++ b/DiscordBot/Services/Profiles/KarmaService.cs @@ -0,0 +1,117 @@ +using System.Text; +using System.Text.RegularExpressions; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Profiles; + +public class KarmaService +{ + private readonly DatabaseService _databaseService; + private readonly ILoggingService _loggingService; + private readonly BotSettings _settings; + + private readonly HashSet _canEditThanks; + private readonly Dictionary _thanksCooldown; + private readonly string _thanksRegex; + private readonly int _thanksCooldownTime; + private readonly int _thanksMinJoinTime; + + public KarmaService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, + BotSettings settings, UserSettings userSettings) + { + _databaseService = databaseService; + _loggingService = loggingService; + _settings = settings; + _canEditThanks = new HashSet(32); + _thanksCooldown = new Dictionary(); + + var sbThanks = new StringBuilder(); + var thx = userSettings.Thanks; + sbThanks.Append(@"(?i)(?(Thanks, nameof(Thanks)); + client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(ThanksEdited, nameof(ThanksEdited)); + } + + private async Task ThanksEdited(Cacheable cachedMessage, SocketMessage messageParam, + ISocketMessageChannel socketMessageChannel) + { + if (_canEditThanks.Contains(messageParam.Id)) await Thanks(messageParam); + } + + private async Task Thanks(SocketMessage messageParam) + { + var channel = (SocketGuildChannel)messageParam.Channel; + var guildId = channel.Guild.Id; + + if (guildId != _settings.GuildId) return; + + if (messageParam.Author.IsBot) + return; + var match = Regex.Match(messageParam.Content, _thanksRegex); + if (!match.Success) + return; + + var userId = messageParam.Author.Id; + var mentions = messageParam.MentionedUsers; + mentions = mentions.Distinct().Where(who => !who.IsBot && who.Id != userId).ToList(); + + const int defaultDelTime = 120; + if (mentions.Count > 0) + { + if (_thanksCooldown.HasUser(userId)) + { + await messageParam.Channel!.SendMessageAsync( + $"{messageParam.Author!.Mention} you must wait " + + $"{DateTime.Now - _thanksCooldown[userId]:ss} " + + "seconds before giving another karma point." + Environment.NewLine + + "(In the future, if you are trying to thank multiple people, include all their names in the thanks message.)") + .DeleteAfterTime(defaultDelTime)!; + return; + } + + var joinDate = ((IGuildUser)messageParam.Author).JoinedAt; + var j = joinDate + TimeSpan.FromSeconds(_thanksMinJoinTime); + if (j > DateTime.Now) + { + return; + } + + var sb = new StringBuilder(); + sb.Append(messageParam.Author.GetUserPreferredName().ToBold()); + sb.Append(" gave karma to "); + sb.Append(mentions.ToArray().ToUserPreferredNameArray().ToBoldArray().ToCommaList()); + var dbQuery = _databaseService.Query; + if (dbQuery != null) + { + foreach (var mention in mentions) + await dbQuery.IncrementKarma(mention.Id.ToString()); + + var authorKarmaGiven = await dbQuery.GetKarmaGiven(messageParam.Author.Id.ToString()); + await dbQuery.UpdateKarmaGiven(messageParam.Author.Id.ToString(), authorKarmaGiven + 1); + } + + sb.Append("."); + + _canEditThanks.Remove(messageParam.Id); + _thanksCooldown.AddCooldown(userId, _thanksCooldownTime); + + await messageParam.Channel.SendMessageAsync(sb.ToString()); + await _loggingService.LogChannelAndFile(sb + " in channel " + messageParam.Channel.Name); + } + + if (mentions.Count == 0 && _canEditThanks.Add(messageParam.Id)) + { + var _ = _canEditThanks.RemoveAfterSeconds(messageParam.Id, 240); + } + } +} diff --git a/DiscordBot/Services/Profiles/ProfileCardService.cs b/DiscordBot/Services/Profiles/ProfileCardService.cs new file mode 100644 index 00000000..06c99baa --- /dev/null +++ b/DiscordBot/Services/Profiles/ProfileCardService.cs @@ -0,0 +1,152 @@ +using System.IO; +using System.Net.Http; +using DiscordBot.Domain; +using DiscordBot.Settings; +using DiscordBot.Skin; +using ImageMagick; +using Newtonsoft.Json; + +namespace DiscordBot.Services.Profiles; + +public class ProfileCardService +{ + private readonly DatabaseService _databaseService; + private readonly ILoggingService _loggingService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly BotSettings _settings; + private readonly XpService _xpService; + + public ProfileCardService(DatabaseService databaseService, ILoggingService loggingService, + IHttpClientFactory httpClientFactory, BotSettings settings, XpService xpService) + { + _databaseService = databaseService; + _loggingService = loggingService; + _httpClientFactory = httpClientFactory; + _settings = settings; + _xpService = xpService; + } + + private SkinData? GetSkinData() => + JsonConvert.DeserializeObject(File.ReadAllText($"{_settings.AssetsRootPath}/skins/skin.json"), + new SkinModuleJsonConverter()); + + public async Task GenerateProfileCard(IUser user) + { + string profileCardPath = string.Empty; + + try + { + var dbRepo = _databaseService.Query; + if (dbRepo == null) + return profileCardPath; + + var userData = await dbRepo.GetUser(user.Id.ToString()); + + var xpTotal = userData.Exp; + var xpRank = await dbRepo.GetLevelRank(userData.UserID, userData.Level); + var karmaRank = await dbRepo.GetKarmaRank(userData.UserID, userData.Karma); + var karma = userData.Karma; + var level = userData.Level; + var xpLow = _xpService.GetXpLow(level); + var xpHigh = _xpService.GetXpHigh(level); + + var xpShown = (int)(xpTotal - xpLow); + var maxXpShown = (int)(xpHigh - xpLow); + + var percentage = (float)xpShown / maxXpShown; + + var u = (IGuildUser)user; + IRole? mainRole = null; + foreach (var id in u.RoleIds) + { + var role = u.Guild.GetRole(id); + if (mainRole == null) + mainRole = u.Guild.GetRole(id); + else if (role.Position > mainRole.Position) mainRole = role; + } + + mainRole ??= u.Guild.EveryoneRole; + + using var profileCard = new MagickImageCollection(); + var skin = GetSkinData(); + if (skin == null) + return profileCardPath; + var profile = new ProfileData + { + Karma = karma, + KarmaRank = karmaRank, + Level = level, + MainRoleColor = mainRole.Color, + MaxXpShown = maxXpShown, + Nickname = ((IGuildUser)user).Nickname, + UserId = ulong.Parse(userData.UserID), + Username = user.GetPreferredAndUsername(), + XpHigh = xpHigh, + XpLow = xpLow, + XpPercentage = percentage, + XpRank = xpRank, + XpShown = xpShown, + XpTotal = xpTotal + }; + + var background = new MagickImage($"{_settings.AssetsRootPath}/skins/{skin.Background}"); + + var avatarUrl = user.GetAvatarUrl(ImageFormat.Auto, 256); + if (string.IsNullOrEmpty(avatarUrl)) + profile.Picture = new MagickImage($"{_settings.AssetsRootPath}/images/default.png"); + else + try + { + Stream stream; + + using (var http = _httpClientFactory.CreateClient()) + { + stream = await http.GetStreamAsync(new Uri(avatarUrl)); + } + + profile.Picture = new MagickImage(stream); + } + catch (Exception e) + { + LoggingService.LogToConsole( + $"Failed to download user profile image for ProfileCard.\nEx:{e.Message}", + LogSeverity.Warning); + profile.Picture = new MagickImage($"{_settings.AssetsRootPath}/images/default.png"); + } + + profile.Picture.Resize((uint)skin.AvatarSize, (uint)skin.AvatarSize); + profileCard.Add(background); + + foreach (var layer in skin.Layers) + { + if (layer.Image != null) + { + var image = layer.Image.ToLower() == "avatar" + ? profile.Picture + : new MagickImage($"{_settings.AssetsRootPath}/skins/{layer.Image}"); + + background.Composite(image, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); + } + + var l = new MagickImage(MagickColors.Transparent, (uint)layer.Width, (uint)layer.Height); + foreach (var module in layer.Modules) module.GetDrawables(profile).Draw(l); + + background.Composite(l, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); + } + + profileCardPath = $"{_settings.ServerRootPath}/images/profiles/{user.Username}-profile.png"; + + using var result = profileCard.Mosaic(); + result.Write(profileCardPath); + } + catch (Exception e) + { + await _loggingService.LogChannelAndFile($"Failed to generate profile card for {user.Username}.\nEx:{e.Message}", ExtendedLogSeverity.LowWarning); + } + + if (!string.IsNullOrEmpty(profileCardPath)) + await Task.Delay(100); + + return profileCardPath; + } +} diff --git a/DiscordBot/Services/UserExtendedService.cs b/DiscordBot/Services/Profiles/UserExtendedService.cs similarity index 73% rename from DiscordBot/Services/UserExtendedService.cs rename to DiscordBot/Services/Profiles/UserExtendedService.cs index 7a3df56f..baf72b9e 100644 --- a/DiscordBot/Services/UserExtendedService.cs +++ b/DiscordBot/Services/Profiles/UserExtendedService.cs @@ -1,4 +1,4 @@ -namespace DiscordBot.Services; +namespace DiscordBot.Services.Profiles; /// /// May be renamed later. @@ -7,10 +7,10 @@ namespace DiscordBot.Services; public class UserExtendedService { private readonly DatabaseService _databaseService; - + // Cached Information private Dictionary _cityCachedName = new(); - + public UserExtendedService(DatabaseService databaseService) { _databaseService = databaseService; @@ -18,40 +18,46 @@ public UserExtendedService(DatabaseService databaseService) public async Task SetUserDefaultCity(IUser user, string city) { + var query = _databaseService.Query; + if (query is null) return false; // Update Database - await _databaseService.Query.UpdateDefaultCity(user.Id.ToString(), city); + await query.UpdateDefaultCity(user.Id.ToString(), city); // Update Cache _cityCachedName[user.Id] = city; return true; } - + public async Task DoesUserHaveDefaultCity(IUser user) { // Quickest check if we have cached result if (_cityCachedName.ContainsKey(user.Id)) return true; - + + var query = _databaseService.Query; + if (query is null) return false; // Check database - var res = await _databaseService.Query.GetDefaultCity(user.Id.ToString()); + var res = await query.GetDefaultCity(user.Id.ToString()); if (string.IsNullOrEmpty(res)) return false; - + // Cache result _cityCachedName[user.Id] = res; return true; } - + public async Task GetUserDefaultCity(IUser user) { if (await DoesUserHaveDefaultCity(user)) return _cityCachedName[user.Id]; return ""; } - + public async Task RemoveUserDefaultCity(IUser user) { + var query = _databaseService.Query; + if (query is null) return false; // Update Database - await _databaseService.Query.UpdateDefaultCity(user.Id.ToString(), null); + await query.UpdateDefaultCity(user.Id.ToString(), null); // Update Cache _cityCachedName.Remove(user.Id); return true; diff --git a/DiscordBot/Services/Profiles/XpService.cs b/DiscordBot/Services/Profiles/XpService.cs new file mode 100644 index 00000000..c3ab38ee --- /dev/null +++ b/DiscordBot/Services/Profiles/XpService.cs @@ -0,0 +1,111 @@ +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Profiles; + +public class XpService +{ + private readonly DatabaseService _databaseService; + private readonly ILoggingService _loggingService; + + private readonly Dictionary _xpCooldown; + private readonly List _noXpChannels; + private readonly Random _rand; + + private readonly int _xpMinPerMessage; + private readonly int _xpMaxPerMessage; + private readonly int _xpMinCooldown; + private readonly int _xpMaxCooldown; + + public XpService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, + BotSettings settings, UserSettings userSettings) + { + _databaseService = databaseService; + _loggingService = loggingService; + _rand = new Random(); + _xpCooldown = new Dictionary(); + + _xpMinPerMessage = userSettings.XpMinPerMessage; + _xpMaxPerMessage = userSettings.XpMaxPerMessage; + _xpMinCooldown = userSettings.XpMinCooldown; + _xpMaxCooldown = userSettings.XpMaxCooldown; + + _noXpChannels = new List { settings.Channels.BotCommands.Id }; + + client.MessageReceived += EventGuard.Guarded(UpdateXp, nameof(UpdateXp)); + } + + private Task UpdateXp(SocketMessage messageParam) + { + if (messageParam.Author.IsBot) + return Task.CompletedTask; + + if (_noXpChannels.Contains(messageParam.Channel.Id)) + return Task.CompletedTask; + + var userId = messageParam.Author.Id; + if (_xpCooldown.HasUser(userId)) + return Task.CompletedTask; + + var waitTime = _rand.Next(_xpMinCooldown, _xpMaxCooldown); + float baseXp = _rand.Next(_xpMinPerMessage, _xpMaxPerMessage); + float bonusXp = 0; + + _xpCooldown.AddCooldown(userId, waitTime); + Task.Run(async () => + { + var user = await _databaseService.GetOrAddUser((SocketGuildUser)messageParam.Author); + if (user == null) + return; + + var query = _databaseService.Query; + if (query is null) + return; + + bonusXp += baseXp * (1f + user.Karma / 100f); + + if (((IGuildUser)messageParam.Author).RoleIds.Count < 2) + baseXp *= .9f; + + var reduceXp = 1f; + if (user.Karma < user.Level) reduceXp = 1 - Math.Min(.9f, (user.Level - user.Karma) * .05f); + + var xpGain = (int)Math.Round((baseXp + bonusXp) * reduceXp); + + await query.UpdateXp(userId.ToString(), user.Exp + (long)xpGain); + + _loggingService.LogXp(messageParam.Channel.Name, messageParam.Author.Username, baseXp, bonusXp, reduceXp, + xpGain); + + await LevelUp(messageParam, userId); + }); + + return Task.CompletedTask; + } + + private async Task LevelUp(SocketMessage messageParam, ulong userId) + { + var query = _databaseService.Query; + if (query is null) return; + + var level = await query.GetLevel(userId.ToString()); + var xp = await query.GetXp(userId.ToString()); + + var xpHigh = GetXpHigh(level); + + if (xp < xpHigh) + return; + + await query.UpdateLevel(userId.ToString(), level + 1); + + if (level <= 3) + return; + + var msg = messageParam.Author.GetUserPreferredName().ToBold() + " has leveled up!"; + await (messageParam.Channel.SendMessageAsync(msg).DeleteAfterTime(60) ?? Task.CompletedTask); + } + + public double GetXpLow(int level) => 70d - 139.5d * (level + 1d) + 69.5 * Math.Pow(level + 1d, 2d); + + public double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); +} diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/Server/AuditLogService.cs similarity index 57% rename from DiscordBot/Services/ModerationService.cs rename to DiscordBot/Services/Server/AuditLogService.cs index d9320086..a2c7dc2e 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/Server/AuditLogService.cs @@ -1,39 +1,30 @@ -using System.Text.RegularExpressions; +using System.Globalization; using Discord.WebSocket; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Server; -public class ModerationService +public class AuditLogService { private readonly ILoggingService _loggingService; - private readonly DiscordSocketClient _client; - private readonly CommandHandlingService _commandHandlingService; - + private const int MaxMessageLength = 800; - private static readonly Color DeletedMessageColor = new (200, 128, 128); - private static readonly Color EditedMessageColor = new (255, 255, 128); - - private readonly IMessageChannel _botAnnouncementChannel; - private readonly IMessageChannel _memeChannel; - private readonly bool _moderatorNoInviteLinks; - - public ModerationService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService, - CommandHandlingService commandHandlingService) + private static readonly Color DeletedMessageColor = new(200, 128, 128); + private static readonly Color EditedMessageColor = new(255, 255, 128); + + private readonly IMessageChannel? _botAnnouncementChannel = null!; + + public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService) { - _client = client; _loggingService = loggingService; - _commandHandlingService = commandHandlingService; - client.MessageDeleted += MessageDeleted; - client.MessageUpdated += MessageUpdated; - client.MessageReceived += MessageReceived; + client.MessageDeleted += EventGuard.Guarded, Cacheable>(MessageDeleted, nameof(MessageDeleted)); + client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(MessageUpdated, nameof(MessageUpdated)); + client.UserLeft += EventGuard.Guarded(UserLeft, nameof(UserLeft)); + client.GuildMemberUpdated += EventGuard.Guarded, SocketGuildUser>(GuildMemberUpdated, nameof(GuildMemberUpdated)); - if (settings.BotAnnouncementChannel != null) - _botAnnouncementChannel = _client.GetChannel(settings.BotAnnouncementChannel.Id) as IMessageChannel; - if (settings.MemeChannel != null) - _memeChannel = _client.GetChannel(settings.MemeChannel.Id) as IMessageChannel; - _moderatorNoInviteLinks = settings.ModeratorNoInviteLinks; + if (settings.Channels.BotAnnouncement != null) + _botAnnouncementChannel = client.GetChannel(settings.Channels.BotAnnouncement.Id) as IMessageChannel; } private async Task MessageDeleted(Cacheable message, Cacheable channel) @@ -43,10 +34,10 @@ private async Task MessageDeleted(Cacheable message, Cacheable< await _loggingService.LogChannelAndFile($"An uncached Message snowflake:`{message.Id}` was deleted from channel <#{(await channel.GetOrDownloadAsync()).Id}>"); return; } - - if (message.Value.Author.IsBot || channel.Id == _botAnnouncementChannel.Id) + + if (message.Value.Author.IsBot || channel.Id == _botAnnouncementChannel?.Id) return; - // Check the author is even in the guild + var guildUser = message.Value.Author as SocketGuildUser; if (guildUser == null) return; @@ -64,13 +55,13 @@ private async Task MessageDeleted(Cacheable message, Cacheable< .AddField($"Deleted Message {(content.Length != message.Value.Content.Length ? "(truncated)" : "")}", content); var embed = builder.Build(); - + await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); } private async Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) { - if (after.Author.IsBot || channel.Id == _botAnnouncementChannel.Id) + if (after.Author.IsBot || channel.Id == _botAnnouncementChannel?.Id) return; bool isCached = true; @@ -81,10 +72,9 @@ private async Task MessageUpdated(Cacheable before, SocketMessa else content = beforeMessage.Content; - // Check the message aren't the same if (content == after.Content) return; - if (content.Length == 0 && beforeMessage.Attachments.Count == 0) + if (content.Length == 0 && (beforeMessage?.Attachments.Count ?? 0) == 0) return; bool isTruncated = false; @@ -103,8 +93,7 @@ private async Task MessageUpdated(Cacheable before, SocketMessa if (isCached) { builder.AddField($"Previous message content {(isTruncated ? "(truncated)" : "")}", content); - // if any attachments that after does not, add a link to them and a count - if (beforeMessage.Attachments.Count > 0) + if (beforeMessage?.Attachments.Count > 0) { var attachments = beforeMessage.Attachments.Where(x => after.Attachments.All(y => y.Url != x.Url)); var removedAttachments = attachments.ToList(); @@ -119,35 +108,37 @@ private async Task MessageUpdated(Cacheable before, SocketMessa builder.WithDescription($"Message: [{after.Id}]({after.GetJumpUrl()})"); var embed = builder.Build(); - // TimeStamp for the Footer - await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); } - - // MessageReceived - private async Task MessageReceived(SocketMessage message) + + private async Task UserLeft(SocketGuild guild, SocketUser user) { - if (message.Author.IsBot) - return; + if (user.IsBot) return; - if (_moderatorNoInviteLinks == true) + var guildUser = guild.GetUser(user.Id); + if (guildUser?.JoinedAt != null) { - if (_memeChannel.Id == message.Channel.Id) - { - if (message.ContainsInviteLink()) - { - await message.DeleteAsync(); - // Send a message in _botAnnouncementChannel about the deleted message, nothing fancy, name, userid, channel and message content - await _botAnnouncementChannel.SendMessageAsync( - $"{message.Author.Mention} tried to post an invite link in <#{message.Channel.Id}>: {message.Content}"); - return; - } - } + var joinDate = guildUser.JoinedAt.Value.Date; + var timeStayed = DateTime.Now - joinDate; + await _loggingService.LogChannelAndFile( + $"User Left - After {(timeStayed.Days > 1 ? Math.Floor((double)timeStayed.Days) + " days" : " ")}" + + $" {Math.Floor((double)timeStayed.Hours).ToString(CultureInfo.InvariantCulture)} hours {user.Mention} - `{guildUser.GetPreferredAndUsername()}` - ID : `{user.Id}`"); + } + else + { + await _loggingService.LogChannelAndFile( + $"User Left - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}` - Left at {DateTime.Now}"); } } - public async Task GetBotCommandHistory(int count) + private async Task GuildMemberUpdated(Cacheable oldUserCached, SocketGuildUser user) { - return await _commandHandlingService.GetCommandHistory(count); + var oldUser = await oldUserCached.GetOrDownloadAsync(); + if (oldUser.Nickname != user.Nickname) + { + await _loggingService.LogChannelAndFile( + $"User {oldUser.GetUserPreferredName()} changed his " + + $"username to {user.GetUserPreferredName()}"); + } } -} \ No newline at end of file +} diff --git a/DiscordBot/Services/Server/EmbedParsingService.cs b/DiscordBot/Services/Server/EmbedParsingService.cs new file mode 100644 index 00000000..3ddf428a --- /dev/null +++ b/DiscordBot/Services/Server/EmbedParsingService.cs @@ -0,0 +1,135 @@ +using System.Net.Http; +using System.Text; +using Newtonsoft.Json; + +namespace DiscordBot.Services.Server; + +public class EmbedParsingService +{ + private readonly IHttpClientFactory _httpClientFactory; + + public EmbedParsingService(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + +#pragma warning disable 0649 + private class EmbedData + { + public class Footer + { + public string icon_url = string.Empty; + public string text = string.Empty; + } + + public class Thumbnail + { + public string url = string.Empty; + } + + public class Image + { + public string url = string.Empty; + } + + public class Author + { + public string name = string.Empty; + public string url = string.Empty; + public string icon_url = string.Empty; + } + + public class Field + { + public string name = string.Empty; + public string value = string.Empty; + public bool? inline; + } + + public string title = string.Empty; + public string description = string.Empty; + public string url = string.Empty; + public uint? color; + public DateTimeOffset? timestamp; + public Footer footer = null!; + public Thumbnail thumbnail = null!; + public Image image = null!; + public Author author = null!; + public Field[] fields = []; + } +#pragma warning restore 0649 + + private static readonly string[] ValidHosts = + { + "hastebin.com", "gdl.space", "hastepaste.com", "pastebin.com", "pastie.org" + }; + + public bool IsValidHost(string host) => ValidHosts.Contains(host); + + public string GetDownloadUrl(Uri uri) + { + return uri.Host switch + { + "hastebin.com" or "gdl.space" => $"https://{uri.Host}/raw{uri.AbsolutePath}", + "hastepaste.com" => $"https://hastepaste.com/raw{uri.AbsolutePath[5..]}", + "pastebin.com" => $"https://pastebin.com/raw{uri.AbsolutePath}", + "pastie.org" => $"{uri.OriginalString}/raw", + _ => string.Empty + }; + } + + public async Task BuildEmbedFromUrl(string url) + { + using var client = _httpClientFactory.CreateClient(); + var buffer = await client.GetByteArrayAsync(url); + string json = Encoding.UTF8.GetString(buffer); + return BuildEmbed(json); + } + + public Discord.Embed BuildEmbed(string json) + { + var embedData = JsonConvert.DeserializeObject(json); + var builder = new Discord.EmbedBuilder(); + if (embedData == null) return builder.Build(); + + if (!string.IsNullOrEmpty(embedData.title)) builder.Title = embedData.title; + if (!string.IsNullOrEmpty(embedData.description)) builder.Description = embedData.description; + if (!string.IsNullOrEmpty(embedData.url)) builder.Url = embedData.url; + if (embedData.color.HasValue) builder.Color = new Discord.Color(embedData.color.Value); + if (embedData.timestamp.HasValue) builder.Timestamp = embedData.timestamp.Value; + + if (embedData.footer != null) + { + builder.Footer = new Discord.EmbedFooterBuilder(); + if (!string.IsNullOrEmpty(embedData.footer.icon_url)) builder.Footer.IconUrl = embedData.footer.icon_url; + if (!string.IsNullOrEmpty(embedData.footer.text)) builder.Footer.Text = embedData.footer.text; + } + + if (embedData.thumbnail != null && !string.IsNullOrEmpty(embedData.thumbnail.url)) + builder.ThumbnailUrl = embedData.thumbnail.url; + if (embedData.image != null && !string.IsNullOrEmpty(embedData.image.url)) + builder.ImageUrl = embedData.image.url; + + if (embedData.author != null) + { + builder.Author = new Discord.EmbedAuthorBuilder(); + if (!string.IsNullOrEmpty(embedData.author.icon_url)) builder.Author.IconUrl = embedData.author.icon_url; + if (!string.IsNullOrEmpty(embedData.author.name)) builder.Author.Name = embedData.author.name; + if (!string.IsNullOrEmpty(embedData.author.url)) builder.Author.Url = embedData.author.url; + } + + if (embedData.fields != null) + { + foreach (var field in embedData.fields) + { + var f = new Discord.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; + builder.AddField(f); + } + } + + return builder.Build(); + } +} diff --git a/DiscordBot/Services/Server/EveryoneScoldService.cs b/DiscordBot/Services/Server/EveryoneScoldService.cs new file mode 100644 index 00000000..90ad1d7a --- /dev/null +++ b/DiscordBot/Services/Server/EveryoneScoldService.cs @@ -0,0 +1,36 @@ +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Server; + +public class EveryoneScoldService +{ + private readonly BotSettings _settings; + private readonly Dictionary _everyoneScoldCooldown = new(); + + public EveryoneScoldService(DiscordSocketClient client, BotSettings settings) + { + _settings = settings; + client.MessageReceived += EventGuard.Guarded(ScoldForAtEveryoneUsage, nameof(ScoldForAtEveryoneUsage)); + } + + private async Task ScoldForAtEveryoneUsage(SocketMessage messageParam) + { + if (messageParam.Author.IsBot || ((IGuildUser)messageParam.Author).GuildPermissions.MentionEveryone) + return; + var content = messageParam.Content; + if (content.Contains("@everyone") || content.Contains("@here")) + { + if (_everyoneScoldCooldown.ContainsKey(messageParam.Author.Id) && + _everyoneScoldCooldown[messageParam.Author.Id] > DateTime.Now) + return; + _everyoneScoldCooldown[messageParam.Author.Id] = + DateTime.Now.AddSeconds(_settings.EveryoneScoldPeriodSeconds); + + await (messageParam.Channel.SendMessageAsync( + $"Please don't try to alert **everyone** on the server, {messageParam.Author.Mention}!\n" + + "If you are asking a question, people will help you when they have time.") + .DeleteAfterTime(minutes: 2) ?? Task.CompletedTask); + } + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Server/RecruitService.cs similarity index 87% rename from DiscordBot/Services/Recruitment/RecruitService.cs rename to DiscordBot/Services/Server/RecruitService.cs index 8a18e95d..bd2d1fd4 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Server/RecruitService.cs @@ -2,36 +2,35 @@ using DiscordBot.Settings; using DiscordBot.Utils; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Server; public class RecruitService { private const string ServiceName = "RecruitmentService"; - + private readonly DiscordSocketClient _client; - private readonly ILoggingService _logging; private SocketRole ModeratorRole { get; set; } #region Extra Details - + private readonly ForumTag _tagIsHiring; private readonly ForumTag _tagWantsWork; private readonly ForumTag _tagUnpaidCollab; private readonly ForumTag _tagPosFilled; - private readonly IForumChannel _recruitChannel; + private readonly IForumChannel? _recruitChannel; #endregion // Extra Details - + #region Configuration - private static Color DeletedMessageColor => new (255, 50, 50); - private static Color WarningMessageColor => new (255, 255, 100); - private static Color EditedMessageColor => new (100, 255, 100); + private static Color DeletedMessageColor => new(255, 50, 50); + private static Color WarningMessageColor => new(255, 255, 100); + private static Color EditedMessageColor => new(100, 255, 100); private const int TimeBeforeDeletingForumInSec = 60; private const string MessageToBeDeleted = "Your thread will be deleted in %s because it did not follow the expected guidelines. Try again after the slow mode period has passed."; - + private const int MinimumLengthMessage = 120; private const int ShortMessageNoticeDurationInSec = 30 * 4; @@ -39,56 +38,55 @@ public class RecruitService private const string MessageToBeEdited = "This post will remain editable until %s, make any desired changes to your thread. After that the thread will be locked."; - private Embed _userHiringButNoPrice; - private Embed _userWantsWorkButNoPrice; + private Embed _userHiringButNoPrice = null!; + private Embed _userWantsWorkButNoPrice = null!; - private Embed _userDidntUseTags; - private Embed _userRevShareMentioned; - private Embed _userMoreThanOneTagUsed; + private Embed _userDidntUseTags = null!; + private Embed _userRevShareMentioned = null!; + private Embed _userMoreThanOneTagUsed = null!; Dictionary _botSanityCheck = new Dictionary(); #endregion // Configuration - - public RecruitService(DiscordSocketClient client, ILoggingService logging, BotSettings settings) + + public RecruitService(DiscordSocketClient client, BotSettings settings) { _client = client; - _logging = logging; - ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); + ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.Roles.Moderator); - if (!settings.RecruitmentServiceEnabled) + if (!settings.Recruitment.Enabled) { - LoggingService.LogServiceDisabled(ServiceName, nameof(settings.RecruitmentServiceEnabled)); + LoggingService.LogServiceDisabled(ServiceName, nameof(settings.Recruitment.Enabled)); return; } - _editTimePermissionInMin = settings.EditPermissionAccessTimeMin; - + _editTimePermissionInMin = settings.Recruitment.EditPermissionAccessTimeMin; + // Get target channel - _recruitChannel = _client.GetChannel(settings.RecruitmentChannel.Id) as IForumChannel; + _recruitChannel = _client.GetChannel(settings.Channels.Recruitment.Id) as IForumChannel; if (_recruitChannel == null) { LoggingService.LogToConsole("[{ServiceName}] Recruitment channel not found.", LogSeverity.Error); return; } - + try { - var lookingToHire = ulong.Parse(settings.TagLookingToHire); - var lookingForWork = ulong.Parse(settings.TagLookingForWork); - var unpaidCollab = ulong.Parse(settings.TagUnpaidCollab); - var positionFilled = ulong.Parse(settings.TagPositionFilled); - + var lookingToHire = ulong.Parse(settings.Recruitment.TagLookingToHire); + var lookingForWork = ulong.Parse(settings.Recruitment.TagLookingForWork); + var unpaidCollab = ulong.Parse(settings.Recruitment.TagUnpaidCollab); + var positionFilled = ulong.Parse(settings.Recruitment.TagPositionFilled); + var availableTags = _recruitChannel.Tags; _tagIsHiring = availableTags.First(x => x.Id == lookingToHire); _tagWantsWork = availableTags.First(x => x.Id == lookingForWork); _tagUnpaidCollab = availableTags.First(x => x.Id == unpaidCollab); _tagPosFilled = availableTags.First(x => x.Id == positionFilled); - + // If any tags are null we print a logging warning - if (_tagIsHiring == null) StartUpTagMissing(lookingToHire, nameof(settings.TagLookingToHire)); - if (_tagWantsWork == null) StartUpTagMissing(lookingForWork, nameof(settings.TagLookingForWork)); - if (_tagUnpaidCollab == null) StartUpTagMissing(unpaidCollab, nameof(settings.TagUnpaidCollab)); - if (_tagPosFilled == null) StartUpTagMissing(positionFilled, nameof(settings.TagPositionFilled)); + if (_tagIsHiring == null) StartUpTagMissing(lookingToHire, nameof(settings.Recruitment.TagLookingToHire)); + if (_tagWantsWork == null) StartUpTagMissing(lookingForWork, nameof(settings.Recruitment.TagLookingForWork)); + if (_tagUnpaidCollab == null) StartUpTagMissing(unpaidCollab, nameof(settings.Recruitment.TagUnpaidCollab)); + if (_tagPosFilled == null) StartUpTagMissing(positionFilled, nameof(settings.Recruitment.TagPositionFilled)); } catch (Exception e) { @@ -100,14 +98,16 @@ public RecruitService(DiscordSocketClient client, ILoggingService logging, BotSe _client.MessageReceived += GatewayOnMessageReceived; ConstructEmbeds(); - + LoggingService.LogServiceEnabled(ServiceName); } - + #region Thread Creation private async Task GatewayOnThreadCreated(SocketThreadChannel thread) { + if (_recruitChannel == null) + return; if (!thread.IsThreadInChannel(_recruitChannel.Id)) return; if (thread.Owner.IsUserBotOrWebhook()) @@ -127,7 +127,7 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) _botSanityCheck.Clear(); _botSanityCheck.Add(thread.Id, true); #endregion // Sanity Check - + LoggingService.DebugLog($"[{ServiceName}] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); var message = (await thread.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); @@ -137,7 +137,7 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) return; } - Task.Run(async () => + _ = Task.Run(async () => { if (!DoesThreadHaveAValidTag(thread)) { @@ -163,11 +163,11 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) } await ThreadHandleRevShare(thread, message); } - + // Any Notices that we can recommend the user for improvement if (message.Content.Length < MinimumLengthMessage) { - Task.Run(() => ThreadHandleShortMessage(thread, message)); + _ = Task.Run(() => ThreadHandleShortMessage(thread, message)); } await Task.Delay(millisecondsDelay: 200); @@ -184,7 +184,7 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) var threadMessage = await (channel.GetMessageAsync(thread.Id)); if (threadMessage == null) return; - + // We do one last check to make sure the thread is still valid if (isPaidWork && !threadMessage.Content.ContainsCurrencySymbol()) { @@ -192,14 +192,16 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) } }); } - + private async Task GatewayOnMessageReceived(SocketMessage message) { var thread = message.Channel as SocketThreadChannel; // check if channel is a thread in a forum if (thread == null) return; - + + if (_recruitChannel == null) + return; if (!thread.IsThreadInChannel(_recruitChannel.Id)) return; if (message.Author.IsUserBotOrWebhook()) @@ -234,42 +236,42 @@ private async Task ThreadHandleRevShare(SocketThreadChannel thread, IMessage mes await thread.SendMessageAsync(embed: _userRevShareMentioned); } } - + private async Task ThreadHandleMoreThanOneTag(SocketThreadChannel thread) { await thread.SendMessageAsync(embed: _userMoreThanOneTagUsed); await DeleteThread(thread); } - + private async Task ThreadHandleNoTags(SocketThreadChannel thread) { await thread.SendMessageAsync(embed: _userDidntUseTags); await DeleteThread(thread); } - + private async Task ThreadHandleShortMessage(SocketThreadChannel thread, IMessage message) { if (message.Content.Length < MinimumLengthMessage) { var ourResponse = await thread.SendMessageAsync(embed: GetShortMessageEmbed()); - await ourResponse.DeleteAfterSeconds(ShortMessageNoticeDurationInSec); + await (ourResponse.DeleteAfterSeconds(ShortMessageNoticeDurationInSec) ?? Task.CompletedTask); } } - + private async Task GrantEditPermissions(SocketThreadChannel thread) { var parentChannel = thread.ParentChannel; var message = await thread.SendMessageAsync(embed: GetEditPermMessageEmbed()); await parentChannel.AddPermissionOverwriteAsync(thread.Owner, new OverwritePermissions(sendMessages: PermValue.Allow)); - + // We give them a bit of time to edit their post, then remove the permission - await message.DeleteAfterSeconds((_editTimePermissionInMin * 60) + 2); + await (message.DeleteAfterSeconds((_editTimePermissionInMin * 60) + 2) ?? Task.CompletedTask); await parentChannel.RemovePermissionOverwriteAsync(thread.Owner); - + // Lock the thread so anyone else can't post even when they have edit permissions await thread.ModifyAsync(x => x.Locked = true); } - + #endregion // Basic Handlers for posts #region Basic Logging Assisst @@ -291,14 +293,14 @@ private void ConstructEmbeds() $"You have used the `{_tagIsHiring.Name}` tag but have not specified a price of any kind.\n\nPost **must** include a currency symbol or word, e.g. $, dollars, USD, £, pounds, €, EUR, euro, euros, GBP.") .WithColor(DeletedMessageColor) .Build(); - + _userWantsWorkButNoPrice = new EmbedBuilder() .WithTitle("No payment price detected") .WithDescription( $"You have used the `{_tagWantsWork.Name}` tag but have not specified a price of any kind.\n\nPost **must** include a currency symbol or word, e.g. $, dollars, USD, £, pounds, €, EUR, euro, euros, GBP.") .WithColor(DeletedMessageColor) .Build(); - + _userRevShareMentioned = new EmbedBuilder() .WithTitle("Notice: Rev-Share mentioned") .WithDescription( @@ -306,7 +308,7 @@ private void ConstructEmbeds() $"Consider using the `{_tagUnpaidCollab.Name}` tag instead if you intend to use rev-share as a source of payment.") .WithColor(WarningMessageColor) .Build(); - + _userMoreThanOneTagUsed = new EmbedBuilder() .WithTitle("Broken Guideline: Colliding tags used") .WithDescription( @@ -314,7 +316,7 @@ private void ConstructEmbeds() "Be sure to read the guidelines before posting.") .WithColor(DeletedMessageColor) .Build(); - + _userDidntUseTags = new EmbedBuilder() .WithTitle("Broken Guideline: No tags used") .WithDescription( @@ -323,7 +325,7 @@ private void ConstructEmbeds() .WithColor(DeletedMessageColor) .Build(); } - + private Embed GetDeletedMessageEmbed() { var message = MessageToBeDeleted.Replace("%s", GetDynamicTimeStampString(TimeBeforeDeletingForumInSec)); @@ -365,11 +367,11 @@ private bool IsThreadUsingMoreThanOneTag(SocketThreadChannel thread) { int clashingTagCount = 0; var tags = thread.AppliedTags; - + if (tags.Contains(_tagIsHiring.Id)) clashingTagCount++; if (tags.Contains(_tagWantsWork.Id)) clashingTagCount++; if (tags.Contains(_tagUnpaidCollab.Id)) clashingTagCount++; - + return clashingTagCount > 1; } @@ -378,11 +380,11 @@ private bool DoesThreadHaveAValidTag(SocketThreadChannel thread) var tags = thread.AppliedTags; return tags.Contains(_tagIsHiring.Id) || tags.Contains(_tagWantsWork.Id) || tags.Contains(_tagUnpaidCollab.Id); } - + private async Task DeleteThread(SocketThreadChannel thread) { await thread.SendMessageAsync(embed: GetDeletedMessageEmbed()); - await thread.DeleteAfterSeconds(TimeBeforeDeletingForumInSec); + await (thread.DeleteAfterSeconds(TimeBeforeDeletingForumInSec) ?? Task.CompletedTask); } private string GetDynamicTimeStampString(int addSeconds) @@ -392,5 +394,5 @@ private string GetDynamicTimeStampString(int addSeconds) } #endregion // Basic Utility - + } \ No newline at end of file diff --git a/DiscordBot/Services/ReminderService.cs b/DiscordBot/Services/Server/ReminderService.cs similarity index 87% rename from DiscordBot/Services/ReminderService.cs rename to DiscordBot/Services/Server/ReminderService.cs index 6c25ad32..8e61b127 100644 --- a/DiscordBot/Services/ReminderService.cs +++ b/DiscordBot/Services/Server/ReminderService.cs @@ -1,7 +1,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Server; [Serializable] public class ReminderItem @@ -9,7 +9,7 @@ public class ReminderItem public ulong ChannelId { get; set; } public ulong MessageId { get; set; } public ulong UserId { get; set; } - public string Message { get; set; } + public string Message { get; set; } = null!; public DateTime When { get; set; } } @@ -31,15 +31,18 @@ public class ReminderService private readonly ChannelInfo _botCommandsChannel; private readonly string _serverRootPath; private bool _hasChangedSinceLastSave = false; + private readonly CancellationToken _shutdownToken; private const int _maxUserReminders = 10; - public ReminderService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) + public ReminderService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings, + CancellationTokenSource cts) { _client = client; _loggingService = loggingService; - _botCommandsChannel = settings.BotCommandsChannel; + _botCommandsChannel = settings.Channels.BotCommands; _serverRootPath = settings.ServerRootPath; + _shutdownToken = cts.Token; Initialize(); } @@ -61,11 +64,11 @@ private void Initialize() // Serialize Reminders to file public void SaveReminders() { - Utils.SerializeUtil.SerializeFile($"{_serverRootPath}/reminders.json", _reminders); + global::DiscordBot.Utils.SerializeUtil.SerializeFile($"{_serverRootPath}/reminders.json", _reminders); } private void LoadReminders() { - _reminders = Utils.SerializeUtil.DeserializeFile>($"{_serverRootPath}/reminders.json"); + _reminders = global::DiscordBot.Utils.SerializeUtil.DeserializeFile>($"{_serverRootPath}/reminders.json"); } public void AddReminder(ReminderItem reminder) { @@ -112,7 +115,7 @@ private async Task CheckReminders() { try { - while (true) + while (!_shutdownToken.IsCancellationRequested) { // We check if there has been a change to the reminders list since the last update. if (_hasChangedSinceLastSave) @@ -121,7 +124,7 @@ private async Task CheckReminders() _hasChangedSinceLastSave = false; } - await Task.Delay(1000); + await Task.Delay(1000, _shutdownToken); var now = DateTime.Now; // We wait until we know at least one reminder needs to be checked @@ -134,7 +137,7 @@ private async Task CheckReminders() { _reminders.Remove(reminder); - IUserMessage message = null; + IUserMessage? message = null; var channel = _client.GetChannel(reminder.ChannelId) as SocketTextChannel; if (channel != null) message = await channel.GetMessageAsync(reminder.MessageId) as IUserMessage; @@ -179,6 +182,12 @@ await channel.SendMessageAsync( _nearestReminder = _reminders.Min(x => x.When); } } + catch (OperationCanceledException) + { + // Save any pending changes on shutdown + if (_hasChangedSinceLastSave) + SaveReminders(); + } catch (Exception e) { // Catch and show exception diff --git a/DiscordBot/Services/Server/ServerService.cs b/DiscordBot/Services/Server/ServerService.cs new file mode 100644 index 00000000..caa27d82 --- /dev/null +++ b/DiscordBot/Services/Server/ServerService.cs @@ -0,0 +1,15 @@ +using Discord.WebSocket; + +namespace DiscordBot.Services.Server; + +public class ServerService +{ + private readonly DiscordSocketClient _client; + + public ServerService(DiscordSocketClient client) + { + _client = client; + } + + public int GetGatewayPing() => _client.Latency; +} diff --git a/DiscordBot/Services/Server/WelcomeService.cs b/DiscordBot/Services/Server/WelcomeService.cs new file mode 100644 index 00000000..1bb3ce36 --- /dev/null +++ b/DiscordBot/Services/Server/WelcomeService.cs @@ -0,0 +1,245 @@ +using System.IO; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Server; + +public class WelcomeService +{ + private const string ServiceName = "WelcomeService"; + + private readonly DiscordSocketClient _client; + private readonly DatabaseService _databaseService; + private readonly ILoggingService _loggingService; + + private readonly BotSettings _settings; + private readonly CancellationToken _shutdownToken; + + private readonly List<(ulong id, DateTime time)> _welcomeNoticeUsers = new(); + + private readonly Color _welcomeColour = new Color(7, 84, 53); + public int WaitingWelcomeMessagesCount => _welcomeNoticeUsers.Count; + + public DateTime NextWelcomeMessage => + _welcomeNoticeUsers.Any() ? _welcomeNoticeUsers.Min(x => x.time) : DateTime.MaxValue; + + public WelcomeService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, + BotSettings settings, CancellationTokenSource cts) + { + _client = client; + _databaseService = databaseService; + _loggingService = loggingService; + _settings = settings; + _shutdownToken = cts.Token; + + /* Make sure folders we require exist */ + if (!Directory.Exists($"{_settings.ServerRootPath}/images/profiles/")) + { + Directory.CreateDirectory($"{_settings.ServerRootPath}/images/profiles/"); + } + + /* + Event subscriptions + */ + _client.UserJoined += EventGuard.Guarded(UserJoined, nameof(UserJoined)); + + _client.MessageReceived += EventGuard.Guarded(CheckForWelcomeMessage, nameof(CheckForWelcomeMessage)); + _client.UserIsTyping += EventGuard.Guarded, Cacheable>(UserIsTyping, nameof(UserIsTyping)); + + Task.Run(DelayedWelcomeService); + } + + public Embed WelcomeMessage(SocketGuildUser user) + { + string icon = user.GetAvatarUrl(); + icon = string.IsNullOrEmpty(icon) ? "https://cdn.discordapp.com/embed/avatars/0.png" : icon; + + string welcomeString = $"Welcome to Unity Developer Community, {user.GetPreferredAndUsername()}!"; + var builder = new EmbedBuilder() + .WithDescription(welcomeString) + .WithColor(_welcomeColour) + .WithAuthor(user.GetUserPreferredName(), icon); + + var embed = builder.Build(); + return embed; + } + + #region Events + + // Anything relevant to the first time someone connects to the server + + #region Welcome Service + + // If a user talks before they've been welcomed, we welcome them and remove them from the welcome list so they're not welcomes a second time. + private async Task UserIsTyping(Cacheable user, Cacheable channel) + { + if (_welcomeNoticeUsers.Count == 0) + return; + if (user.Value.IsBot) + return; + + if (_welcomeNoticeUsers.Exists(u => u.id == user.Id)) + { + _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); + await ProcessWelcomeUser(user.Id, user.Value); + } + } + + private async Task CheckForWelcomeMessage(SocketMessage messageParam) + { + if (_welcomeNoticeUsers.Count == 0) + return; + + var user = messageParam.Author; + if (user.IsBot) + return; + + if (_welcomeNoticeUsers.Exists(u => u.id == user.Id)) + { + _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); + await ProcessWelcomeUser(user.Id, user); + } + } + + private async Task UserJoined(SocketGuildUser user) + { + // Send them the Welcome DM first. + await DMFormattedWelcome(user); + + var socketTextChannel = _client.GetChannel(_settings.Channels.General.Id) as SocketTextChannel; + await _databaseService.GetOrAddUser(user); + + await _loggingService.LogChannelAndFile( + $"User Joined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); + + // We check if they're already in the welcome list, if they are we don't add them again to avoid double posts + if (_welcomeNoticeUsers.Count == 0 || !_welcomeNoticeUsers.Exists(u => u.id == user.Id)) + { + _welcomeNoticeUsers.Add((user.Id, DateTime.Now.AddSeconds(_settings.WelcomeMessageDelaySeconds))); + } + } + + // Welcomes users to the server after they've been connected for over x number of seconds. + private async Task DelayedWelcomeService() + { + ulong currentlyProcessedUserId = 0; + bool firstRun = true; + await Task.Delay(10000, _shutdownToken); + try + { + List toRemove = new(); + while (!_shutdownToken.IsCancellationRequested) + { + var now = DateTime.Now; + // This could be optimized, however the users in this list won't ever really be large enough to matter. + // We loop through our list, anyone that has been in the list for more than x seconds is welcomed. + foreach (var userData in _welcomeNoticeUsers.Where(u => u.time < now)) + { + currentlyProcessedUserId = userData.id; + await ProcessWelcomeUser(userData.id, null); + + toRemove.Add(userData.id); + } + + // Remove all the users we've welcomed from the list + if (toRemove.Count > 0) + { + _welcomeNoticeUsers.RemoveAll(u => toRemove.Contains(u.id)); + toRemove.Clear(); + // Prevent the list from growing too large, not that it really matters. + if (toRemove.Capacity > 20) + { + toRemove.Capacity = 20; + } + } + + if (firstRun) + firstRun = false; + await Task.Delay(10000, _shutdownToken); + } + } + catch (OperationCanceledException) { } + catch (Exception e) + { + // Catch and show exception + await _loggingService.LogChannelAndFile($"{ServiceName} Exception during welcome message `{currentlyProcessedUserId}`.\n{e.Message}.", ExtendedLogSeverity.Warning); + + // Remove the offending user from the dictionary and run the service again. + _welcomeNoticeUsers.RemoveAll(u => u.id == currentlyProcessedUserId); + if (_welcomeNoticeUsers.Count > 200) + { + _welcomeNoticeUsers.Clear(); + await _loggingService.LogAction($"{ServiceName}: Welcome list cleared due to size (+200), this should not happen.", ExtendedLogSeverity.Error); + } + + if (firstRun) + await _loggingService.LogAction($"{ServiceName}: Welcome service failed on first run!? This should not happen.", ExtendedLogSeverity.Error); + + // Restart unless shutdown was requested + if (!_shutdownToken.IsCancellationRequested) + _ = Task.Run(DelayedWelcomeService); + } + } + + private async Task ProcessWelcomeUser(ulong userID, IUser? user = null) + { + if (_welcomeNoticeUsers.Exists(u => u.id == userID)) + // If we didn't get the user passed in, we try grab it + user ??= await _client.GetUserAsync(userID); + // if they're null, they've likely left, so we just remove them from the list. + if (user == null) + return; + + var offTopic = await _client.GetChannelAsync(_settings.Channels.General.Id) as SocketTextChannel; + if (user is not SocketGuildUser guildUser) + return; + var em = WelcomeMessage(guildUser); + if (offTopic != null && em != null) + await offTopic.SendMessageAsync(string.Empty, false, em); + } + + + public async Task DMFormattedWelcome(SocketGuildUser user) + { + var dm = await user.CreateDMChannelAsync(); + return await dm.TrySendMessage(embed: GetWelcomeEmbed(user.Username)); + } + + public Embed GetWelcomeEmbed(string username = "") + { + //TODO Generate this using Settings or some other config, hardcoded isn't ideal. + var em = new EmbedBuilder() + .WithColor(new Color(0x12D687)) + .AddField("Hello " + username, + "Welcome to Unity Developer Community!\nPlease read and respect the rules to keep the community friendly!\n*When asking questions, remember to ask your question, [don't ask to ask](https://dontasktoask.com/).*") + .AddField("__RULES__", + ":white_small_square: Be polite and respectful.\n" + + ":white_small_square: No Direct Messages to users without permission.\n" + + ":white_small_square: Do not post the same question in multiple channels.\n" + + ":white_small_square: Only post links to your games in the appropriate channels.\n" + + ":white_small_square: Some channels have additional rules, please check pinned messages.\n" + + $":white_small_square: A more inclusive list of rules can be found in {(_settings.Channels.Rules is null || _settings.Channels.Rules.Id == 0 ? "#rules" : $"<#{_settings.Channels.Rules.Id.ToString()}>")}" + ) + .AddField("__PROGRAMMING RESOURCES__", + ":white_small_square: Official Unity [Manual](https://docs.unity3d.com/Manual/index.html)\n" + + ":white_small_square: Official Unity [Script API](https://docs.unity3d.com/ScriptReference/index.html)\n" + + ":white_small_square: Introductory Tutorials: [Official Unity Tutorials](https://unity3d.com/learn/tutorials)\n" + + ":white_small_square: Intermediate Tutorials: [CatLikeCoding](https://catlikecoding.com/unity/tutorials/)\n" + ) + .AddField("__ART RESOURCES__", + ":white_small_square: Blender Beginner Tutorial [Blender Guru Donut](https://www.youtube.com/watch?v=TPrnSACiTJ4&list=PLjEaoINr3zgEq0u2MzVgAaHEBt--xLB6U&index=2)\n" + + ":white_small_square: Free Simple Assets [Kenney](https://www.kenney.nl/assets)\n" + + ":white_small_square: Game Assets [itch.io](https://itch.io/game-assets/free)" + ) + .AddField("__GAME DESIGN RESOURCES__", + ":white_small_square: How to write a Game Design Document (GDD) [Gamasutra](https://www.gamasutra.com/blogs/LeandroGonzalez/20160726/277928/How_to_Write_a_Game_Design_Document.php)\n" + + ":white_small_square: How to start building video games [CGSpectrum](https://www.cgspectrum.com/blog/game-design-basics-how-to-start-building-video-games)\n" + + ":white_small_square: Keep Things Clear: Don't Confuse Your Players [TutsPlus](https://gamedevelopment.tutsplus.com/articles/keep-things-clear-dont-confuse-your-players--cms-22780)" + ); + return (em.Build()); + } + + #endregion + + #endregion +} diff --git a/DiscordBot/Services/Tips/Components/Tip.cs b/DiscordBot/Services/Tips/Components/Tip.cs deleted file mode 100644 index bfaab3f4..00000000 --- a/DiscordBot/Services/Tips/Components/Tip.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Discord; - -namespace DiscordBot.Services.Tips.Components; - -public class Tip: IEntity -{ - public ulong Id { get; set; } - public string Content { get; set; } - public List Keywords { get; set; } - public List ImagePaths { get; set; } - public int Requests { get; set; } -} diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index d2d802a4..be480645 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text.RegularExpressions; using Discord.WebSocket; +using DiscordBot.Domain; using DiscordBot.Settings; using DiscordBot.Utils; using HtmlAgilityPack; @@ -18,19 +19,17 @@ public class UserData { public UserData() { - MutedUsers = new Dictionary(); CodeReminderCooldown = new Dictionary(); } - public Dictionary MutedUsers { get; set; } public Dictionary CodeReminderCooldown { get; set; } } public class FaqData { - public string Question { get; set; } - public string Answer { get; set; } - public string[] Keywords { get; set; } + public string Question { get; set; } = null!; + public string Answer { get; set; } = null!; + public string[] Keywords { get; set; } = null!; } public class FeedData @@ -49,29 +48,27 @@ public FeedData() public class UpdateService { private const string ServiceName = "UpdateService"; - private readonly ILoggingService _loggingService; + private readonly ILoggingService _loggingService = null!; private readonly FeedService _feedService; private readonly BotSettings _settings; private readonly CancellationToken _token; - private string[][] _apiDatabase; + private DocEntry[] _apiDatabase = null!; - private BotData _botData; - private readonly DiscordSocketClient _client; - private List _faqData; - private FeedData _feedData; + private BotData _botData = null!; + private List _faqData = null!; + private FeedData _feedData = null!; - private string[][] _manualDatabase; - private UserData _userData; + private DocEntry[] _manualDatabase = null!; + private UserData _userData = null!; - public UpdateService(DiscordSocketClient client, - DatabaseService databaseService, BotSettings settings, FeedService feedService, ILoggingService loggingService) + public UpdateService(DatabaseService databaseService, BotSettings settings, FeedService feedService, ILoggingService loggingService, + CancellationTokenSource cts) { - _client = client; _feedService = feedService; - _loggingService = loggingService as LoggingService; + _loggingService = (loggingService as LoggingService)!; _settings = settings; - _token = new CancellationToken(); + _token = cts.Token; UpdateLoop(); } @@ -90,37 +87,6 @@ private void ReadDataFromFile() _botData = SerializeUtil.DeserializeFile($"{_settings.ServerRootPath}/botdata.json"); _userData = SerializeUtil.DeserializeFile($"{_settings.ServerRootPath}/userdata.json"); - Task.Run( - async () => - { - while (_client.ConnectionState != ConnectionState.Connected || - _client.LoginState != LoginState.LoggedIn) - await Task.Delay(100, _token); - - await Task.Delay(10000, _token); - //Check if there are users still muted - foreach (var userId in _userData.MutedUsers) - { - if (!_userData.MutedUsers.HasUser(userId.Key, true)) continue; - - var guild = _client.Guilds.First(g => g.Id == _settings.GuildId); - var sgu = guild.GetUser(userId.Key); - if (sgu == null) continue; - - IGuildUser user = sgu; - - var mutedRole = user.Guild.GetRole(_settings.MutedRoleId); - //Make sure they have the muted role - if (!user.RoleIds.Contains(_settings.MutedRoleId)) await user.AddRoleAsync(mutedRole); - - //Setup delay to remove role when time is up. - await Task.Run(async () => - { - await _userData.MutedUsers.AwaitCooldown(user.Id); - await user.RemoveRoleAsync(mutedRole); - }, _token); - } - }, _token); _faqData = SerializeUtil.DeserializeFile>("Settings/FAQs.json"); _feedData = SerializeUtil.DeserializeFile($"{_settings.ServerRootPath}/feeds.json"); @@ -129,24 +95,27 @@ await Task.Run(async () => // Saves data to file private async Task SaveDataToFile() { - while (true) + try { - await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/botdata.json", _botData); - await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/userdata.json", _userData); - await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/feeds.json", _feedData); - await Task.Delay(TimeSpan.FromSeconds(20d), _token); + while (!_token.IsCancellationRequested) + { + await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/botdata.json", _botData); + await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/userdata.json", _userData); + await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/feeds.json", _feedData); + await Task.Delay(TimeSpan.FromSeconds(20d), _token); + } } - // ReSharper disable once FunctionNeverReturns + catch (OperationCanceledException) { } } - public async Task GetManualDatabase() + public async Task GetManualDatabase() { if (_manualDatabase == null) await LoadDocDatabase(); return _manualDatabase; } - public async Task GetApiDatabase() + public async Task GetApiDatabase() { if (_apiDatabase == null) await LoadDocDatabase(); @@ -161,9 +130,9 @@ private async Task LoadDocDatabase() File.Exists($"{_settings.ServerRootPath}/unityapi.json")) { var json = await File.ReadAllTextAsync($"{_settings.ServerRootPath}/unitymanual.json", _token); - _manualDatabase = JsonConvert.DeserializeObject(json); + _manualDatabase = JsonConvert.DeserializeObject(json)!; json = await File.ReadAllTextAsync($"{_settings.ServerRootPath}/unityapi.json", _token); - _apiDatabase = JsonConvert.DeserializeObject(json); + _apiDatabase = JsonConvert.DeserializeObject(json)!; } else await DownloadDocDatabase(); @@ -182,38 +151,13 @@ private async Task DownloadDocDatabase() var api = await htmlWeb.LoadFromWebAsync("https://docs.unity3d.com/ScriptReference/docdata/index.js"); var apiInput = api.DocumentNode.OuterHtml; - _manualDatabase = ConvertJsToArray(manualInput, true); - _apiDatabase = ConvertJsToArray(apiInput, false); + _manualDatabase = UnityDocParser.ConvertJsToArray(manualInput, true); + _apiDatabase = UnityDocParser.ConvertJsToArray(apiInput, false); if (!SerializeUtil.SerializeFile($"{_settings.ServerRootPath}/unitymanual.json", _manualDatabase)) await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile, $"{ServiceName}: Failed to save unitymanual.json", ExtendedLogSeverity.Warning); if (!SerializeUtil.SerializeFile($"{_settings.ServerRootPath}/unityapi.json", _apiDatabase)) await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile, $"{ServiceName}: Failed to save unityapi.json", ExtendedLogSeverity.Warning); - - string[][] ConvertJsToArray(string data, bool isManual) - { - var list = new List(); - string pagesInput; - if (isManual) - { - pagesInput = data.Split("info = [")[0].Split("pages=")[1]; - pagesInput = pagesInput.Substring(2, pagesInput.Length - 4); - } - else - { - pagesInput = data.Split("info =")[0]; - pagesInput = pagesInput.Substring(63, pagesInput.Length - 65); - } - - foreach (var s in pagesInput.Split("],[")) - { - var ps = s.Split(","); - list.Add(new[] { ps[0].Replace("\"", ""), ps[1].Replace("\"", "") }); - //Console.WriteLine(ps[0].Replace("\"", "") + "," + ps[1].Replace("\"", "")); - } - - return list.ToArray(); - } } catch (Exception e) { @@ -223,54 +167,60 @@ string[][] ConvertJsToArray(string data, bool isManual) private async Task UpdateDocDatabase() { - while (true) + try { - if (_botData.LastUnityDocDatabaseUpdate < DateTime.Now - TimeSpan.FromDays(1d)) - await DownloadDocDatabase(); + while (!_token.IsCancellationRequested) + { + if (_botData.LastUnityDocDatabaseUpdate < DateTime.Now - TimeSpan.FromDays(1d)) + await DownloadDocDatabase(); - await Task.Delay(TimeSpan.FromHours(1), _token); + await Task.Delay(TimeSpan.FromHours(1), _token); + } } - // ReSharper disable once FunctionNeverReturns + catch (OperationCanceledException) { } } private async Task UpdateRssFeeds() { - await Task.Delay(TimeSpan.FromSeconds(30d), _token); - while (true) + try { - try + await Task.Delay(TimeSpan.FromSeconds(30d), _token); + while (!_token.IsCancellationRequested) { - if (_feedData != null) + try { - if (_feedData.LastUnityReleaseCheck < DateTime.Now - TimeSpan.FromMinutes(5)) + if (_feedData != null) { - _feedData.LastUnityReleaseCheck = DateTime.Now; + if (_feedData.LastUnityReleaseCheck < DateTime.Now - TimeSpan.FromMinutes(5)) + { + _feedData.LastUnityReleaseCheck = DateTime.Now; - await _feedService.CheckUnityBetasAsync(_feedData); - await _feedService.CheckUnityReleasesAsync(_feedData); - } + await _feedService.CheckUnityBetasAsync(_feedData); + await _feedService.CheckUnityReleasesAsync(_feedData); + } - if (_feedData.LastUnityBlogCheck < DateTime.Now - TimeSpan.FromMinutes(10)) - { - _feedData.LastUnityBlogCheck = DateTime.Now; + if (_feedData.LastUnityBlogCheck < DateTime.Now - TimeSpan.FromMinutes(10)) + { + _feedData.LastUnityBlogCheck = DateTime.Now; - await _feedService.CheckUnityBlogAsync(_feedData); + await _feedService.CheckUnityBlogAsync(_feedData); + } } } - } - catch (Exception e) - { - await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile, $"{ServiceName}: Failed to update RSS feeds, attempting to continue.", ExtendedLogSeverity.Error); - } + catch (Exception e) when (e is not OperationCanceledException) + { + await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile, $"{ServiceName}: Failed to update RSS feeds, attempting to continue.", ExtendedLogSeverity.Error); + } - await Task.Delay(TimeSpan.FromSeconds(30d), _token); + await Task.Delay(TimeSpan.FromSeconds(30d), _token); + } } - // ReSharper disable once FunctionNeverReturns + catch (OperationCanceledException) { } } - public async Task<(string name, string extract, string url)> DownloadWikipediaArticle(string searchQuery) + public async Task<(string? name, string? extract, string? url)> DownloadWikipediaArticle(string searchQuery) { - var wikiSearchUri = Uri.EscapeUriString(_settings.WikipediaSearchPage + searchQuery); + var wikiSearchUri = _settings.WikipediaSearchPage + Uri.EscapeDataString(searchQuery); var htmlWeb = new HtmlWeb { CaptureRedirect = true }; HtmlDocument wikiSearchResponse; @@ -290,7 +240,9 @@ private async Task UpdateRssFeeds() if (job.TryGetValue("query", out var query)) { - var pages = JsonConvert.DeserializeObject>(job[query.Path]["pages"].ToString(), new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); + var pagesToken = job[query.Path]?["pages"]; + if (pagesToken == null) return (null, null, null); + var pages = JsonConvert.DeserializeObject>(pagesToken.ToString(), new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); if (pages != null && pages.Count > 0) { @@ -345,12 +297,12 @@ private class WikiPage public long Index { get; set; } [JsonProperty("title")] - public string Title { get; set; } + public string Title { get; set; } = null!; [JsonProperty("extract")] - public string Extract { get; set; } + public string Extract { get; set; } = null!; [JsonProperty("fullurl")] - public Uri FullUrl { get; set; } + public Uri FullUrl { get; set; } = null!; } } \ No newline at end of file diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs deleted file mode 100644 index fbfc169b..00000000 --- a/DiscordBot/Services/UserService.cs +++ /dev/null @@ -1,839 +0,0 @@ -using System.Globalization; -using System.IO; -using System.Net.Http; -using System.Text; -using System.Text.RegularExpressions; -using Discord.WebSocket; -using DiscordBot.Domain; -using DiscordBot.Settings; -using DiscordBot.Skin; -using DiscordBot.Data; -using ImageMagick; -using Newtonsoft.Json; - -namespace DiscordBot.Services; - -public class UserService -{ - private const string ServiceName = "UserService"; - - private readonly HashSet _canEditThanks; //Doesn't need to be saved - private readonly DiscordSocketClient _client; - public readonly string CodeFormattingExample; - private readonly int _codeReminderCooldownTime; - private readonly string CodeReminderFormattingExample; - private readonly DatabaseService _databaseService; - private readonly ILoggingService _loggingService; - - 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; - - private readonly List _noXpChannels; - - private readonly BotSettings _settings; - private readonly Dictionary _thanksCooldown; - private readonly Dictionary _everyoneScoldCooldown = new(); - - private readonly List<(ulong id, DateTime time)> _welcomeNoticeUsers = new(); - - private readonly int _thanksCooldownTime; - private readonly int _thanksMinJoinTime; - private readonly string _thanksRegex; - - private DateTime _mikuMentioned; - private readonly TimeSpan _mikuCooldownTime; - private readonly string _mikuRegex; - private readonly string _mikuReply; - - private readonly UpdateService _updateService; - - private readonly Dictionary _xpCooldown; - private readonly int _xpMaxCooldown; - private readonly int _xpMaxPerMessage; - private readonly int _xpMinCooldown; - - private readonly int _xpMinPerMessage; - - private readonly Random _rand; - - public Dictionary MutedUsers { get; private set; } - private readonly Color _welcomeColour = new Color(7, 84, 53); - public int WaitingWelcomeMessagesCount => _welcomeNoticeUsers.Count; - - public DateTime NextWelcomeMessage => - _welcomeNoticeUsers.Any() ? _welcomeNoticeUsers.Min(x => x.time) : DateTime.MaxValue; - - public UserService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, - UpdateService updateService, - BotSettings settings, UserSettings userSettings) - { - _client = client; - _rand = new Random(); - _databaseService = databaseService; - _loggingService = loggingService; - _updateService = updateService; - _settings = settings; - MutedUsers = new Dictionary(); - _xpCooldown = new Dictionary(); - _canEditThanks = new HashSet(32); - _thanksCooldown = new Dictionary(); - CodeReminderCooldown = new Dictionary(); - - //TODO We should make this into a config file that we can confiure during runtime. - _noXpChannels = new List - { - _settings.BotCommandsChannel.Id - }; - - /* - Init XP - */ - _xpMinPerMessage = userSettings.XpMinPerMessage; - _xpMaxPerMessage = userSettings.XpMaxPerMessage; - _xpMinCooldown = userSettings.XpMinCooldown; - _xpMaxCooldown = userSettings.XpMaxCooldown; - - /* - Init thanks - */ - var sbThanks = new StringBuilder(); - var thx = userSettings.Thanks; - sbThanks.Append(@"(?i)(? (-chan|)!"; - //_mikuReply = "Oi, mite, mite, <@427306565184389132> ! :three: :nine:"; // test - - /* - Init Code analysis - */ - _codeReminderCooldownTime = userSettings.CodeReminderCooldown; - CodeFormattingExample = @"\`\`\`cs" + Environment.NewLine + - "Write your code on new line here." + Environment.NewLine + - @"\`\`\`" + Environment.NewLine; - - CodeReminderFormattingExample = CodeFormattingExample + "*To disable these reminders use \"!disablecodetips\"*"; - - //TODO Detect double code block and tell them to use 3? Seems kinda pointless since all it provides is highlights - - _codeBlockWarnPatterns = new List(); - // Checks if there is { } in the message - _codeBlockWarnPatterns.Add(new Regex(".*?({.+?}).*?", RegexOptions.Singleline)); - // We look for (if, else if) followed by ( and ) somewhere after. We also check that the ) is end of the line, or followed by comments // - _codeBlockWarnPatterns.Add(new Regex("(if|else\\sif).?\\(.+\\).?($|\\/{2}|\\s?)", RegexOptions.Multiline)); - // Check for a method from start of line (since discord would ignore tab) and if any comments after - _codeBlockWarnPatterns.Add(new Regex("^(\\w*.\\w*)\\(\\w*?\\);($|.?($|.*?\\/{2}))", RegexOptions.Multiline)); - // Check for some collection of characters being set to some other collection of characters and check if end of line or comment. - _codeBlockWarnPatterns.Add(new Regex("^.+? =.+?($|.*?\\/\\/)", RegexOptions.Multiline)); - - /* Make sure folders we require exist */ - if (!Directory.Exists($"{_settings.ServerRootPath}/images/profiles/")) - { - Directory.CreateDirectory($"{_settings.ServerRootPath}/images/profiles/"); - } - - /* - Event subscriptions - */ - _client.MessageReceived += UpdateXp; - _client.MessageReceived += Thanks; - _client.MessageUpdated += ThanksEdited; - //_client.MessageReceived += MikuCheck; - _client.MessageReceived += CodeCheck; - _client.MessageReceived += ScoldForAtEveryoneUsage; - _client.UserJoined += UserJoined; - _client.GuildMemberUpdated += UserUpdated; - _client.UserLeft += UserLeft; - - _client.MessageReceived += CheckForWelcomeMessage; - _client.UserIsTyping += UserIsTyping; - - LoadData(); - UpdateLoop(); - - Task.Run(DelayedWelcomeService); - } - - private async Task UserLeft(SocketGuild guild, SocketUser user) - { - if (user.IsBot) return; - // Try get user, may not exist anymore since they've "left" - var guildUser = guild.GetUser(user.Id); - if (guildUser?.JoinedAt != null) - { - var joinDate = guildUser.JoinedAt.Value.Date; - - var timeStayed = DateTime.Now - joinDate; - await _loggingService.LogChannelAndFile( - $"User Left - After {(timeStayed.Days > 1 ? Math.Floor((double)timeStayed.Days) + " days" : " ")}" + - $" {Math.Floor((double)timeStayed.Hours).ToString(CultureInfo.InvariantCulture)} hours {user.Mention} - `{guildUser.GetPreferredAndUsername()}` - ID : `{user.Id}`"); - } - // If bot is to slow to get user info, we just say they left at current time. - else - { - await _loggingService.LogChannelAndFile( - $"User Left - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}` - Left at {DateTime.Now}"); - } - } - - public Dictionary CodeReminderCooldown { get; private set; } - - private async void UpdateLoop() - { - while (true) - { - await Task.Delay(10000); - SaveData(); - } - // ReSharper disable once FunctionNeverReturns - } - - private void LoadData() - { - var data = _updateService.GetUserData(); - MutedUsers = data.MutedUsers ?? new Dictionary(); - CodeReminderCooldown = data.CodeReminderCooldown ?? new Dictionary(); - } - - private void SaveData() - { - var data = new UserData - { - MutedUsers = MutedUsers, - CodeReminderCooldown = CodeReminderCooldown - }; - _updateService.SetUserData(data); - } - - public async Task UpdateXp(SocketMessage messageParam) - { - if (messageParam.Author.IsBot) - return; - - if (_noXpChannels.Contains(messageParam.Channel.Id)) - return; - - var userId = messageParam.Author.Id; - if (_xpCooldown.HasUser(userId)) - return; - - var waitTime = _rand.Next(_xpMinCooldown, _xpMaxCooldown); - float baseXp = _rand.Next(_xpMinPerMessage, _xpMaxPerMessage); - float bonusXp = 0; - - // Add Delay and delay action by 200ms to avoid some weird database collision? - _xpCooldown.AddCooldown(userId, waitTime); - Task.Run(async () => - { - var user = await _databaseService.GetOrAddUser((SocketGuildUser)messageParam.Author); - if (user == null) - return; - - bonusXp += baseXp * (1f + user.Karma / 100f); - - //Reduce XP for members with no role - if (((IGuildUser)messageParam.Author).RoleIds.Count < 2) - baseXp *= .9f; - - //Lower xp for difference between level and karma - var reduceXp = 1f; - if (user.Karma < user.Level) reduceXp = 1 - Math.Min(.9f, (user.Level - user.Karma) * .05f); - - var xpGain = (int)Math.Round((baseXp + bonusXp) * reduceXp); - - await _databaseService.Query.UpdateXp(userId.ToString(), user.Exp + (long)xpGain); - - _loggingService.LogXp(messageParam.Channel.Name, messageParam.Author.Username, baseXp, bonusXp, reduceXp, - xpGain); - - await LevelUp(messageParam, userId); - }); - } - - /// - /// Show level up message - /// - /// - /// - /// - private async Task LevelUp(SocketMessage messageParam, ulong userId) - { - var level = await _databaseService.Query.GetLevel(userId.ToString()); - var xp = await _databaseService.Query.GetXp(userId.ToString()); - - var xpHigh = GetXpHigh(level); - - if (xp < xpHigh) - return; - - await _databaseService.Query.UpdateLevel(userId.ToString(), level + 1); - - // First few levels are only a couple messages, - // so we hide them to avoid scaring people away and give them slightly longer to naturally see these in the server. - if (level <= 3) - return; - - var msg = messageParam.Author.GetUserPreferredName().ToBold() + " has leveled up!"; - await messageParam.Channel.SendMessageAsync(msg).DeleteAfterTime(60); - //TODO Add level up card - } - - private double GetXpLow(int level) => 70d - 139.5d * (level + 1d) + 69.5 * Math.Pow(level + 1d, 2d); - - private double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); - - private SkinData GetSkinData() => - JsonConvert.DeserializeObject(File.ReadAllText($"{_settings.AssetsRootPath}/skins/skin.json"), - new SkinModuleJsonConverter()); - - /// - /// Generate the profile card for a given user and returns the generated image path - /// - /// - /// - public async Task GenerateProfileCard(IUser user) - { - string profileCardPath = string.Empty; - - try - { - var dbRepo = _databaseService.Query; - if (dbRepo == null) - return profileCardPath; - - var userData = await dbRepo.GetUser(user.Id.ToString()); - - var xpTotal = userData.Exp; - var xpRank = await dbRepo.GetLevelRank(userData.UserID, userData.Level); - var karmaRank = await dbRepo.GetKarmaRank(userData.UserID, userData.Karma); - var karma = userData.Karma; - var level = userData.Level; - var xpLow = GetXpLow(level); - var xpHigh = GetXpHigh(level); - - var xpShown = (int)(xpTotal - xpLow); - var maxXpShown = (int)(xpHigh - xpLow); - - var percentage = (float)xpShown / maxXpShown; - - var u = (IGuildUser)user; - IRole mainRole = null; - foreach (var id in u.RoleIds) - { - var role = u.Guild.GetRole(id); - if (mainRole == null) - mainRole = u.Guild.GetRole(id); - else if (role.Position > mainRole.Position) mainRole = role; - } - - mainRole ??= u.Guild.EveryoneRole; - - using var profileCard = new MagickImageCollection(); - var skin = GetSkinData(); - var profile = new ProfileData - { - Karma = karma, - KarmaRank = karmaRank, - Level = level, - MainRoleColor = mainRole.Color, - MaxXpShown = maxXpShown, - Nickname = ((IGuildUser)user).Nickname, - UserId = ulong.Parse(userData.UserID), - Username = user.GetPreferredAndUsername(), - XpHigh = xpHigh, - XpLow = xpLow, - XpPercentage = percentage, - XpRank = xpRank, - XpShown = xpShown, - XpTotal = xpTotal - }; - - var background = new MagickImage($"{_settings.AssetsRootPath}/skins/{skin.Background}"); - - var avatarUrl = user.GetAvatarUrl(ImageFormat.Auto, 256); - if (string.IsNullOrEmpty(avatarUrl)) - profile.Picture = new MagickImage($"{_settings.AssetsRootPath}/images/default.png"); - else - try - { - Stream stream; - - using (var http = new HttpClient()) - { - stream = await http.GetStreamAsync(new Uri(avatarUrl)); - } - - profile.Picture = new MagickImage(stream); - } - catch (Exception e) - { - LoggingService.LogToConsole( - $"Failed to download user profile image for ProfileCard.\nEx:{e.Message}", - LogSeverity.Warning); - profile.Picture = new MagickImage($"{_settings.AssetsRootPath}/images/default.png"); - } - - profile.Picture.Resize(skin.AvatarSize, skin.AvatarSize); - profileCard.Add(background); - - foreach (var layer in skin.Layers) - { - if (layer.Image != null) - { - var image = layer.Image.ToLower() == "avatar" - ? profile.Picture - : new MagickImage($"{_settings.AssetsRootPath}/skins/{layer.Image}"); - - background.Composite(image, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); - } - - var l = new MagickImage(MagickColors.Transparent, (int)layer.Width, (int)layer.Height); - foreach (var module in layer.Modules) module.GetDrawables(profile).Draw(l); - - background.Composite(l, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); - } - - profileCardPath = $"{_settings.ServerRootPath}/images/profiles/{user.Username}-profile.png"; - - using var result = profileCard.Mosaic(); - result.Write(profileCardPath); - } - catch (Exception e) - { - await _loggingService.LogChannelAndFile($"Failed to generate profile card for {user.Username}.\nEx:{e.Message}", ExtendedLogSeverity.LowWarning); - } - - if (!string.IsNullOrEmpty(profileCardPath)) - await Task.Delay(100); - - return profileCardPath; - } - - public Embed WelcomeMessage(SocketGuildUser user) - { - string icon = user.GetAvatarUrl(); - icon = string.IsNullOrEmpty(icon) ? "https://cdn.discordapp.com/embed/avatars/0.png" : icon; - - string welcomeString = $"Welcome to Unity Developer Community, {user.GetPreferredAndUsername()}!"; - var builder = new EmbedBuilder() - .WithDescription(welcomeString) - .WithColor(_welcomeColour) - .WithAuthor(user.GetUserPreferredName(), icon); - - var embed = builder.Build(); - return embed; - } - - public int GetGatewayPing() => _client.Latency; - - #region Events - - // Message Edited Thanks - public async Task ThanksEdited(Cacheable cachedMessage, SocketMessage messageParam, - ISocketMessageChannel socketMessageChannel) - { - if (_canEditThanks.Contains(messageParam.Id)) await Thanks(messageParam); - } - - public async Task Thanks(SocketMessage messageParam) - { - //Get guild id - var channel = (SocketGuildChannel)messageParam.Channel; - var guildId = channel.Guild.Id; - - //Make sure its in the UDC server - if (guildId != _settings.GuildId) return; - - if (messageParam.Author.IsBot) - return; - var match = Regex.Match(messageParam.Content, _thanksRegex); - if (!match.Success) - return; - - var userId = messageParam.Author.Id; - var mentions = messageParam.MentionedUsers; - mentions = mentions.Distinct().Where(who => !who.IsBot && who.Id != userId).ToList(); - - const int defaultDelTime = 120; - if (mentions.Count > 0) - { - if (_thanksCooldown.HasUser(userId)) - { - await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} you must wait " + - $"{DateTime.Now - _thanksCooldown[userId]:ss} " + - "seconds before giving another karma point." + Environment.NewLine + - "(In the future, if you are trying to thank multiple people, include all their names in the thanks message.)") - .DeleteAfterTime(defaultDelTime); - return; - } - - var joinDate = ((IGuildUser)messageParam.Author).JoinedAt; - var j = joinDate + TimeSpan.FromSeconds(_thanksMinJoinTime); - if (j > DateTime.Now) - { - return; - } - - var sb = new StringBuilder(); - sb.Append(messageParam.Author.GetUserPreferredName().ToBold()); - sb.Append(" gave karma to "); - sb.Append(mentions.ToArray().ToUserPreferredNameArray().ToBoldArray().ToCommaList()); - foreach (var mention in mentions) - await _databaseService.Query.IncrementKarma(mention.Id.ToString()); - - // Even if a user gives multiple karma in one message, we only give one credit. - var authorKarmaGiven = await _databaseService.Query.GetKarmaGiven(messageParam.Author.Id.ToString()); - await _databaseService.Query.UpdateKarmaGiven(messageParam.Author.Id.ToString(), authorKarmaGiven + 1); - - sb.Append("."); - - _canEditThanks.Remove(messageParam.Id); - _thanksCooldown.AddCooldown(userId, _thanksCooldownTime); - - await messageParam.Channel.SendMessageAsync(sb.ToString()); - await _loggingService.LogChannelAndFile(sb + " in channel " + messageParam.Channel.Name); - } - - if (mentions.Count == 0 && _canEditThanks.Add(messageParam.Id)) - { - var _ = _canEditThanks.RemoveAfterSeconds(messageParam.Id, 240); - } - } - - public async Task MikuCheck(SocketMessage messageParam) - { - //Get guild id - var channel = (SocketGuildChannel)messageParam.Channel; - var guildId = channel.Guild.Id; - - //Make sure its in the UDC server - if (guildId != _settings.GuildId) return; - - if (messageParam.Author.IsBot) - return; - - var now = DateTime.Now; - if ((DateTime.Now - _mikuMentioned) < _mikuCooldownTime) - return; - - var match = Regex.Match(messageParam.Content, _mikuRegex); - if (!match.Success) - return; - - _mikuMentioned = now; - var reply = FuzzTable.Evaluate(_mikuReply); - await messageParam.Channel.SendMessageAsync(reply); - } - - public async Task CodeCheck(SocketMessage messageParam) - { - // Don't correct a Bot, don't correct in off-topic - if (messageParam.Author.IsBot || messageParam.Channel.Id == _settings.GeneralChannel.Id) - return; - - // We just ignore anything if it is under 200 characters - if (messageParam.Content.Length < 200) - return; - - var userId = messageParam.Author.Id; - - //Simple check to cover most large code posting cases without being an issue for most non-code messages - // TODO Perhaps work out a more advanced Regex based check at a later time - if (!CodeReminderCooldown.HasUser(userId)) - { - var content = messageParam.Content; - - // We have a smart cookie using ```cs so we assume they're all knowing and abort early to save cpu - var foundTrippleCodeBlock = _x3CodeBlock.Match(content); - if (foundTrippleCodeBlock.Groups["CS"].Length > 0) - return; - if (foundTrippleCodeBlock.Groups["CodeBlock"].Success) - { - // A ``` codeblock was found, but no CS, let 'em know - await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{CodeReminderFormattingExample}") - .DeleteAfterSeconds(seconds: 60); - return; - } - - // Checks get a bit more expensive from here - var foundDoubleCodeBlock = _x2CodeBlock.Match(content).Success; - - int hits = 0; - foreach (var regex in _codeBlockWarnPatterns) - { - hits += regex.Match(content).Captures.Count; - } - - // Some arbitary condition, this means 3 regex captures would be required which should easy enough to trigger without much chance for a false positive. - if (!foundDoubleCodeBlock && hits >= 3) - { - //! CodeReminderCooldown.AddCooldown(userId, _codeReminderCooldownTime); - await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} are you sharing C# scripts? Remember to use codeblocks to help readability!\n{CodeReminderFormattingExample}") - .DeleteAfterSeconds(seconds: 60); - 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); - } - } - // If we know there is a codeblock - else if (foundDoubleCodeBlock && hits > 0) - { - //! CodeReminderCooldown.AddCooldown(userId, _codeReminderCooldownTime); - 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); - } - } - } - - - private async Task ScoldForAtEveryoneUsage(SocketMessage messageParam) - { - if (messageParam.Author.IsBot || ((IGuildUser)messageParam.Author).GuildPermissions.MentionEveryone) - return; - var content = messageParam.Content; - if (content.Contains("@everyone") || content.Contains("@here")) - { - if (_everyoneScoldCooldown.ContainsKey(messageParam.Author.Id) && - _everyoneScoldCooldown[messageParam.Author.Id] > DateTime.Now) - return; - // We add to dictionary with the time it must be passed before they'll be notified again. - _everyoneScoldCooldown[messageParam.Author.Id] = - DateTime.Now.AddSeconds(_settings.EveryoneScoldPeriodSeconds); - - await messageParam.Channel.SendMessageAsync( - $"Please don't try to alert **everyone** on the server, {messageParam.Author.Mention}!\n" + - "If you are asking a question, people will help you when they have time.") - .DeleteAfterTime(minutes: 2); - } - } - - // Anything relevant to the first time someone connects to the server - - #region Welcome Service - - // If a user talks before they've been welcomed, we welcome them and remove them from the welcome list so they're not welcomes a second time. - private async Task UserIsTyping(Cacheable user, Cacheable channel) - { - if (_welcomeNoticeUsers.Count == 0) - return; - if (user.Value.IsBot) - return; - - if (_welcomeNoticeUsers.Exists(u => u.id == user.Id)) - { - _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); - await ProcessWelcomeUser(user.Id, user.Value); - } - } - - private async Task CheckForWelcomeMessage(SocketMessage messageParam) - { - if (_welcomeNoticeUsers.Count == 0) - return; - - var user = messageParam.Author; - if (user.IsBot) - return; - - if (_welcomeNoticeUsers.Exists(u => u.id == user.Id)) - { - _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); - await ProcessWelcomeUser(user.Id, user); - } - } - - private async Task UserJoined(SocketGuildUser user) - { - // Send them the Welcome DM first. - await DMFormattedWelcome(user); - - var socketTextChannel = _client.GetChannel(_settings.GeneralChannel.Id) as SocketTextChannel; - await _databaseService.GetOrAddUser(user); - - // Check if moderator commands are enabled, and if so we check if they were previously muted. - if (_settings.ModeratorCommandsEnabled) - { - if (MutedUsers.HasUser(user.Id)) - { - await user.AddRoleAsync(socketTextChannel?.Guild.GetRole(_settings.MutedRoleId)); - await _loggingService.LogChannelAndFile( - $"Currently muted user rejoined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); - if (socketTextChannel != null) - await socketTextChannel.SendMessageAsync( - $"{user.Mention} tried to rejoin the server to avoid their mute. Mute time increased by 72 hours."); - MutedUsers.AddCooldown(user.Id, hours: 72); - return; - } - } - - await _loggingService.LogChannelAndFile( - $"User Joined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); - - // We check if they're already in the welcome list, if they are we don't add them again to avoid double posts - if (_welcomeNoticeUsers.Count == 0 || !_welcomeNoticeUsers.Exists(u => u.id == user.Id)) - { - _welcomeNoticeUsers.Add((user.Id, DateTime.Now.AddSeconds(_settings.WelcomeMessageDelaySeconds))); - } - } - - // Welcomes users to the server after they've been connected for over x number of seconds. - private async Task DelayedWelcomeService() - { - ulong currentlyProcessedUserId = 0; - bool firstRun = true; - await Task.Delay(10000); - try - { - List toRemove = new(); - while (true) - { - var now = DateTime.Now; - // This could be optimized, however the users in this list won't ever really be large enough to matter. - // We loop through our list, anyone that has been in the list for more than x seconds is welcomed. - foreach (var userData in _welcomeNoticeUsers.Where(u => u.time < now)) - { - currentlyProcessedUserId = userData.id; - await ProcessWelcomeUser(userData.id, null); - - toRemove.Add(userData.id); - } - - // Remove all the users we've welcomed from the list - if (toRemove.Count > 0) - { - _welcomeNoticeUsers.RemoveAll(u => toRemove.Contains(u.id)); - toRemove.Clear(); - // Prevent the list from growing too large, not that it really matters. - if (toRemove.Capacity > 20) - { - toRemove.Capacity = 20; - } - } - - if (firstRun) - firstRun = false; - await Task.Delay(10000); - } - } - catch (Exception e) - { - // Catch and show exception - await _loggingService.LogChannelAndFile($"{ServiceName} Exception during welcome message `{currentlyProcessedUserId}`.\n{e.Message}.", ExtendedLogSeverity.Warning); - - // Remove the offending user from the dictionary and run the service again. - _welcomeNoticeUsers.RemoveAll(u => u.id == currentlyProcessedUserId); - if (_welcomeNoticeUsers.Count > 200) - { - _welcomeNoticeUsers.Clear(); - await _loggingService.LogAction($"{ServiceName}: Welcome list cleared due to size (+200), this should not happen.", ExtendedLogSeverity.Error); - } - - if (firstRun) - await _loggingService.LogAction($"{ServiceName}: Welcome service failed on first run!? This should not happen.", ExtendedLogSeverity.Error); - - // Run the service again. - Task.Run(DelayedWelcomeService); - } - } - - private async Task ProcessWelcomeUser(ulong userID, IUser user = null) - { - if (_welcomeNoticeUsers.Exists(u => u.id == userID)) - // If we didn't get the user passed in, we try grab it - user ??= await _client.GetUserAsync(userID); - // if they're null, they've likely left, so we just remove them from the list. - if (user == null) - return; - - var offTopic = await _client.GetChannelAsync(_settings.GeneralChannel.Id) as SocketTextChannel; - if (user is not SocketGuildUser guildUser) - return; - var em = WelcomeMessage(guildUser); - if (offTopic != null && em != null) - await offTopic.SendMessageAsync(string.Empty, false, em); - } - - - public async Task DMFormattedWelcome(SocketGuildUser user) - { - var dm = await user.CreateDMChannelAsync(); - return await dm.TrySendMessage(embed: GetWelcomeEmbed(user.Username)); - } - - public Embed GetWelcomeEmbed(string username = "") - { - //TODO Generate this using Settings or some other config, hardcoded isn't ideal. - var em = new EmbedBuilder() - .WithColor(new Color(0x12D687)) - .AddField("Hello " + username, - "Welcome to Unity Developer Community!\nPlease read and respect the rules to keep the community friendly!\n*When asking questions, remember to ask your question, [don't ask to ask](https://dontasktoask.com/).*") - .AddField("__RULES__", - ":white_small_square: Be polite and respectful.\n" + - ":white_small_square: No Direct Messages to users without permission.\n" + - ":white_small_square: Do not post the same question in multiple channels.\n" + - ":white_small_square: Only post links to your games in the appropriate channels.\n" + - ":white_small_square: Some channels have additional rules, please check pinned messages.\n" + - $":white_small_square: A more inclusive list of rules can be found in {(_settings.RulesChannel is null || _settings.RulesChannel.Id == 0 ? "#rules" : $"<#{_settings.RulesChannel.Id.ToString()}>")}" - ) - .AddField("__PROGRAMMING RESOURCES__", - ":white_small_square: Official Unity [Manual](https://docs.unity3d.com/Manual/index.html)\n" + - ":white_small_square: Official Unity [Script API](https://docs.unity3d.com/ScriptReference/index.html)\n" + - ":white_small_square: Introductory Tutorials: [Official Unity Tutorials](https://unity3d.com/learn/tutorials)\n" + - ":white_small_square: Intermediate Tutorials: [CatLikeCoding](https://catlikecoding.com/unity/tutorials/)\n" - ) - .AddField("__ART RESOURCES__", - ":white_small_square: Blender Beginner Tutorial [Blender Guru Donut](https://www.youtube.com/watch?v=TPrnSACiTJ4&list=PLjEaoINr3zgEq0u2MzVgAaHEBt--xLB6U&index=2)\n" + - ":white_small_square: Free Simple Assets [Kenney](https://www.kenney.nl/assets)\n" + - ":white_small_square: Game Assets [itch.io](https://itch.io/game-assets/free)" - ) - .AddField("__GAME DESIGN RESOURCES__", - ":white_small_square: How to write a Game Design Document (GDD) [Gamasutra](https://www.gamasutra.com/blogs/LeandroGonzalez/20160726/277928/How_to_Write_a_Game_Design_Document.php)\n" + - ":white_small_square: How to start building video games [CGSpectrum](https://www.cgspectrum.com/blog/game-design-basics-how-to-start-building-video-games)\n" + - ":white_small_square: Keep Things Clear: Don't Confuse Your Players [TutsPlus](https://gamedevelopment.tutsplus.com/articles/keep-things-clear-dont-confuse-your-players--cms-22780)" - ); - return (em.Build()); - } - - #endregion - - private async Task UserUpdated(Cacheable oldUserCached, SocketGuildUser user) - { - var oldUser = await oldUserCached.GetOrDownloadAsync(); - if (oldUser.Nickname != user.Nickname) - { - await _loggingService.LogChannelAndFile( - $"User {oldUser.GetUserPreferredName()} changed his " + - $"username to {user.GetUserPreferredName()}"); - } - } - - #endregion -} diff --git a/DiscordBot/Services/AirportService.cs b/DiscordBot/Services/Utils/AirportService.cs similarity index 60% rename from DiscordBot/Services/AirportService.cs rename to DiscordBot/Services/Utils/AirportService.cs index 18f14281..92816b4a 100644 --- a/DiscordBot/Services/AirportService.cs +++ b/DiscordBot/Services/Utils/AirportService.cs @@ -1,18 +1,14 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text; -using Discord.WebSocket; using DiscordBot.Settings; using DiscordBot.Utils; using Newtonsoft.Json; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Utils; public class AirportService { - private readonly DiscordSocketClient _client; - private readonly ILoggingService _loggingService; - #region Amadeus private readonly string _flightApiKey; @@ -26,118 +22,118 @@ public class AirportService private const string CheapestRouteParam = "?originLocationCode={0}&destinationLocationCode={1}&departureDate={2}&adults=1&nonStop=false&max=5¤cyCode=USD"; #region Return Results - + public class AmadeusRoot { - public List data { get; set; } + public List data { get; set; } = []; } - + public class FlightInfo { - public string type { get; set; } - public string id { get; set; } - public string source { get; set; } + public string type { get; set; } = string.Empty; + public string id { get; set; } = string.Empty; + public string source { get; set; } = string.Empty; public bool instantTicketingRequired { get; set; } public bool nonHomogeneous { get; set; } public bool oneWay { get; set; } - public string lastTicketingDate { get; set; } + public string lastTicketingDate { get; set; } = string.Empty; public int numberOfBookableSeats { get; set; } - public List itineraries { get; set; } - public Price price { get; set; } - public PricingOptions pricingOptions { get; set; } - public List validatingAirlineCodes { get; set; } + public List itineraries { get; set; } = []; + public Price price { get; set; } = null!; + public PricingOptions pricingOptions { get; set; } = null!; + public List validatingAirlineCodes { get; set; } = []; // public List travelerPricings { get; set; } } - + public class PricingOptions { - public List fareType { get; set; } + public List fareType { get; set; } = []; public bool includedCheckedBagsOnly { get; set; } } - + public class Price { - public string currency { get; set; } - public string total { get; set; } - public string @base { get; set; } - public List fees { get; set; } - public string grandTotal { get; set; } + public string currency { get; set; } = string.Empty; + public string total { get; set; } = string.Empty; + public string @base { get; set; } = string.Empty; + public List fees { get; set; } = []; + public string grandTotal { get; set; } = string.Empty; public double GrandTotalNumber() { return double.TryParse(grandTotal, out double result) ? result : double.MinValue; } } - + public class Fee { - public string amount { get; set; } - public string type { get; set; } + public string amount { get; set; } = string.Empty; + public string type { get; set; } = string.Empty; } - + public class Itinerary { - public string duration { get; set; } - public List segments { get; set; } + public string duration { get; set; } = string.Empty; + public List segments { get; set; } = []; } - + public class Segment { - public FlightDetails departure { get; set; } - public FlightDetails arrival { get; set; } - public string carrierCode { get; set; } - public string number { get; set; } + public FlightDetails departure { get; set; } = null!; + public FlightDetails arrival { get; set; } = null!; + public string carrierCode { get; set; } = string.Empty; + public string number { get; set; } = string.Empty; // public Aircraft aircraft { get; set; } // public Operating operating { get; set; } - public string duration { get; set; } - public string id { get; set; } + public string duration { get; set; } = string.Empty; + public string id { get; set; } = string.Empty; public int numberOfStops { get; set; } public bool blacklistedInEU { get; set; } } - + public class FlightDetails { - public string iataCode { get; set; } + public string iataCode { get; set; } = string.Empty; public DateTime at { get; set; } } - + public class AmadeusAuthRoot { - public string type { get; set; } - public string username { get; set; } - public string application_name { get; set; } - public string client_id { get; set; } - public string token_type { get; set; } - public string access_token { get; set; } + public string type { get; set; } = string.Empty; + public string username { get; set; } = string.Empty; + public string application_name { get; set; } = string.Empty; + public string client_id { get; set; } = string.Empty; + public string token_type { get; set; } = string.Empty; + public string access_token { get; set; } = string.Empty; public int expires_in { get; set; } - public string state { get; set; } - public string scope { get; set; } + public string state { get; set; } = string.Empty; + public string scope { get; set; } = string.Empty; } #endregion // Return Results - + #endregion // Amadeus #region AirLabs - + private string _airLabsNearbyCityRoute = "https://airlabs.co/api/v9/nearby?lat={0}&lng={1}&distance=100"; private string _airLabsAPIInclude = "&api_key={0}"; private string _airLabsAPIRequiredFields = "&_fields=iata_code"; - + #region Return Results - + public class AirLabsAirport { - public string icao_code { get; set; } - public string country_code { get; set; } - public string iata_code { get; set; } + public string icao_code { get; set; } = string.Empty; + public string country_code { get; set; } = string.Empty; + public string iata_code { get; set; } = string.Empty; public double lng { get; set; } - public string city { get; set; } - public string timezone { get; set; } - public string name { get; set; } - public string city_code { get; set; } - public string slug { get; set; } + public string city { get; set; } = string.Empty; + public string timezone { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; + public string city_code { get; set; } = string.Empty; + public string slug { get; set; } = string.Empty; public double lat { get; set; } public int popularity { get; set; } public double distance { get; set; } @@ -145,12 +141,12 @@ public class AirLabsAirport public class AirLabsCity { - public string country_code { get; set; } + public string country_code { get; set; } = string.Empty; public double lng { get; set; } - public string timezone { get; set; } - public string name { get; set; } - public string city_code { get; set; } - public string slug { get; set; } + public string timezone { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; + public string city_code { get; set; } = string.Empty; + public string slug { get; set; } = string.Empty; public double lat { get; set; } public int popularity { get; set; } public double distance { get; set; } @@ -158,47 +154,41 @@ public class AirLabsCity public class AirLabsRoot { - public List airports { get; set; } - public List cities { get; set; } + public List airports { get; set; } = []; + public List cities { get; set; } = []; } public class AirLabsSuperRoot { - public AirLabsRoot response { get; set; } + public AirLabsRoot response { get; set; } = null!; } #endregion // Return Results - + #endregion // AirLabs - public AirportService(DiscordSocketClient client, ILoggingService loggingService, BotSettings botSettings) + public AirportService(BotSettings botSettings) { - _client = client; - _loggingService = loggingService; - _flightApiKey = botSettings.FlightAPIKey; - _flightSecret = botSettings.FlightAPISecret; - - _airLabsAPIInclude = string.Format(_airLabsAPIInclude, botSettings.AirLabAPIKey); + _flightApiKey = botSettings.ApiKeys.Flight; + _flightSecret = botSettings.ApiKeys.FlightSecret; + + _airLabsAPIInclude = string.Format(_airLabsAPIInclude, botSettings.ApiKeys.AirLab); _airLabsNearbyCityRoute += _airLabsAPIInclude + _airLabsAPIRequiredFields; } - public async Task GetClosestAirport(double lat, double lng) + public async Task GetClosestAirport(double lat, double lng) { var url = string.Format(_airLabsNearbyCityRoute, lat, lng); var result = await SerializeUtil.LoadUrlDeserializeResult(url); - + if (result?.response?.airports == null) + return null; + // Sort by popularity result.response.airports.Sort((a, b) => b.popularity.CompareTo(a.popularity)); // Return first Airport that has a IATA code return result.response.airports.FirstOrDefault(a => !string.IsNullOrEmpty(a.iata_code)); } - - public async Task GetFlightTickets(string from, string to) - { - - return null; - } #region Utility Methods @@ -206,42 +196,42 @@ public async Task GetValidationToken() { if (_amadeusTokenExpiration > DateTime.Now) return true; - + var url = "https://test.api.amadeus.com/v1/security/oauth2/token"; var data = "grant_type=client_credentials&client_id=" + _flightApiKey + "&client_secret=" + _flightSecret; - + HttpClient client = new(); var response = await client.PostAsync(url, new StringContent(data, Encoding.UTF8, "application/x-www-form-urlencoded")); if (!response.IsSuccessStatusCode) return false; - + var result = await response.Content.ReadAsStringAsync(); var authRoot = JsonConvert.DeserializeObject(result); if (authRoot == null) return false; - + _amadeusToken = authRoot.access_token; _amadeusTokenExpiration = DateTime.Now.AddSeconds(authRoot.expires_in - 1); return true; } - - public async Task> GetFlightInfo(string from, string to, int daysFromNow = 2) + + public async Task?> GetFlightInfo(string from, string to, int daysFromNow = 2) { if (!await GetValidationToken()) return null; - + var url = BaseRoute + FindCheapestRoute + string.Format(CheapestRouteParam, from, to, DateTime.Now.AddDays(daysFromNow).ToString("yyyy-MM-dd")); - + HttpClient client = new(); HttpRequestHeaders headers = client.DefaultRequestHeaders; headers.Add("Authorization", "Bearer " + _amadeusToken); - + var response = await client.GetAsync(url); if (!response.IsSuccessStatusCode) return null; - + var result = await response.Content.ReadAsStringAsync(); var root = JsonConvert.DeserializeObject(result); if (root == null) return null; - + root.data.Sort((a, b) => b.price.GrandTotalNumber().CompareTo(a.price.GrandTotalNumber())); return root.data; } diff --git a/DiscordBot/Services/CurrencyService.cs b/DiscordBot/Services/Utils/CurrencyService.cs similarity index 78% rename from DiscordBot/Services/CurrencyService.cs rename to DiscordBot/Services/Utils/CurrencyService.cs index d274f5e2..770fffe1 100644 --- a/DiscordBot/Services/CurrencyService.cs +++ b/DiscordBot/Services/Utils/CurrencyService.cs @@ -1,51 +1,57 @@ -using DiscordBot.Utils; +using DiscordBot.Utils; using Newtonsoft.Json.Linq; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Utils; public class CurrencyService { private const string ServiceName = "CurrencyService"; - + private readonly IWebClient _webClient; + #region Configuration private const int ApiVersion = 1; private const string TargetDate = "latest"; private const string ValidCurrenciesEndpoint = "currencies.min.json"; private const string ExchangeRatesEndpoint = "currencies"; - + private class Currency { - public string Name { get; set; } - public string Short { get; set; } + public string Name { get; set; } = null!; + public string Short { get; set; } = null!; } #endregion // Configuration - + private readonly Dictionary _currencies = new(); private static readonly string ApiUrl = $"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{TargetDate}/v{ApiVersion}/"; + public CurrencyService(IWebClient webClient) + { + _webClient = webClient; + } + public async Task GetConversion(string toCurrency, string fromCurrency = "usd") { toCurrency = toCurrency.ToLower(); fromCurrency = fromCurrency.ToLower(); - + var url = $"{ApiUrl}{ExchangeRatesEndpoint}/{fromCurrency.ToLower()}.min.json"; - + // Check if success - var (success, response) = await WebUtil.TryGetObjectFromJson(url); + var (success, response) = await _webClient.TryGetObjectFromJson(url); if (!success) return -1; - + // json[fromCurrency][toCurrency] - var value = response.SelectToken($"{fromCurrency}.{toCurrency}"); + var value = response?.SelectToken($"{fromCurrency}.{toCurrency}"); if (value == null) return -1; - + return value.Value(); } - + #region Public Methods public async Task GetCurrencyName(string currency) @@ -59,7 +65,7 @@ public async Task GetCurrencyName(string currency) // Checks if a provided currency is valid, it also checks is we have a list of currencies to check against and rebuilds it if not. (If the API was down when bot started) public async Task IsCurrency(string currency) { - if (_currencies.Count <= 1) + if (_currencies.Count <= 1) await BuildCurrencyList(); return _currencies.ContainsKey(currency); } @@ -71,8 +77,10 @@ public async Task IsCurrency(string currency) private async Task BuildCurrencyList() { var url = ApiUrl + ValidCurrenciesEndpoint; - var currencies = await WebUtil.GetObjectFromJson>(url); - + var currencies = await _webClient.GetObjectFromJson>(url); + if (currencies == null) + return; + // Json is weird format of `Code: Name` each in dependant ie; {"1inch":"1inch Network","aave":"Aave"} foreach (var currency in currencies) { @@ -82,7 +90,7 @@ private async Task BuildCurrencyList() Short = currency.Key }); } - + LoggingService.LogToConsole($"[{ServiceName}] Built currency list with {_currencies.Count} currencies.", ExtendedLogSeverity.Positive); } diff --git a/DiscordBot/Services/Utils/SearchService.cs b/DiscordBot/Services/Utils/SearchService.cs new file mode 100644 index 00000000..5ea87e54 --- /dev/null +++ b/DiscordBot/Services/Utils/SearchService.cs @@ -0,0 +1,118 @@ +using System.Net; +using DiscordBot.Domain; +using HtmlAgilityPack; + +namespace DiscordBot.Services.Utils; + +public class SearchService +{ + public record SearchResult(string Title, string Url); + + public record DocSearchResult(string PageName, string Title, string BaseUrl, string? Description = null); + + public List SearchDuckDuckGo(string query, uint maxResults = 3, string site = "") + { + maxResults = maxResults <= 5 ? maxResults : 5; + var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+'); + if (site != string.Empty) searchQuery += "+site:" + site; + + var doc = new HtmlWeb().Load(searchQuery); + var results = new List(); + + var nodes = doc.DocumentNode.SelectNodes("/html/body/div[1]/div[3]/div/div/div[*]/div/h2/a"); + if (nodes == null) return results; + + foreach (var row in nodes) + { + if (results.Count >= maxResults) break; + + row.Attributes["href"].Value = row.Attributes["href"].Value + .Replace("//duckduckgo.com/l/?uddg=", string.Empty); + + if (row.Attributes["href"].Value.Contains("duckduckgo.com") || + row.Attributes["href"].Value.Contains("duck.co")) + continue; + + var url = WebUtility.UrlDecode(row.Attributes["href"].Value); + int andCount = url.Count(c => c == '&'); + url = url[..url.LastIndexOf('&')]; + + var title = row.InnerText.Length > 60 ? $"{row.InnerText[..60]}.." : row.InnerText; + results.Add(new SearchResult(title, url + (andCount > 1 ? "~" : string.Empty))); + } + + return results; + } + + public DocSearchResult? FindBestMatch(string query, DocEntry[] database, string baseUrl) + { + var minimumScore = double.MaxValue; + DocEntry? mostSimilarPage = null; + + foreach (var p in database) + { + var curScore = CalculateScore(p.Title, query); + if (curScore < minimumScore) + { + minimumScore = curScore; + mostSimilarPage = p; + } + } + + if (mostSimilarPage == null) return null; + return new DocSearchResult(mostSimilarPage.PageName, mostSimilarPage.Title, baseUrl); + } + + public string? FetchPageDescription(string url, string descriptionXPath, string? nextSiblingFilter = null) + { + var doc = new HtmlWeb().Load(url); + var node = doc.DocumentNode.SelectSingleNode(descriptionXPath); + if (node == null) return null; + + if (nextSiblingFilter != null) + node = node.SelectSingleNode(nextSiblingFilter); + + node?.Descendants() + .Where(n => n.GetAttributeValue("class", "").Contains("tooltip")) + .ToList() + .ForEach(n => n.Remove()); + + var text = node?.InnerText; + if (text != null && text.Length > 500) + text = $"{text[..500]}.."; + + return text; + } + + public string? FetchManualLink(string url) + { + var doc = new HtmlWeb().Load(url); + var manualLink = doc.DocumentNode.SelectSingleNode("//a[contains(@class, 'switch-link')]"); + if (manualLink == null || !manualLink.Attributes.Contains("title")) return null; + + var text = manualLink.GetAttributes("title").First().Value; + var linkUrl = "https://docs.unity3d.com/" + manualLink.GetAttributeValue("href", ""); + return $"[{text}]({linkUrl})"; + } + + 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; + } +} diff --git a/DiscordBot/Services/WeatherService.cs b/DiscordBot/Services/Utils/Weather/WeatherService.cs similarity index 65% rename from DiscordBot/Services/WeatherService.cs rename to DiscordBot/Services/Utils/Weather/WeatherService.cs index 4c9190a5..55e83e04 100644 --- a/DiscordBot/Services/WeatherService.cs +++ b/DiscordBot/Services/Utils/Weather/WeatherService.cs @@ -1,44 +1,41 @@ using Discord.WebSocket; using DiscordBot.Settings; using DiscordBot.Utils; -using DiscordBot.Modules.Weather; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Utils.Weather; public class WeatherService { private const string ServiceName = "FeedService"; - - private readonly DiscordSocketClient _client; + private readonly ILoggingService _loggingService; private readonly string _weatherApiKey; - public WeatherService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) + public WeatherService(ILoggingService loggingService, BotSettings settings) { - _client = client; _loggingService = loggingService; - _weatherApiKey = settings.WeatherAPIKey; + _weatherApiKey = settings.ApiKeys.Weather; if (string.IsNullOrWhiteSpace(_weatherApiKey)) { _loggingService.LogAction($"[{ServiceName}] Error: Weather API Key is not set.", ExtendedLogSeverity.Warning); } } - - - public async Task GetWeather(string city, string unit = "metric") + + + public async Task GetWeather(string city, string unit = "metric") { var query = $"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={_weatherApiKey}&units={unit}"; return await SerializeUtil.LoadUrlDeserializeResult(query); } - public async Task GetPollution(double lon, double lat) + public async Task GetPollution(double lon, double lat) { var query = $"https://api.openweathermap.org/data/2.5/air_pollution?lat={lat}&lon={lon}&appid={_weatherApiKey}"; return await SerializeUtil.LoadUrlDeserializeResult(query); } - - public async Task<(bool exists, WeatherContainer.Result result)> CityExists(string city) + + public async Task<(bool exists, WeatherContainer.Result? result)> CityExists(string city) { var res = await GetWeather(city: city); var exists = !object.Equals(res, default(WeatherContainer.Result)); diff --git a/DiscordBot/Settings/Deserialized/Rules.cs b/DiscordBot/Settings/Deserialized/Rules.cs index 8acf1938..8bf9840c 100644 --- a/DiscordBot/Settings/Deserialized/Rules.cs +++ b/DiscordBot/Settings/Deserialized/Rules.cs @@ -2,12 +2,12 @@ public class Rules { - public List Channel { get; set; } + public List Channel { get; set; } = []; } public class ChannelData { public ulong Id { get; set; } - public string Header { get; set; } - public string Content { get; set; } + public string Header { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; } \ No newline at end of file diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 9b6be99e..6e9ea102 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -2,179 +2,168 @@ namespace DiscordBot.Settings; public class BotSettings { - #region Important Settings - - public string Token { get; set; } - public string Invite { get; set; } - - public string DbConnectionString { get; set; } - public string ServerRootPath { get; set; } + public string Token { get; set; } = string.Empty; + public string Invite { get; set; } = string.Empty; + public string DbConnectionString { get; set; } = string.Empty; + public string ServerRootPath { get; set; } = string.Empty; public string AssetsRootPath { get; set; } = "./Assets"; public char Prefix { get; set; } public ulong GuildId { get; set; } public bool LogCommandExecutions { get; set; } = true; - - #endregion // Important - - #region Configuration - public int WelcomeMessageDelaySeconds { get; set; } = 300; - public bool ModeratorCommandsEnabled { get; set; } - public bool ModeratorNoInviteLinks { get; set; } - // How long between when the bot will scold a user for trying to ping everyone. Default 6 hours public ulong EveryoneScoldPeriodSeconds { get; set; } = 21600; + public string WikipediaSearchPage { get; set; } = string.Empty; + + public ChannelSettings Channels { get; set; } = new(); + public RoleSettings Roles { get; set; } = new(); + public RecruitmentSettings Recruitment { get; set; } = new(); + public UnityHelpSettings UnityHelp { get; set; } = new(); + public CasinoSettings Casino { get; set; } = new(); + public BirthdaySettings Birthday { get; set; } = new(); + public ApiKeySettings ApiKeys { get; set; } = new(); + public FunCommandSettings FunCommands { get; set; } = new(); + + public (List Errors, List Warnings) Validate() + { + var errors = new List(); + var warnings = new List(); + + if (string.IsNullOrWhiteSpace(Token)) + errors.Add("Token is not configured — bot cannot authenticate"); + if (GuildId == 0) + errors.Add("GuildId is not configured"); + if (Prefix == '\0') + errors.Add("Prefix is not configured"); + if (string.IsNullOrWhiteSpace(DbConnectionString)) + errors.Add("DbConnectionString is not configured — database features will fail"); + + if (string.IsNullOrWhiteSpace(ServerRootPath)) + warnings.Add("ServerRootPath is empty — runtime data storage may fail"); + + ValidateChannel(warnings, Channels.General, nameof(Channels.General)); + ValidateChannel(warnings, Channels.Introduction, nameof(Channels.Introduction)); + ValidateChannel(warnings, Channels.BotAnnouncement, nameof(Channels.BotAnnouncement)); + ValidateChannel(warnings, Channels.BotCommands, nameof(Channels.BotCommands)); + ValidateChannel(warnings, Channels.UnityNews, nameof(Channels.UnityNews)); + ValidateChannel(warnings, Channels.UnityReleases, nameof(Channels.UnityReleases)); + ValidateChannel(warnings, Channels.Rules, nameof(Channels.Rules)); + + if (Birthday.Enabled) + ValidateChannel(warnings, Channels.BirthdayAnnouncement, nameof(Channels.BirthdayAnnouncement)); + + if (Recruitment.Enabled) + { + ValidateChannel(warnings, Channels.Recruitment, nameof(Channels.Recruitment)); + if (string.IsNullOrWhiteSpace(Recruitment.TagLookingToHire)) + warnings.Add("Recruitment enabled but TagLookingToHire is empty"); + if (string.IsNullOrWhiteSpace(Recruitment.TagLookingForWork)) + warnings.Add("Recruitment enabled but TagLookingForWork is empty"); + if (string.IsNullOrWhiteSpace(Recruitment.TagUnpaidCollab)) + warnings.Add("Recruitment enabled but TagUnpaidCollab is empty"); + if (string.IsNullOrWhiteSpace(Recruitment.TagPositionFilled)) + warnings.Add("Recruitment enabled but TagPositionFilled is empty"); + } + + if (UnityHelp.BabySitterEnabled) + { + ValidateChannel(warnings, Channels.GenericHelp, nameof(Channels.GenericHelp)); + if (string.IsNullOrWhiteSpace(UnityHelp.TagResolved)) + warnings.Add("UnityHelp BabySitter enabled but TagResolved is empty"); + } + + if (Casino.Enabled && Casino.StartingTokens < 0) + errors.Add("Casino.StartingTokens is negative"); + + return (errors, warnings); + } + + private static void ValidateChannel(List warnings, ChannelInfo? channel, string name) + { + if (channel is null || channel.Id == 0) + warnings.Add($"{name} is not configured (null or Id=0)"); + } +} - #region Fun Commands - - public string UserModuleSlapObjectsTable { get; set; } = null; - //NOTE: Deserializer will not override a List from the json if a default one is made here. - public List UserModuleSlapChoices { get; set; } - // = { "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", - // "life choice", "bug report", "unhandled exception", "null pointer", "keyboard", - // "cheese wheel", "banana peel", "unresolved bug", "low poly donut" }; - public List UserModuleSlapFails { get; set; } - // = { "hurting themselves" }; - - #endregion // Fun Commands - - #region Service Enabling - // Used for enabling/disabling services in the bot - - public bool RecruitmentServiceEnabled { get; set; } = false; - public bool UnityHelpBabySitterEnabled { get; set; } = false; - public bool IntroductionWatcherServiceEnabled { get; set; } = false; - - #endregion // Service Enabling - - #region Birthday Announcements - - public bool BirthdayAnnouncementEnabled { get; set; } = true; - public int BirthdayCheckIntervalMinutes { get; set; } = 240; // Check every 4 hours by default - public ChannelInfo BirthdayAnnouncementChannel { get; set; } - - #endregion // Birthday Announcements - - #endregion // Configuration - - #region Channels - - public ChannelInfo IntroductionChannel { get; set; } - public ChannelInfo GeneralChannel { get; set; } - public ChannelInfo GenericHelpChannel { get; set; } - - public ChannelInfo BotAnnouncementChannel { get; set; } - public ChannelInfo BotCommandsChannel { get; set; } - public ChannelInfo UnityNewsChannel { get; set; } - public ChannelInfo UnityReleasesChannel { get; set; } - public ChannelInfo RulesChannel { get; set; } - - // Recruitment Channels - - public ChannelInfo RecruitmentChannel { get; set; } - - public ChannelInfo ReportedMessageChannel { get; set; } - - public ChannelInfo MemeChannel { get; set; } - - #region Complaint Channel +public class ChannelSettings +{ + public ChannelInfo Introduction { get; set; } = null!; + public ChannelInfo General { get; set; } = null!; + public ChannelInfo GenericHelp { get; set; } = null!; + public ChannelInfo BotAnnouncement { get; set; } = null!; + public ChannelInfo BotCommands { get; set; } = null!; + public ChannelInfo UnityNews { get; set; } = null!; + public ChannelInfo UnityReleases { get; set; } = null!; + public ChannelInfo Rules { get; set; } = null!; + public ChannelInfo Recruitment { get; set; } = null!; + public ChannelInfo Meme { get; set; } = null!; + public ChannelInfo BirthdayAnnouncement { get; set; } = null!; public ulong ComplaintCategoryId { get; set; } - public string ComplaintChannelPrefix { get; set; } + public string ComplaintPrefix { get; set; } = string.Empty; public ulong ClosedComplaintCategoryId { get; set; } - public string ClosedComplaintChannelPrefix { get; set; } - - #endregion // Complaint Channel - - #endregion // Channels - - #region User Roles - - public RoleGroup UserAssignableRoles { get; set; } - public ulong MutedRoleId { get; set; } - public ulong SubsReleasesRoleId { get; set; } - public ulong SubsNewsRoleId { get; set; } - public ulong ModeratorRoleId { get; set; } - public ulong TipsUserRoleId { get; set; } // e.g., Helpers - - #endregion // User Roles - - #region Recruitment Thread + public string ClosedComplaintPrefix { get; set; } = string.Empty; +} - public string TagLookingToHire { get; set; } - public string TagLookingForWork { get; set; } - public string TagUnpaidCollab { get; set; } - public string TagPositionFilled { get; set; } +public class RoleSettings +{ + public ulong SubsReleases { get; set; } + public ulong SubsNews { get; set; } + public ulong Moderator { get; set; } + public ulong TipsUser { get; set; } +} +public class RecruitmentSettings +{ + public bool Enabled { get; set; } = false; + public string TagLookingToHire { get; set; } = string.Empty; + public string TagLookingForWork { get; set; } = string.Empty; + public string TagUnpaidCollab { get; set; } = string.Empty; + public string TagPositionFilled { get; set; } = string.Empty; public int EditPermissionAccessTimeMin { get; set; } = 3; +} - #endregion // Recruitment Thread Tags - - #region Unity Help Threads - - #region Tips - - public string TipImageDirectory { get; set; } - - public int TipMaxImageFileSize { get; set; } = 1024 * 1024 * 10; // 10MB - // Unlikely, but we prevent exploitation by limiting the max directory size to avoid VPS disk space issues - public int TipMaxDirectoryFileSize { get; set; } = 1024 * 1024 * 1024; // 1GB - - #endregion // Tips - - public string TagUnitHelpResolvedTag { get; set; } - - #endregion // Unity Help Threads - - #region API Keys - - public string WeatherAPIKey { get; set; } - - public string FlightAPIKey { get; set; } - public string FlightAPISecret { get; set; } - - public string AirLabAPIKey { get; set; } - - #endregion // API Keys - - #region Casino Settings - - public bool CasinoEnabled { get; set; } = true; - public long CasinoStartingTokens { get; set; } = 1000; - public List CasinoAllowedChannels { get; set; } = new List(); - public int CasinoGameTimeoutMinutes { get; set; } = 5; - - // Daily Reward Settings - public long CasinoDailyRewardTokens { get; set; } = 100; - public int CasinoDailyRewardIntervalSeconds { get; set; } = 86400; // 24 hours = 86400 seconds - - #endregion // Casino Settings - - #region Other - - public string WikipediaSearchPage { get; set; } - - #endregion // Other - +public class UnityHelpSettings +{ + public bool BabySitterEnabled { get; set; } = false; + public string TagResolved { get; set; } = string.Empty; + public string TipImageDirectory { get; set; } = string.Empty; + public int TipMaxImageFileSize { get; set; } = 1024 * 1024 * 10; + public int TipMaxDirectoryFileSize { get; set; } = 1024 * 1024 * 1024; } -#region Role Group Collections +public class CasinoSettings +{ + public bool Enabled { get; set; } = true; + public long StartingTokens { get; set; } = 1000; + public List AllowedChannels { get; set; } = new(); + public int GameTimeoutMinutes { get; set; } = 5; + public long DailyRewardTokens { get; set; } = 100; + public int DailyRewardIntervalSeconds { get; set; } = 86400; +} -// Classes used to hold information regarding a collection of role ids with a description. -public class RoleGroup +public class BirthdaySettings { - public string Desc { get; set; } - public List Roles { get; set; } + public bool Enabled { get; set; } = true; + public int CheckIntervalMinutes { get; set; } = 240; } -#endregion +public class ApiKeySettings +{ + public string Weather { get; set; } = string.Empty; + public string Flight { get; set; } = string.Empty; + public string FlightSecret { get; set; } = string.Empty; + public string AirLab { get; set; } = string.Empty; +} -#region Channel Information +public class FunCommandSettings +{ + public string? SlapObjectsTable { get; set; } = null; + public List SlapChoices { get; set; } = []; + public List SlapFails { get; set; } = []; +} -// Channel Information. Description and Channel ID public class ChannelInfo { - public string Desc { get; set; } + public string Desc { get; set; } = string.Empty; public ulong Id { get; set; } } - -#endregion diff --git a/DiscordBot/Settings/Deserialized/UserSettings.cs b/DiscordBot/Settings/Deserialized/UserSettings.cs index e981b8ca..01fed4c4 100644 --- a/DiscordBot/Settings/Deserialized/UserSettings.cs +++ b/DiscordBot/Settings/Deserialized/UserSettings.cs @@ -13,5 +13,19 @@ public class UserSettings public int CodeReminderCooldown { get; set; } = 86400; - //TODO Introduce notice for asking for help "Can someone help" when they haven't posted in a couple minutes would be a giveaway that they should be reminded to post their question, and not just ask if someone is there. + public List Validate() + { + var warnings = new List(); + + if (XpMinPerMessage > XpMaxPerMessage) + warnings.Add($"XpMinPerMessage ({XpMinPerMessage}) > XpMaxPerMessage ({XpMaxPerMessage})"); + if (XpMinCooldown > XpMaxCooldown) + warnings.Add($"XpMinCooldown ({XpMinCooldown}) > XpMaxCooldown ({XpMaxCooldown})"); + if (ThanksCooldown <= 0) + warnings.Add($"ThanksCooldown is {ThanksCooldown} — should be positive"); + if (Thanks.Count == 0) + warnings.Add("Thanks list is empty — thanks/karma feature will never trigger"); + + return warnings; + } } \ No newline at end of file diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index db00b571..3871ab2b 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -1,100 +1,77 @@ { - /* Auth Info requires creating a Bot which can be done through https://discordapp.com/developers/applications/ (Make sure to give it Administrator Perms)*/ - /* Auth info */ - "token": "Y O U R _ B O T _ T O K E N", - "invite": "InviteLink", // Currently Unused - /* DB Info*/ + /* Auth Info — create a Bot at https://discordapp.com/developers/applications/ */ + "Token": "Y O U R _ B O T _ T O K E N", + "Invite": "InviteLink", "DbConnectionString": "Host=localhost;Port=5432;Database=udcbot;Username=udcbot;Password=USERPASSWORD", - /*Server Info*/ - "serverRootPath": "./SERVER", - "assetsRootPath": "./Assets", - /* Base info */ - "prefix": "!", - "ModeratorRoleId": "0", - "guildId": "0", // Replace with your servers guild ID - /* All assignable roles as of 29/04/21 */ - "UserAssignableRoles": { - "desc": "All normal user assignable roles available", - "roles": [ - "Audio-Engineers", - "Technical-Artists", - "Animators", - "3D-Artists", - "2D-Artists", - "XR-Developers", - "Programmers", - "Writers", - "Game-Designers", - "Generalists", - "Hobbyists", - "Students" - ] - }, - /* Channel IDs for certain channels. */ - "generalChannel": { // Off-topic - "desc": "General-Chat Channel", - "id": "0" - }, - "IntroductionChannel": { // Introductions - "desc": "Introductions Channel", - "id": "0" + "ServerRootPath": "./SERVER", + "AssetsRootPath": "./Assets", + "Prefix": "!", + "GuildId": "0", + "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", + "EveryoneScoldPeriodSeconds": "21600", + + "Channels": { + "General": { "desc": "General-Chat Channel", "id": "0" }, + "Introduction": { "desc": "Introductions Channel", "id": "0" }, + "BotAnnouncement": { "desc": "Bot-Announcement Channel", "id": "0" }, + "BotCommands": { "desc": "Bot-Commands Channel", "id": "0" }, + "UnityNews": { "desc": "Unity News Channel", "id": "0" }, + "UnityReleases": { "desc": "Unity Releases Channel", "id": "0" }, + "Rules": { "desc": "Rules Channel", "id": "0" }, + "Recruitment": { "desc": "Channel for job postings", "id": "0" }, + "GenericHelp": { "desc": "Unity-Help Channel", "id": "0" }, + "Meme": { "desc": "Meme Channel", "id": "0" }, + "BirthdayAnnouncement": { "desc": "Channel for birthday announcements", "id": "0" }, + "ComplaintCategoryId": "0", + "ComplaintPrefix": "Complaint", + "ClosedComplaintCategoryId": "0", + "ClosedComplaintPrefix": "Closed-" }, - "botAnnouncementChannel": { // Most bot logs will go here - "desc": "Bot-Announcement Channel", - "id": "0" + + "Roles": { + "Moderator": "0", + "SubsReleases": "0", + "SubsNews": "0", + "TipsUser": "0" }, - "botCommandsChannel": { - "desc": "Bot-Commands Channel", - "id": "0" + + "Recruitment": { + "Enabled": false, + "TagLookingToHire": "0", + "TagLookingForWork": "0", + "TagUnpaidCollab": "0", + "TagPositionFilled": "0", + "EditPermissionAccessTimeMin": 3 }, - "unityNewsChannel": { - "desc": "Unity News Channel", - "id": "0" + + "UnityHelp": { + "BabySitterEnabled": false, + "TipImageDirectory": "tips" }, - "ReportedMessageChannel": { - "desc": "Reported Message Channel", - "id": "0" + + "Casino": { + "Enabled": true, + "StartingTokens": 1000, + "AllowedChannels": [], + "DailyRewardTokens": 100, + "DailyRewardIntervalSeconds": 86400 }, - /* Role Ids */ - "mutedRoleID": "0", - "SubsNewsRoleId": "0", - "SubsReleasesRoleId": "0", - /*Complaints Channels Stuff*/ - "complaintCategoryId": "0", - "complaintChannelPrefix": "Complaint", - "closedComplaintChannelPrefix": "Closed-", - "closedComplaintCategoryId": "662084543662129175", - /*Commands Configuration*/ - "wikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", - "EveryoneScoldPeriodSeconds": "21600", - /*API Keys*/ - "WeatherAPIKey": "", // Key for openweathermap.org - "FlightAPIKey": "", - "FlightAPISecret": "", - "AirLabAPIKey": "", - /* Recruitment Service */ - "RecruitmentServiceEnabled": false, - "RecruitmentChannel": { // Recruitment - "desc": "Channel for job postings", - "id": "0" + + "Birthday": { + "Enabled": true, + "CheckIntervalMinutes": 240 }, - "TagLookingToHire": "0", - "TagLookingForWork": "0", - "TagUnpaidCollab": "0", - "TagPositionFilled": "0", - "EditPermissionAccessTimeMin": 3, - /* Unity Help Service */ - "UnityHelpBabySitterEnabled": false, - "genericHelpChannel": { - // Unity-help - "desc": "Unity-Help Channel", - "id": "0" + + "ApiKeys": { + "Weather": "", + "Flight": "", + "FlightSecret": "", + "AirLab": "" }, - /* Birthday Announcement Service */ - "BirthdayAnnouncementEnabled": true, - "BirthdayCheckIntervalMinutes": 240, // Check every 4 hours - "BirthdayAnnouncementChannel": { - "desc": "Channel for birthday announcements (e.g., #offtopic-chat)", - "id": "0" + + "FunCommands": { + "SlapObjectsTable": null, + "SlapChoices": [], + "SlapFails": [] } } \ No newline at end of file diff --git a/DiscordBot/Settings/UserSettings.json b/DiscordBot/Settings/UserSettings.json index 578c6a26..89fce836 100644 --- a/DiscordBot/Settings/UserSettings.json +++ b/DiscordBot/Settings/UserSettings.json @@ -30,10 +30,5 @@ "xpMinCooldown": 60, "xpMaxCooldown": 180, /*Code parameters*/ - "codeReminderCooldown": 86400, - "isSomeoneThere": [ - "is anyone around?", - "can someone help?", - "can someone help me?" - ] + "codeReminderCooldown": 86400 } diff --git a/DiscordBot/Skin/AvatarBorderSkinModule.cs b/DiscordBot/Skin/AvatarBorderSkinModule.cs index c47556f9..48fee26c 100644 --- a/DiscordBot/Skin/AvatarBorderSkinModule.cs +++ b/DiscordBot/Skin/AvatarBorderSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -14,9 +15,9 @@ public AvatarBorderSkinModule() public double StartY { get; set; } public double Size { get; set; } - public string Type { get; set; } + public string Type { get; set; } = string.Empty; - public Drawables GetDrawables(ProfileData data) + public IDrawables GetDrawables(ProfileData data) { var avatarContourStartX = StartX; var avatarContourStartY = StartY; diff --git a/DiscordBot/Skin/BaseTextSkinModule.cs b/DiscordBot/Skin/BaseTextSkinModule.cs index c5eb18a6..a9faae8e 100644 --- a/DiscordBot/Skin/BaseTextSkinModule.cs +++ b/DiscordBot/Skin/BaseTextSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -21,32 +22,35 @@ public BaseTextSkinModule() public bool StrokeAntiAlias { get; set; } public bool TextAntiAlias { get; set; } - public string StrokeColor { get; set; } + public string StrokeColor { get; set; } = string.Empty; public double StrokeWidth { get; set; } - public string FillColor { get; set; } + public string FillColor { get; set; } = string.Empty; public string Font { get; set; } public double FontPointSize { get; set; } - public string Text { get; set; } + public string Text { get; set; } = string.Empty; public double TextKerning { get; set; } [JsonConverter(typeof(StringEnumConverter))] public TextAlignment TextAlignment { get; set; } - public virtual string Type { get; set; } + public virtual string Type { get; set; } = string.Empty; - public virtual Drawables GetDrawables(ProfileData data) + public virtual IDrawables GetDrawables(ProfileData data) { var position = new PointD(StartX, StartY); - return new Drawables() + var drawables = new Drawables() .FontPointSize(FontPointSize) .Font(Font) .StrokeColor(new MagickColor(StrokeColor)) .StrokeWidth(StrokeWidth) - .StrokeAntialias(StrokeAntiAlias) .FillColor(new MagickColor(FillColor)) - .TextAntialias(TextAntiAlias) .TextAlignment(TextAlignment) .TextKerning(TextKerning) .Text(position.X, position.Y, Text); + + if (StrokeAntiAlias) drawables.EnableStrokeAntialias(); else drawables.DisableStrokeAntialias(); + if (TextAntiAlias) drawables.EnableTextAntialias(); else drawables.DisableTextAntialias(); + + return drawables; } } \ No newline at end of file diff --git a/DiscordBot/Skin/CustomTextSkinModule.cs b/DiscordBot/Skin/CustomTextSkinModule.cs index a66b2178..2a24a377 100644 --- a/DiscordBot/Skin/CustomTextSkinModule.cs +++ b/DiscordBot/Skin/CustomTextSkinModule.cs @@ -1,6 +1,7 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -15,7 +16,7 @@ public CustomTextSkinModule() FontPointSize = 15; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { var textPosition = new PointD(StartX, StartY); @@ -24,26 +25,29 @@ public override Drawables GetDrawables(ProfileData data) var mc = reg.Matches(Text); foreach (var match in mc) { - var prop = typeof(ProfileData).GetProperty(match.ToString()); + var prop = typeof(ProfileData).GetProperty(match.ToString()!); if (prop == null) continue; - var value = (dynamic)prop.GetValue(data, null); - Text = Text.Replace("{" + match + "}", value.ToString()); + var value = (dynamic?)prop.GetValue(data, null); + Text = Text.Replace("{" + match + "}", value?.ToString() ?? string.Empty); } /* ALL properties of ProfileData.cs can be used! * Like {Level} for ProfileData.Level * Or {Nickname} for ProfileData.Nickname */ - return new Drawables() + var drawables = new Drawables() .FontPointSize(FontPointSize) .Font(Font) .StrokeColor(new MagickColor(StrokeColor)) .StrokeWidth(StrokeWidth) - .StrokeAntialias(StrokeAntiAlias) .FillColor(new MagickColor(FillColor)) .TextAlignment(TextAlignment) - .TextAntialias(TextAntiAlias) .TextKerning(TextKerning) .Text(textPosition.X, textPosition.Y, $"{Text ?? Text}"); + + if (StrokeAntiAlias) drawables.EnableStrokeAntialias(); else drawables.DisableStrokeAntialias(); + if (TextAntiAlias) drawables.EnableTextAntialias(); else drawables.DisableTextAntialias(); + + return drawables; } } \ No newline at end of file diff --git a/DiscordBot/Skin/ISkinModule.cs b/DiscordBot/Skin/ISkinModule.cs index 4414f814..b56dde68 100644 --- a/DiscordBot/Skin/ISkinModule.cs +++ b/DiscordBot/Skin/ISkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -7,5 +8,5 @@ public interface ISkinModule { string Type { get; set; } - Drawables GetDrawables(ProfileData data); + IDrawables GetDrawables(ProfileData data); } \ No newline at end of file diff --git a/DiscordBot/Skin/KarmaPointsSkinModule.cs b/DiscordBot/Skin/KarmaPointsSkinModule.cs index 397a2950..0db247be 100644 --- a/DiscordBot/Skin/KarmaPointsSkinModule.cs +++ b/DiscordBot/Skin/KarmaPointsSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -14,7 +15,7 @@ public KarmaPointsSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"{data.Karma}"; return base.GetDrawables(data); diff --git a/DiscordBot/Skin/KarmaRankSkinModule.cs b/DiscordBot/Skin/KarmaRankSkinModule.cs index fce379ec..231c64db 100644 --- a/DiscordBot/Skin/KarmaRankSkinModule.cs +++ b/DiscordBot/Skin/KarmaRankSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -14,7 +15,7 @@ public KarmaRankSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"#{data.KarmaRank}"; return base.GetDrawables(data); diff --git a/DiscordBot/Skin/LevelSkinModule.cs b/DiscordBot/Skin/LevelSkinModule.cs index 587f095c..953a8fe5 100644 --- a/DiscordBot/Skin/LevelSkinModule.cs +++ b/DiscordBot/Skin/LevelSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -14,7 +15,7 @@ public LevelSkinModule() FontPointSize = 50; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = data.Level.ToString(); return base.GetDrawables(data); diff --git a/DiscordBot/Skin/RectangleD.cs b/DiscordBot/Skin/RectangleD.cs deleted file mode 100644 index dc4da6ed..00000000 --- a/DiscordBot/Skin/RectangleD.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace DiscordBot.Skin; - -public struct RectangleD -{ - public double UpperLeftX; - public double UpperLeftY; - public double LowerRightX; - public double LowerRightY; - - public RectangleD(double upperLeftX, double upperLeftY, double lowerRightX, double lowerRightY) - { - UpperLeftX = upperLeftX; - UpperLeftY = upperLeftY; - LowerRightX = lowerRightX; - LowerRightY = lowerRightY; - } -} \ No newline at end of file diff --git a/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs b/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs index 53b01f01..d2594233 100644 --- a/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs +++ b/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -13,11 +14,11 @@ public class RectangleSampleAvatarColorSkinModule : ISkinModule public int Width { get; set; } public int Height { get; set; } public bool WhiteFix { get; set; } - public string DefaultColor { get; set; } + public string DefaultColor { get; set; } = string.Empty; - public string Type { get; set; } + public string Type { get; set; } = string.Empty; - public Drawables GetDrawables(ProfileData data) + public IDrawables GetDrawables(ProfileData data) { var color = DetermineColor(data.Picture); @@ -28,10 +29,13 @@ public Drawables GetDrawables(ProfileData data) private MagickColor DetermineColor(MagickImage dataPicture) { - //basically we let magick to choose what the main color by resizing to 1x1 var copy = new MagickImage(dataPicture); copy.Resize(1, 1); - var color = copy.GetPixels()[0, 0].ToColor(); + var pixels = copy.GetPixels(); + var pixelColor = pixels?[0, 0]?.ToColor(); + var color = pixelColor != null + ? new MagickColor(pixelColor.R, pixelColor.G, pixelColor.B) + : new MagickColor(DefaultColor); if (WhiteFix && color.R + color.G + color.B > 650) color = new MagickColor(DefaultColor); diff --git a/DiscordBot/Skin/SkinData.cs b/DiscordBot/Skin/SkinData.cs index 733eba93..c3221145 100644 --- a/DiscordBot/Skin/SkinData.cs +++ b/DiscordBot/Skin/SkinData.cs @@ -7,10 +7,10 @@ public SkinData() Layers = new List(); } - public string Name { get; set; } - public string Codename { get; set; } - public string Description { get; set; } + public string Name { get; set; } = string.Empty; + public string Codename { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; public int AvatarSize { get; set; } - public string Background { get; set; } + public string Background { get; set; } = string.Empty; public List Layers { get; set; } } \ No newline at end of file diff --git a/DiscordBot/Skin/SkinLayer.cs b/DiscordBot/Skin/SkinLayer.cs index 5f2cd768..ec599969 100644 --- a/DiscordBot/Skin/SkinLayer.cs +++ b/DiscordBot/Skin/SkinLayer.cs @@ -7,7 +7,7 @@ public SkinLayer() Modules = new List(); } - public string Image { get; set; } + public string Image { get; set; } = string.Empty; public double StartX { get; set; } public double StartY { get; set; } public double Width { get; set; } diff --git a/DiscordBot/Skin/SkinModuleJsonConverter.cs b/DiscordBot/Skin/SkinModuleJsonConverter.cs index f97cdb61..7f673af1 100644 --- a/DiscordBot/Skin/SkinModuleJsonConverter.cs +++ b/DiscordBot/Skin/SkinModuleJsonConverter.cs @@ -9,14 +9,14 @@ public class SkinModuleJsonConverter : JsonConverter public override bool CanConvert(Type objectType) => objectType == typeof(ISkinModule); - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { var jo = JObject.Load(reader); Type type; try { - var t = $"DiscordBot.Skin.{jo["Type"].Value()}SkinModule"; - type = Type.GetType(t); + var t = $"DiscordBot.Skin.{jo["Type"]!.Value()}SkinModule"; + type = Type.GetType(t)!; return jo.ToObject(type); } catch (Exception e) @@ -26,7 +26,7 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist } } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { throw new NotImplementedException(); } diff --git a/DiscordBot/Skin/TotalXpSkinModule.cs b/DiscordBot/Skin/TotalXpSkinModule.cs index 67d63331..cbcfde3c 100644 --- a/DiscordBot/Skin/TotalXpSkinModule.cs +++ b/DiscordBot/Skin/TotalXpSkinModule.cs @@ -1,6 +1,7 @@ using System.Globalization; using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -13,7 +14,7 @@ public TotalXpSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = data.XpTotal.ToString("N0", new CultureInfo("en-US")); return base.GetDrawables(data); diff --git a/DiscordBot/Skin/UsernameSkinModule.cs b/DiscordBot/Skin/UsernameSkinModule.cs index da1a2427..c455a767 100644 --- a/DiscordBot/Skin/UsernameSkinModule.cs +++ b/DiscordBot/Skin/UsernameSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -13,7 +14,7 @@ public UsernameSkinModule() FillColor = MagickColors.DeepSkyBlue.ToString(); } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"{data.Nickname ?? data.Username}"; return base.GetDrawables(data); diff --git a/DiscordBot/Skin/XpBarInfoSkinModule.cs b/DiscordBot/Skin/XpBarInfoSkinModule.cs index 5fdc99e6..b663f576 100644 --- a/DiscordBot/Skin/XpBarInfoSkinModule.cs +++ b/DiscordBot/Skin/XpBarInfoSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -13,7 +14,7 @@ public XpBarInfoSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"{data.XpShown:#,##0} / {data.MaxXpShown:N0} ({Math.Floor(data.XpPercentage * 100):0}%)"; return base.GetDrawables(data); diff --git a/DiscordBot/Skin/XpBarSkinModule.cs b/DiscordBot/Skin/XpBarSkinModule.cs index e46e7529..39b8978f 100644 --- a/DiscordBot/Skin/XpBarSkinModule.cs +++ b/DiscordBot/Skin/XpBarSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -26,9 +27,9 @@ public XpBarSkinModule() public string InsideStrokeColor { get; set; } public string InsideFillColor { get; set; } - public string Type { get; set; } + public string Type { get; set; } = string.Empty; - public Drawables GetDrawables(ProfileData data) + public IDrawables GetDrawables(ProfileData data) { var xpBarOutsideRectangle = new RectangleD(StartX, StartY, StartX + Width, StartY + Height); diff --git a/DiscordBot/Skin/XpRankSkinModule.cs b/DiscordBot/Skin/XpRankSkinModule.cs index c00f18b5..695cdb34 100644 --- a/DiscordBot/Skin/XpRankSkinModule.cs +++ b/DiscordBot/Skin/XpRankSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -12,7 +13,7 @@ public XpRankSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"#{data.XpRank}"; return base.GetDrawables(data); diff --git a/DiscordBot/Utils/IWebClient.cs b/DiscordBot/Utils/IWebClient.cs new file mode 100644 index 00000000..267e37c4 --- /dev/null +++ b/DiscordBot/Utils/IWebClient.cs @@ -0,0 +1,15 @@ +using HtmlAgilityPack; + +namespace DiscordBot.Utils; + +public interface IWebClient +{ + Task GetContent(string url); + Task GetHtmlDocument(string url); + Task GetHtmlNode(string url, string xpath); + Task GetHtmlNodes(string url, string xpath); + Task GetHtmlNodeInnerText(string url, string xpath); + Task GetXMLContent(string url); + Task GetObjectFromJson(string url); + Task<(bool success, T? result)> TryGetObjectFromJson(string url); +} diff --git a/DiscordBot/Utils/SerializeUtil.cs b/DiscordBot/Utils/SerializeUtil.cs index 9b42fde0..0e45e519 100644 --- a/DiscordBot/Utils/SerializeUtil.cs +++ b/DiscordBot/Utils/SerializeUtil.cs @@ -83,7 +83,7 @@ private static async Task AtomicWriteTextAsync(string path, string content) File.Move(tmpPath, path, overwrite: true); } - public static async Task LoadUrlDeserializeResult(string url) + public static async Task LoadUrlDeserializeResult(string url) { var result = await InternetExtensions.GetHttpContents(url); var resultObject = JsonConvert.DeserializeObject(result); diff --git a/DiscordBot/Utils/StringUtil.cs b/DiscordBot/Utils/StringUtil.cs index 1f1ba0b4..4b0a50c9 100644 --- a/DiscordBot/Utils/StringUtil.cs +++ b/DiscordBot/Utils/StringUtil.cs @@ -5,15 +5,15 @@ namespace DiscordBot.Utils; public static class StringUtil { private static readonly Regex CurrencyRegex = - new (@"(?:\$\s*\d+|\d+\s*\$|\d*\s*(?:USD|£|pounds|€|EUR|euro|euros|GBP|円|YEN))", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); - private static readonly Regex RevShareRegex = new (@"\b(?:rev-share|revshare|rev share)\b", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); - + new(@"(?:\$\s*\d+|\d+\s*\$|\d*\s*(?:USD|£|pounds|€|EUR|euro|euros|GBP|円|YEN))", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); + private static readonly Regex RevShareRegex = new(@"\b(?:rev-share|revshare|rev share)\b", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); + // a string extension that checks if the contents of the string contains a limited selection of currency symbols/words public static bool ContainsCurrencySymbol(this string str) { return !string.IsNullOrWhiteSpace(str) && CurrencyRegex.IsMatch(str); } - + public static bool ContainsRevShare(this string str) { return !string.IsNullOrWhiteSpace(str) && RevShareRegex.IsMatch(str); @@ -24,7 +24,7 @@ public static string MessageSelfDestructIn(int secondsFromNow) var time = DateTime.Now.ToUnixTimestamp() + secondsFromNow; return $"Self-delete: ****"; } - + /// /// Sanitizes @everyone and @here mentions by adding a zero-width space after the @ symbol. /// @@ -32,5 +32,5 @@ public static string SanitizeEveryoneHereMentions(this string str) { return str.Replace("@everyone", "@\u200beveryone").Replace("@here", "@\u200bhere"); } - + } \ No newline at end of file diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebClient.cs similarity index 68% rename from DiscordBot/Utils/WebUtil.cs rename to DiscordBot/Utils/WebClient.cs index 80cd6591..521c1bef 100644 --- a/DiscordBot/Utils/WebUtil.cs +++ b/DiscordBot/Utils/WebClient.cs @@ -5,31 +5,38 @@ namespace DiscordBot.Utils; -public static class WebUtil +public class WebClient : IWebClient { + private readonly IHttpClientFactory _httpClientFactory; + + public WebClient(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + /// /// Returns the content of a URL as a string, or an empty string if the request fails. /// - public static async Task GetContent(string url) + public async Task GetContent(string url) { - using var client = new HttpClient(); try { + using var client = _httpClientFactory.CreateClient(); var response = await client.GetAsync(url); return await response.Content.ReadAsStringAsync(); } catch (Exception e) { - LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + LoggingService.LogToConsole($"[WebClient] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); return ""; } } - + /// /// Returns the Html document of a url, or null if the request fails. /// Internally calls GetContent and parses the result. /// - public static async Task GetHtmlDocument(string url) + public async Task GetHtmlDocument(string url) { try { @@ -43,44 +50,44 @@ public static async Task GetHtmlDocument(string url) return null; } } - + /// /// Returns the Html node of a url and xpath, or null if the request fails. /// Internally calls GetHtmlDocument and parses the result with xpath. /// - public static async Task GetHtmlNode(string url, string xpath) + public async Task GetHtmlNode(string url, string xpath) { try { var doc = await GetHtmlDocument(url); - return doc.DocumentNode.SelectSingleNode(xpath); + return doc?.DocumentNode.SelectSingleNode(xpath); } catch (Exception) { return null; } } - + /// /// Returns the Html nodes of a url and xpath, or null if the request fails. /// - public static async Task GetHtmlNodes(string url, string xpath) + public async Task GetHtmlNodes(string url, string xpath) { try { var doc = await GetHtmlDocument(url); - return doc.DocumentNode.SelectNodes(xpath); + return doc?.DocumentNode.SelectNodes(xpath); } catch (Exception) { return null; } - } - + } + /// /// Returns the decoded inner text of a url and xpath, or an empty string if the request fails. /// - public static async Task GetHtmlNodeInnerText(string url, string xpath) + public async Task GetHtmlNodeInnerText(string url, string xpath) { try { @@ -92,11 +99,11 @@ public static async Task GetHtmlNodeInnerText(string url, string xpath) return string.Empty; } } - + /// /// Returns the content of a url as a sanitized XML string, or an empty string if the request fails. /// - public static async Task GetXMLContent(string url) + public async Task GetXMLContent(string url) { try { @@ -108,15 +115,15 @@ public static async Task GetXMLContent(string url) } catch (Exception e) { - LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + LoggingService.LogToConsole($"[WebClient] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); return string.Empty; } } - + /// /// Returns a deserialized object from a JSON string. If the string is empty or can't be deserialized, it returns the default value of the type. /// - public static async Task GetObjectFromJson(string url) + public async Task GetObjectFromJson(string url) { try { @@ -125,15 +132,15 @@ public static async Task GetObjectFromJson(string url) } catch (Exception e) { - LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + LoggingService.LogToConsole($"[WebClient] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); return default; } } - + /// /// Returns a deserialized object from a JSON string, or null if the string is empty or can't be deserialized. /// - public static async Task<(bool success, T result)> TryGetObjectFromJson(string url) + public async Task<(bool success, T? result)> TryGetObjectFromJson(string url) { try { @@ -143,7 +150,7 @@ public static async Task GetObjectFromJson(string url) } catch (Exception e) { - LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + LoggingService.LogToConsole($"[WebClient] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); return (false, default); } } diff --git a/docs/casino.md b/docs/casino.md index 116eabce..81811f4b 100644 --- a/docs/casino.md +++ b/docs/casino.md @@ -236,7 +236,7 @@ Game End Condition → GameService.EndGame() → Payout Calculation → CasinoSe ### Configuration - Channel restrictions via `CasinoService.IsChannelAllowed()` -- Starting token amounts in `BotSettings.CasinoStartingTokens` +- Starting token amounts in `BotSettings.Casino.StartingTokens` - Daily reward amounts and cooldowns - Game-specific parameters (max players, betting limits) diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md new file mode 100644 index 00000000..9611b2ce --- /dev/null +++ b/docs/code-quality-audit.md @@ -0,0 +1,424 @@ +# Code Quality Audit Report + +**Date:** 2026-04-03 +**Scope:** Full codebase review — refactoring opportunities, code duplication, patterns, bugs +**Excludes:** New features, test implementation (gaps noted only) + +--- + +## Executive Summary + +The UDC-Bot codebase has a solid feature set but suffers from several structural +issues common in organically grown projects. The most impactful problems are: + +1. **God classes** — `UserService`, `BotSettings`, and `UserModule` carry too many + responsibilities. +2. **Zero test coverage** — the `DiscordBot.Tests/` project is empty. +3. **Thread-safety bugs** — multiple shared collections accessed without + synchronization. +4. **Resource leaks** — `HttpClient` created per-request instead of reused; + database connections potentially undisposed. +5. **Pervasive code duplication** — embed building, error handling, and HTTP + patterns repeated dozens of times. + +--- + +## Findings by Category + +### 1. God Classes & SRP Violations + +| Class | File | Responsibilities | Severity | +|-------|------|-----------------|----------| +| `UserService` | `Services/UserService.cs` | XP, karma, muting, code formatting warnings, everyone-mention scold, profile card generation, welcome messages, avatar ops, data persistence, level calculation (~11 concerns) | Critical | +| `BotSettings` | `Settings/Deserialized/Settings.cs` | ~~60+ properties across channels, roles, API keys, casino, tips, recruitment, weather in one flat class~~ Split into 8 nested domain classes ✅ | ~~High~~ Done | +| `UserModule` | `Modules/UserModule.cs` | 1 000+ lines; text commands, web scraping, role management, search, profile display all in one module | High | +| `UpdateService` | `Services/UpdateService.cs` | Bot data, user muting lifecycle, FAQ loading, RSS feeds, Wikipedia downloading (5 concerns) | High | +| `CasinoSlashModule` | `Modules/Casino/CasinoSlashModule.cs` | 500+ lines; token commands, game commands, admin commands, statistics, nested `TokenCommands` class | High | +| ~~`WebUtil`~~ | ~~`Utils/WebClient.cs`~~ | ~~Refactored to `IWebClient` / `WebClient` with DI~~ ✅ | ~~Medium~~ | +| `ICasinoRepo` | `Extensions/CasinoRepository.cs` | 37+ SQL method signatures in one interface | Medium | + +**Recommended splits:** + +- `UserService` → `XpService`, `KarmaService`, `ProfileCardService`, + `WelcomeService`, `CodeFormattingService` +- `BotSettings` → `ChannelSettings`, `RoleSettings`, `CasinoSettings`, + `TipSettings`, `RecruitmentSettings`, `ApiKeySettings` +- `ICasinoRepo` → `ICasinoUserRepo`, `ITokenTransactionRepo`, + `ICasinoAdminRepo` + +--- + +### 2. Code Duplication + +#### 2a. `HttpClient` creation (repeated everywhere) + +Files: `AirportService.cs`, `UserService.cs`, `TipService.cs`, `WebUtil.cs` + +```csharp +// Pattern found in 4+ places +using (var http = new HttpClient()) { ... } +HttpClient client = new(); // sometimes without using +``` + +**Fix:** Register a shared `IHttpClientFactory` in DI and inject it. + +#### 2b. Embed construction boilerplate + +Files: `ModerationService.cs`, `RecruitService.cs`, `CannedResponseService.cs`, +and ~20 other locations. + +```csharp +var builder = new EmbedBuilder() + .WithColor(color) + .WithTimestamp(...) + .FooterInChannel(...); +``` + +**Fix:** Create an `EmbedFactory` helper with preset methods per use-case. + +#### 2c. Service name constant + +Every service file declares `private const string ServiceName = "...";` +identically. Could be extracted to a base class or generated via `nameof`. + +#### 2d. Fire-and-forget `Task.Run` with pragma suppression + +Files: `RecruitService.cs`, `UnityHelpService.cs` + +```csharp +#pragma warning disable CS4014 +Task.Run(() => ...); +#pragma warning restore CS4014 +``` + +**Fix:** Create a `SafeFireAndForget()` extension that logs exceptions. + +#### ~~2e. `ContainsInviteLink()` — three identical overloads~~ (removed — dead code, no callers) + +File: `MessageExtensions.cs` — same regex for `IUserMessage`, `string`, and +`IMessage`. Should be a single implementation on `string` with the others +delegating. + +#### 2f. Cooldown calculation pattern + +File: `UserServiceExtensions.cs` — `Days()`, `Hours()`, `Minutes()`, +`Seconds()`, `Milliseconds()` all follow the exact same structure with +`cooldowns.HasUser()` check. + +#### 2g. Weather command overloads + +File: `WeatherModule.cs` — each weather command (Temp, Weather, Pollution, Time) +duplicated as `(IUser)` and `(params string[])` overloads with near-identical +bodies. + +#### 2h. Mute logic overloads + +File: `ModerationModule.cs` — three `MuteUser` overloads with near-identical +role-add / logging / cooldown / DM logic. + +--- + +### 3. Potential Bugs + +#### 3a. Thread-safety issues (Critical) + +| Location | Issue | +|----------|-------| +| `Program.cs` — `_isInitialized` flag | Not thread-safe; `Ready` event could fire twice before flag is set. Use `Interlocked.CompareExchange`. | +| `GameService.cs` — `List` | Plain `List` mutated from multiple event handlers. Use `ConcurrentDictionary` or lock. | +| `UserService.cs` — `_xpCooldown` dictionary | Modified from multiple async tasks without synchronization. | +| `ReminderService.cs` — `_reminders` list | Modified while iterating in `CheckReminders`. | +| `GameSession.cs` — `GameData` dictionary | `AddPlayer` / `RemovePlayer` without locks. | +| `RecruitService.cs` — `_botSanityCheck` dictionary | Used as a lock mechanism but is not thread-safe. | + +#### 3b. Resource leaks (High) + +| Location | Issue | +|----------|-------| +| ~~`WebUtil.cs` — `new HttpClient()` per call~~ | ~~Fixed: `WebClient` now uses `IHttpClientFactory`~~ ✅ | +| `AirportService.cs` — `HttpClient client = new()` | Created without `using`, never disposed. | +| `DatabaseService.cs` — `Query` property | Returns new `MySqlConnection` each access; may never be disposed by caller. | +| `UserService.cs` — `GenerateProfileCard()` | MagickImage objects not consistently disposed. | +| `FuzzTable.cs` — `File.ReadLines()` | Not wrapped in `using`; handle may leak on exception. | + +#### 3c. Null-reference risks (Medium) + +| Location | Issue | +|----------|-------| +| `ModerationService` — `MessageDeleted` handler | `_botAnnouncementChannel.Id` — channel could be null. | +| `FeedService` — `HandleFeed` method | `item.Links[0]` — no bounds check. | +| `UserExtensions` — `HasRoleGroup` overload | `user as SocketGuildUser` result used without null check. | +| `ContextExtension` — `IsOnlyReplyingToAuthor` | `context.Message.ReferencedMessage.Author.Id` — no null check. | +| `SkinModuleJsonConverter` — `ReadJson` | `Type.GetType(t)` can return null; `jo["Type"]` can be null. | +| `CustomTextSkinModule` — `GetDrawables` | `prop.GetValue(data, null)` cast to `dynamic` — value could be null. | +| `RoleAttributes` — `CheckPermissionsAsync` | Direct cast `(SocketGuildUser)` crashes in DM context. | + +#### 3d. Logic bugs + +| Location | Issue | Severity | +|----------|-------|----------| +| `EmbedModule.cs` — `SendEmbedToChannel` reaction-polling loop | `i++` inside a `for(int i=0; i<10; i++)` loop — counter incremented twice per iteration, halving the confirmation window from 20s to 10s. Additionally, the loop continues polling after confirmation is received (no `break` on `confirmedEmbed = true`). | High | +| `WeatherModule.cs` — `WeatherEmbed` sunrise/sunset block | `res.sys.sunrise > 0` checked twice; second should be `res.sys.sunset`. Copy-paste bug. Low impact: output uses correct `sunset` variable, but sunset line is suppressed when `sunrise == 0 && sunset > 0`. | Low | +| `StringExtensions.cs` — `MessageSplitToSize` | If no newlines exist, `LastIndexOf("\n")` returns -1/0, risking infinite loop or empty string. | Medium | +| `AirportModule.cs` — `FlyTo` day-of-week calc | Day-of-week calculation may have off-by-one when Sunday (`DayOfWeek = 0`) is involved. | Medium | +| `Blackjack` — `DoubleDown` method | No check that player has sufficient tokens before doubling bet. Could create negative balances. | Medium | + +#### 3e. `async void` event handlers (Medium-High) + +Several event subscriptions use `async void` delegate signatures (e.g., +`_client.MessageReceived += Thanks` in `UserService`). `async void` methods are +fire-and-forget: unhandled exceptions inside them crash the process instead of +being caught. All async event handlers should be wrapped in try-catch or use a +safe-fire-and-forget pattern. + +#### 3f. Session / memory leaks (Medium) + +| Location | Issue | +|----------|-------| +| `GameSession.cs` | `ExpiryTime` commented out — sessions can live forever with no cleanup. | +| `Program.cs` | All services are `Singleton` — never disposed; database connections held forever. | +| `Program.cs` | `await Task.Delay(-1)` — no graceful shutdown; no `CancellationToken`. | + +--- + +### 4. Long Methods (> 50 lines) + +| File | Method | ~Lines | Issue | +|------|--------|--------|-------| +| `UserService.cs` | Constructor | 150 | Initialization, regex compilation, event hookup all mixed | +| `UserService.cs` | `GenerateProfileCard()` | 150 | DB queries, image manipulation, HTTP, file I/O in one method | +| `UserService.cs` | `Thanks()` | 100 | Regex matching, DB calls, cooldown checks combined | +| `UserModule.cs` | `SearchResults` | 120+ | Web scraping, HTML parsing, URL manipulation, embed building | +| `FeedService.cs` | `GetReleaseNotes()` | 100 | Complex HTML parsing with nested loops | +| `AirportModule.cs` | `FlyTo` | 95 | API calls, coordinate lookups, embed building | +| `EmbedModule.cs` | `SendEmbedToChannel` | 90+ | Reaction polling, message creation, confirmation | +| `UpdateService.cs` | `DownloadDocDatabase()` | 80 | Web scraping, parsing, file I/O | +| `CasinoSlashModule.cs` | `DisplayTransactionHistory` | 80+ | Query, pagination, admin checks, embed formatting | +| `PokerHelper.cs` | Hand evaluation | 200 | Complex hand ranking with edge cases | + +--- + +### 5. Hardcoded Values That Should Be In Config + +| File | Value | Purpose | +|------|-------|---------| +| `UserService.cs` | `39` minutes | Miku cooldown | +| `UserService.cs` | `800` | Code block warning length threshold | +| `UnityHelpService.cs` | `10` min, `14` hr, `20` hr, `3` days | Thread close/idle timers | +| `RecruitService.cs` | `120` chars, `60` sec | Min message length, delete delay | +| `ReminderService.cs` | `10` | Max reminders per user | +| `AirportService.cs` | API URLs | Test vs production URLs | +| `TipService.cs` | `"tips.json"` | Filename | +| `StringExtensions.cs` | `1990` | Discord max message length (should use constant) | +| `UserServiceExtensions.cs` | `9999` days | "Permanent" duration | +| `WeatherModule.cs` | `22.5, 67.5, 112.5...` | Wind direction angles | +| `CasinoSlashModule.Games.cs` | Game name mapping | Hardcoded switch expression | +| `Skin modules` | Pixel coordinates | Layout-specific X/Y positions | +| `BotSettings` | `300`, `21600`, `86400` seconds | Various delay timers | + +--- + +### 6. Architecture & Design Issues + +#### 6a. Business logic in command handlers + +Several modules contain significant business logic that should live in services: + +- `AirportModule.FlyTo` — flight calculation and coordinate fetching +- `UserModule.SearchResults` — web scraping and HTML parsing +- `WeatherModule.TemperatureEmbed` — formatting and calculation +- `CasinoSlashModule.DisplayTransactionHistory` — complex query and pagination +- `UserSlashModule.Duel` — AI action loops, timeout handling, component builders +- `TipModule.Tip` — file path handling, attachment creation, DB persistence + +#### 6b. Static mutable state in modules + +`UserSlashModule._activeDuels` is a `ConcurrentDictionary` held as static state +in a module. This should be in a service for proper lifecycle management and +recovery. + +#### 6c. Inconsistent command patterns + +- Mixed text commands (`UserModule`) vs slash commands (`UserSlashModule`) for + similar functionality. +- Inconsistent use of `Priority` attribute across modules. +- Inconsistent alias patterns. +- Different `InteractionModuleBase` generic parameterization. +- Event handler naming varies: `MessageReceived` vs `OnMessageReceived` vs + `GatewayOnMessageReceived`. + +#### 6d. No configuration validation + +`BotSettings` has no `Validate()` method. Critical fields like `Token`, +`GuildId`, `DbConnectionString` could be empty/null/zero without detection until +a runtime crash. + +#### 6e. Singleton-only DI + +`Program.cs` registers every service as `Singleton`. No consideration for +`Scoped` or `Transient` lifetimes. Services holding database connections or +disposable resources are never cleaned up. + +#### 6f. No graceful shutdown + +`await Task.Delay(-1)` blocks forever. No `CancellationToken`, no shutdown +signal handling, no resource cleanup on exit. + +--- + +### 7. Skin System Issues + +| Issue | Severity | Details | +|-------|----------|---------| +| ✅ ~~Duplicate `RectangleD` struct~~ | ~~Low~~ | ~~Resolved — kept `Domain/RectangleD.cs`, deleted `Skin/RectangleD.cs`~~ | +| Reflection in `SkinModuleJsonConverter` | Medium | `Type.GetType()` on every deserialization; no caching; no null check | +| Magic threshold in avatar color sampling | Medium | `650` RGB sum threshold unexplained | +| Inconsistent coordinate types | Low | Some modules use `int`, others use `double` | +| Hardcoded pixel positions | Medium | Skin modules have layout-specific coordinates baked in | +| `CustomTextSkinModule` null risk | Medium | `prop.GetValue()` result cast to `dynamic`, `.ToString()` called without null check | +| Text rendering setup duplication | Medium | All text skin modules repeat the same `StrokeColor`/`FillColor`/`FontPointSize` initialization | + +--- + +### 8. Dead Code & Commented-Out Code + +| File | What | Notes | +|------|------|-------| +| `UserService.cs` | `MikuCheck` event subscription | Commented out | +| `UserService.cs` | `_mikuCooldownTime` initialization | Commented out | +| `UpdateService.cs` | `UpdateUserRanks` task | Commented out | +| `AirportModule.cs` | Flight details (seats, bags, fees) | Commented out | +| `UserModule.cs` | Entire `CompileCode` method | Commented out with note "Not really a required feature" | +| `FeedService.cs` | TODO about other entities | Stale | +| `TipService.cs` | TODO about image attachment | Stale | +| `GameSession.cs` | `ExpiryTime`, `UserId` properties | Commented out | +| `DiscordGameSession.cs` | "Reload Embed" and "Custom" bet buttons | Commented out | + +--- + +### 9. Missing Error Handling + +| File | Method | Issue | +|------|--------|-------| +| `AirportService.cs` | `GetFlightTickets()` | Returns null without logging | +| `DatabaseService.cs` | Constructor | Bare catch block; continues silently | +| `ReminderService.cs` | `LoadReminders()` | No handling for corrupted file | +| `TipService.cs` | `CommitTipDatabase()` | No try-catch for file write failures | +| `UnityHelpService.cs` | Message fetching | Missing null checks on `GetMessageAsync` results | +| `TicketModule.cs` | `Complaint` | No validation that `Settings.ComplaintCategoryId` is valid | +| `CasinoSlashModule.Games.cs` | `DoAction` | `Enum.Parse` with no try-catch | +| `Program.cs` | `DeserializeSettings()` | No error handling; exception propagates uncaught | +| `Program.cs` | `Ready` handler | No try-catch around service initialization | + +--- + +### 10. Security Concerns + +| Location | Issue | Severity | +|----------|-------|----------| +| `EmbedModule.cs` — `BuildEmbedFromUrl` | SSRF risk: `IsValidHost()` allow-list exists but `attachment.Url` from Discord CDN bypasses it. Pastebin/hastebin URLs could also contain redirects. User-supplied URLs are downloaded server-side. | Medium | +| `EmbedModule.cs` | Uses deprecated `WebClient` (obsolete since .NET 6) — should switch to `HttpClient` via `IHttpClientFactory` | Low | +| `CasinoRepository.cs` — 37+ SQL methods | SQL injection surface is large. All queries are likely parameterized via Insight.Database, but this should be verified explicitly. | Low (verify) | +| `RoleAttributes.cs` | ~~Direct cast `(SocketGuildUser)` crashes in DM context — precondition bypass could allow unauthorized command execution if the exception is caught upstream~~ ✅ Fixed | ~~Medium~~ | + +--- + +### 11. Naming Inconsistencies + +| Pattern | Examples | Issue | +|---------|----------|-------| +| Service name strings | Some include "Service" suffix, some don't | Inconsistent | +| Method naming | `GetOrAddUser` vs `GetOrCreateCasinoUser` | No consistent convention | +| Async method naming | `Thanks()` (async void) vs `Thanks` (async Task) | Some void, some Task | +| Private field prefix | `_settings` in some classes, `Settings` in others | Inconsistent underscore | +| Event handlers | `MessageReceived`, `OnMessageReceived`, `GatewayOnMessageReceived` | Three different conventions | +| Data model casing | `Kategory` vs `Category`, `Keyimage` vs `KeyImage`, `Pubdate` vs `PubDate` in `UnityAPI.cs` | Inconsistent | +| `HasAnyPingableMention` | Exists in both `MessageExtensions` and `ContextExtension` with different behavior | Confusing | + +--- + +### 12. Test Coverage + +**Current state: 0%** — `DiscordBot.Tests/` exists as an empty project stub with +no `.cs` files and no test framework configured. + +**Highest-priority areas for testing:** + +1. Attribute preconditions (role checks, channel checks) +2. Extension methods (string splitting, cooldown logic, message extensions) +3. Casino game logic (hand evaluation, bet validation, session management) +4. Service business logic (karma calculation, XP, reminders) +5. Skin module rendering pipeline + +--- + +## Prioritized Action Plan + +### Immediate (Bugs) + +1. ✅ ~~Fix `_isInitialized` race condition in `Program.cs` — use + `Interlocked.CompareExchange`~~ +2. ✅ ~~Replace `List` with `ConcurrentDictionary` in + `GameService.cs`~~ +3. ✅ ~~Fix double `i++` in `EmbedModule.cs` reaction-polling loop (and add + `break` after confirmation)~~ +4. ✅ ~~Fix sunrise/sunset copy-paste bug in `WeatherModule.cs`~~ +5. ✅ ~~Add `using` to all `HttpClient` instances or switch to `IHttpClientFactory`; + replace deprecated `WebClient` usage~~ +6. ✅ ~~Add null checks in `RoleAttributes.cs` for DM context safety~~ +7. ✅ ~~Wrap all `async void` event handlers in try-catch~~ + +### Short-term (Architecture) + +1. ✅ ~~Split `UserService` into focused services~~ +2. ✅ ~~Split `BotSettings` into domain-specific config classes~~ +3. ✅ ~~Add `BotSettings.Validate()` post-deserialization~~ +4. ✅ ~~Extract business logic from command handlers into services~~ +5. ✅ ~~Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation~~ +6. ✅ ~~Add graceful shutdown support with `CancellationToken`~~ +7. ✅ ~~Move static module state (`_activeDuels`) to services~~ + +### Medium-term (Quality) + +1. Create `EmbedFactory` to reduce embed construction duplication +2. ✅ ~~Create `SafeFireAndForget()` extension to replace `#pragma` + `Task.Run`~~ +3. ✅ ~~Consolidate `ContainsInviteLink()` overloads~~ — removed (dead code) +4. ✅ ~~Add configuration validation for all settings~~ — covered by S3 +5. Audit service lifetimes — consider `Scoped` for interaction-scoped services +6. Remove all dead/commented-out code +7. Standardize naming conventions (event handlers, async methods, service + constants) + +### Long-term (Sustainability) + +1. ✅ ~~Set up test project with xUnit and write tests for critical paths~~ +2. Split `ICasinoRepo` into focused interfaces +3. ✅ ~~Extract `IWebClient` from `WebUtil` for testability~~ (`IHtmlParser` split intentionally skipped — thin wrappers) +4. Implement session expiry and cleanup for casino game sessions +5. Refactor skin module hierarchy — intermediate base classes, coordinate config +6. ✅ ~~Consolidate duplicate `RectangleD` struct~~ +7. ✅ ~~Replace `string[][]` database in `UpdateService` with typed `DocEntry` record~~ + +--- + +## Findings Summary + +| Category | Critical | High | Medium | Low | Total | +|----------|----------|------|--------|-----|-------| +| Thread safety | 3 | 3 | — | — | 6 | +| Resource leaks | — | 5 | — | — | 5 | +| Null-reference risks | — | 2 | 5 | — | 7 | +| Logic bugs | — | 1 | 3 | 1 | 5 | +| God classes / SRP | — | 4 | 3 | — | 7 | +| Code duplication | — | 2 | 6 | — | 8 | +| Long methods | — | 4 | 6 | — | 10 | +| Hardcoded values | — | — | 7 | 6 | 13 | +| Missing error handling | — | 2 | 5 | 2 | 9 | +| Architecture / design | — | 3 | 4 | — | 7 | +| Dead code | — | — | 4 | 5 | 9 | +| Naming inconsistencies | — | — | 3 | 4 | 7 | +| Security | — | — | 3 | 1 | 4 | +| Test coverage | 1 | — | — | — | 1 | +| Skin system | — | — | 4 | 3 | 7 | +| Async void handlers | — | 1 | — | — | 1 | +| **Total** | **4** | **27** | **53** | **22** | **106** | diff --git a/docs/codebase.md b/docs/codebase.md index a9ea5736..006d425e 100644 --- a/docs/codebase.md +++ b/docs/codebase.md @@ -64,29 +64,41 @@ DiscordBot/ │ └── ... │ ├── Modules/ # Discord command handlers (text + slash) -│ ├── UserModule.cs # General user commands (text) -│ ├── UserSlashModule.cs # User slash commands -│ ├── ModerationModule.cs # Mod commands -│ ├── TipModule.cs # Tip system -│ ├── ReminderModule.cs # Reminders -│ ├── TicketModule.cs # Support tickets -│ ├── EmbedModule.cs # Embed generation -│ ├── AirportModule.cs # Flight lookups -│ ├── Casino/ # Casino slash commands -│ ├── UnityHelp/ # Help forum, canned responses, FAQ -│ └── Weather/ # Weather commands +│ ├── Profiles/ # User profile, rank & birthday commands +│ │ ├── ProfileModule.cs +│ │ ├── RankModule.cs +│ │ └── BirthdayModule.cs +│ ├── Server/ # Server management, moderation, embeds, quotes, reminders +│ │ ├── ServerModule.cs / ServerSlashModule.cs +│ │ ├── TicketModule.cs +│ │ ├── RulesModule.cs +│ │ ├── EmbedModule.cs +│ │ ├── QuoteModule.cs +│ │ └── ReminderModule.cs +│ ├── Fun/ # Entertainment & games +│ │ ├── FunModule.cs +│ │ ├── DuelSlashModule.cs +│ │ └── Casino/ # Casino slash commands +│ ├── Utils/ # Search, conversion, flights, weather +│ │ ├── SearchModule.cs +│ │ ├── ConvertModule.cs +│ │ ├── AirportModule.cs +│ │ └── Weather/ # Weather commands +│ └── Code/ # Coding tips, Unity help +│ ├── CodeTipModule.cs +│ ├── TipModule.cs +│ └── Unity/UnityHelp/ # Help forum, canned responses, FAQ │ ├── Services/ # Business logic and background services -│ ├── CommandHandlingService.cs # Command routing -│ ├── DatabaseService.cs # MySQL connection/queries -│ ├── UserService.cs # XP, levels, karma, profile cards -│ ├── LoggingService.cs # Console/channel/file logging -│ ├── ModerationService.cs # Audit logging, invite enforcement -│ ├── Casino/ # Token management, game sessions -│ ├── Moderation/ # Moderation sub-services -│ ├── Recruitment/ # Recruitment workflow -│ ├── Tips/ # Tip database management -│ └── UnityHelp/ # Help thread management +│ ├── CommandHandlingService.cs # Command routing (core) +│ ├── DatabaseService.cs # PostgreSQL connection/queries (core) +│ ├── LoggingService.cs # Console/channel/file logging (core) +│ ├── UpdateService.cs # Update checking (core) +│ ├── Profiles/ # Profile cards, XP, karma, birthdays +│ ├── Server/ # Welcome, audit log, embed parsing, reminders +│ ├── Fun/ # Duels, Miku, Casino/ +│ ├── Utils/ # Search, airport, currency, Weather/ +│ └── Code/ # Code checking, Tips/, Unity/ (docs, feeds, UnityHelp/) │ ├── Settings/ # Configuration files │ ├── Settings.json # Main config (gitignored) @@ -123,9 +135,9 @@ DiscordBot/ | What | Where | |------|-------| -| New text command | `Modules/` — add to existing module or create `*Module.cs` | -| New slash command | `Modules/` — add to existing module or create `*SlashModule.cs` | -| New business logic | `Services/` — create `*Service.cs`, register in `Program.cs` | +| New text command | `Modules//` — add to existing module or create `*Module.cs` | +| New slash command | `Modules//` — add to existing module or create `*SlashModule.cs` | +| New business logic | `Services//` — create `*Service.cs`, register in `Program.cs` | | New DB queries | `Extensions/` — add to `*Repository.cs` | | New game type | `Domain/Casino/Games/` — implement `ICasinoGame` | | New precondition | `Attributes/` — extend `PreconditionAttribute` | @@ -133,6 +145,16 @@ DiscordBot/ | Static assets | `Assets/` — fonts, images, skins (baked into Docker image) | | Runtime data | `SERVER/` — auto-generated, gitignored | +### Module/Service Domain Groups + +| Domain | Modules | Services | +|--------|---------|----------| +| **Profiles** | ProfileModule, RankModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | +| **Server** | ServerModule, ServerSlashModule, TicketModule, RulesModule, EmbedModule, QuoteModule, ReminderModule | ServerService, WelcomeService, AuditLogService, EveryoneScoldService, EmbedParsingService, ReminderService, RecruitService | +| **Fun** | FunModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | +| **Utils** | SearchModule, ConvertModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | +| **Code** | CodeTipModule, TipModule, Unity/UnityHelp/ | CodeCheckService, Tips/, Unity/ (feeds, docs, UnityHelp/) | + ### Testing - Tests go in `DiscordBot.Tests/` diff --git a/docs/plans/done/module-service-reorganization.md b/docs/plans/done/module-service-reorganization.md new file mode 100644 index 00000000..5a72e61e --- /dev/null +++ b/docs/plans/done/module-service-reorganization.md @@ -0,0 +1,118 @@ +# Module & Service Directory Reorganization + +## Status: In Progress + +## Summary + +Reorganize flat Modules/ and Services/ directories into domain-based subdirectories. Namespaces will match directory structure. + +## Modules Layout + +``` +Modules/ +├── Profiles/ +│ ├── ProfileModule.cs +│ ├── RankModule.cs +│ └── BirthdayModule.cs +├── Server/ +│ ├── ServerModule.cs +│ ├── ServerSlashModule.cs +│ ├── TicketModule.cs +│ ├── RulesModule.cs +│ ├── EmbedModule.cs +│ ├── QuoteModule.cs +│ └── ReminderModule.cs +├── Fun/ +│ ├── FunModule.cs +│ ├── DuelSlashModule.cs +│ └── Casino/ +│ ├── CasinoSlashModule.cs +│ └── CasinoSlashModule.Games.cs +├── Utils/ +│ ├── SearchModule.cs +│ ├── ConvertModule.cs +│ ├── AirportModule.cs +│ └── Weather/ +│ ├── WeatherModule.cs +│ └── WeatherContainers.cs +└── Code/ + ├── CodeTipModule.cs + ├── TipModule.cs + └── Unity/ + └── UnityHelp/ + ├── CannedInteractiveModule.cs + ├── CannedResponseModule.cs + ├── GeneralHelpModule.cs + ├── UnityHelpInteractiveModule.cs + └── UnityHelpModule.cs +``` + +## Services Layout + +``` +Services/ +├── DatabaseService.cs (root - core) +├── CommandHandlingService.cs (root - core) +├── LoggingService.cs (root - core) +├── UpdateService.cs (root - core) +├── Profiles/ +│ ├── ProfileCardService.cs +│ ├── XpService.cs +│ ├── KarmaService.cs +│ ├── KarmaResetService.cs +│ ├── UserExtendedService.cs +│ └── BirthdayAnnouncementService.cs +├── Server/ +│ ├── ServerService.cs +│ ├── WelcomeService.cs +│ ├── AuditLogService.cs +│ ├── EveryoneScoldService.cs +│ ├── EmbedParsingService.cs +│ └── ReminderService.cs +├── Fun/ +│ ├── DuelService.cs +│ ├── MikuService.cs +│ └── Casino/ +│ ├── CasinoService.cs +│ ├── GameService.cs +│ └── TransactionFormatter.cs +├── Utils/ +│ ├── SearchService.cs +│ ├── AirportService.cs +│ ├── CurrencyService.cs +│ └── Weather/ +│ └── WeatherService.cs +├── Code/ +│ ├── CodeCheckService.cs +│ ├── Tips/ +│ │ ├── TipService.cs +│ │ └── Components/ +│ │ └── Tip.cs +│ └── Unity/ +│ ├── UnityDocParser.cs +│ ├── ReleaseNotesParser.cs +│ ├── FeedService.cs +│ └── UnityHelp/ +│ ├── CannedResponseService.cs +│ ├── UnityHelpService.cs +│ └── Components/ +│ ├── HelpBotMessage.cs +│ └── ThreadContainer.cs +└── Recruitment/ + └── RecruitService.cs +``` + +## Namespace Strategy + +New namespaces match directory paths. All new sub-namespaces added to `GlobalUsings.cs` to avoid mass-editing using statements across the project. + +## Checklist + +- [ ] Create directory structure +- [ ] Move Module files +- [ ] Move Service files +- [ ] Update namespace declarations in moved files +- [ ] Update GlobalUsings.cs with new namespaces +- [ ] Build and verify compilation +- [ ] Run tests +- [ ] Update documentation diff --git a/docs/plans/done/userservice-usermodule-split.md b/docs/plans/done/userservice-usermodule-split.md new file mode 100644 index 00000000..1c328c64 --- /dev/null +++ b/docs/plans/done/userservice-usermodule-split.md @@ -0,0 +1,207 @@ +--- +post_title: "UserService & UserModule Split Plan" +author1: "Copilot" +post_slug: "userservice-usermodule-split" +microsoft_alias: "" +featured_image: "" +categories: [] +tags: ["refactor", "architecture"] +ai_note: "AI-generated plan" +summary: "Detailed plan for extracting focused services from UserService and focused modules from UserModule" +post_date: "2026-04-06" +--- + +## Overview + +Split `UserService` (god service) and `UserModule` (god module) into focused, +single-responsibility classes. Each service extraction is paired with its +corresponding module extraction where applicable. + +## Services to Extract from UserService + +### S1. XpService + +- **State**: `_xpCooldown`, `_xpMin/MaxPerMessage`, `_xpMin/MaxCooldown`, + `_noXpChannels`, `_rand` +- **Events**: `MessageReceived → UpdateXp` +- **Methods**: `UpdateXp()`, `LevelUp()`, `GetXpLow()`, `GetXpHigh()` +- **Dependencies**: `DatabaseService`, `ILoggingService`, `BotSettings`, + `UserSettings` +- **Note**: `GetXpLow/High` already duplicated in `ProfileCardService` — will + remain duplicated for now (different ownership) + +### S2. KarmaService + +- **State**: `_thanksCooldown`, `_canEditThanks`, `_thanksRegex`, + `_thanksCooldownTime`, `_thanksMinJoinTime` +- **Events**: `MessageReceived → Thanks`, + `MessageUpdated → ThanksEdited` +- **Methods**: `Thanks()`, `ThanksEdited()` +- **Dependencies**: `DatabaseService`, `ILoggingService`, `BotSettings`, + `UserSettings` + +### S3. CodeCheckService + +- **State**: `CodeReminderCooldown`, `_codeBlockWarnPatterns`, `_x3CodeBlock`, + `_x2CodeBlock`, `_codeReminderCooldownTime`, `_maxCodeBlockLengthWarning`, + `CodeFormattingExample`, `CodeReminderFormattingExample` +- **Events**: `MessageReceived → CodeCheck` +- **Methods**: `CodeCheck()` +- **Dependencies**: `ILoggingService`, `BotSettings`, `UserSettings`, + `UpdateService` +- **Public API**: `CodeFormattingExample` (used by `CodeTipModule`), + `CodeReminderCooldown` (used by `CodeTipModule`) +- **Persistence**: `UpdateLoop()`, `SaveData()`, `LoadData()` move here — + only `CodeReminderCooldown` is persisted + +### S4. EveryoneScoldService + +- **State**: `_everyoneScoldCooldown` +- **Events**: `MessageReceived → ScoldForAtEveryoneUsage` +- **Methods**: `ScoldForAtEveryoneUsage()` +- **Dependencies**: `BotSettings` +- **Tiny service** — could be inlined into a message filter, but extracting + keeps UserService clean + +### S5. MikuService + +- **State**: `_mikuMentioned`, `_mikuCooldownTime`, `_mikuRegex`, `_mikuReply` +- **Events**: `MessageReceived → MikuCheck` (currently commented out) +- **Methods**: `MikuCheck()` +- **Dependencies**: None (standalone easter egg) +- **Note**: Currently disabled. Will extract as-is with the event subscription + commented out + +### What Stays in UserService (→ renamed to WelcomeService) + +- Welcome block (`UserJoined`, `DelayedWelcomeService`, `ProcessWelcomeUser`, + `WelcomeMessage`, `DMFormattedWelcome`, `GetWelcomeEmbed`, + `CheckForWelcomeMessage`, `UserIsTyping`) + +`UserLeft` and `UserUpdated` move to `AuditLogService` (step 1, pre-split). + +After extraction, `UserService` is renamed to **WelcomeService** (step 17). + +## Modules to Extract from UserModule + +All new modules use `[Group("UserModule"), Alias("")]` to stay visible in +`!help`. + +### M1. ProfileModule — ALREADY DONE + +- Commands: `!profile` (2 overloads) +- Dependencies: `ProfileCardService`, `ILoggingService` + +### M2. QuoteModule + +- Commands: `!quote` (3 overloads) +- Dependencies: None beyond `Context` +- Self-contained, no service dependency + +### M3. RulesModule + +- Commands: `!rules` (2 overloads), `!globalrules`, `!welcome`, `!channels`, + `!faq` +- Dependencies: `Rules`, `UserService` (DMFormattedWelcome), `UpdateService` + (GetFaqData) +- FAQ is server info/guidance, fits with rules thematically +- Includes helper methods: `SearchFaqs`, `ListFaqs`, `GetFaqEmbed`, + `FormatFaq`, `CalculateScore`, `ParseNumber` + +### M4. RankModule + +- Commands: `!top`, `!topkarma`, `!topkarmaweekly`, `!topkarmamonthly`, + `!topkarmayearly` +- Dependencies: `DatabaseService`, `ILoggingService` +- Includes helper: `GenerateRankEmbedFromList()` + +### M5. ProfileModule update + +- Add `!karma` and `!joindate` to existing `ProfileModule` +- Dependencies already satisfied (`DatabaseService` to add) + +### M6. CodeTipModule + +- Commands: `!codetip`, `!disablecodetips` +- Dependencies: `CodeCheckService` (new, replaces `UserService` for + `CodeFormattingExample` and `CodeReminderCooldown`) + +### M7. FunModule + +- Commands: `!slap`, `!coinflip`, `!roll` (2 overloads), `!d20` +- Dependencies: `BotSettings` (slap tables) +- State: `_random`, `_slapObjects`, `_slapFails` + +### M8. SearchModule + +- Commands: `!search` (2 overloads), `!manual`, `!doc`, `!wiki` +- Dependencies: `BotSettings` (API URLs), `ILoggingService` +- Uses `HtmlAgilityPack`, `UnityAPI` +- Includes helpers: various HTML scraping methods + +### M9. BirthdayModule + +- Commands: `!birthday` (2 overloads) +- Dependencies: `UserExtendedService`, `DatabaseService`, `ILoggingService` +- Includes helper: `GenerateBirthdayCard()` + +### M10. ConvertModule + +- Commands: `!ftoc`, `!ctof`, `!translate` (2 overloads), `!currency` + (2 overloads), `!currencyname` +- Dependencies: `CurrencyService` +- Groups temperature, translation, and currency conversion — all + "convert/translate" commands + +### M11. WeatherModule update + +- Move `!setcity` and `!removecity` from UserModule into existing + `WeatherModule` (they're only used by weather) +- Dependencies: `WeatherService`, `UserExtendedService` (already in + WeatherModule) + +### M12. ServerModule + ServerService + +- **ServerService**: `GetGatewayPing()` extracted from UserService +- Commands: `!ping`, `!members`, `!help` +- Dependencies: `ServerService`, `CommandHandlingService` +- `!help` moves here as the final extraction from UserModule + +### What Stays in UserModule + +Nothing — **UserModule is deleted** once all commands are extracted (step 16). + +## Execution Order + +Paired service+module commits where applicable. +Each commit includes DI registration in `Program.cs`. + +| Step | Service | Module | Commit message | +|------|---------|--------|----------------| +| 0 | AuditLogService update | — | `refactor(services): move UserLeft and UserUpdated to AuditLogService` | +| 1 | XpService | — | `refactor(services): extract XpService from UserService` | +| 2 | KarmaService | — | `refactor(services): extract KarmaService from UserService` | +| 3 | CodeCheckService | CodeTipModule | `refactor: extract CodeCheckService and CodeTipModule` | +| 4 | EveryoneScoldService | — | `refactor(services): extract EveryoneScoldService from UserService` | +| 5 | MikuService | — | `refactor(services): extract MikuService from UserService` | +| 6 | — | QuoteModule | `refactor(modules): extract QuoteModule from UserModule` | +| 7 | — | RulesModule (+FAQ) | `refactor(modules): extract RulesModule from UserModule` | +| 8 | — | RankModule | `refactor(modules): extract RankModule from UserModule` | +| 9 | — | ProfileModule update | `refactor(modules): move karma and joindate to ProfileModule` | +| 10 | — | FunModule | `refactor(modules): extract FunModule from UserModule` | +| 11 | — | SearchModule | `refactor(modules): extract SearchModule from UserModule` | +| 12 | — | BirthdayModule | `refactor(modules): extract BirthdayModule from UserModule` | +| 13 | — | ConvertModule | `refactor(modules): extract ConvertModule from UserModule` | +| 14 | — | WeatherModule update | `refactor(modules): move city commands to WeatherModule` | +| 15 | ServerService | ServerModule (+!help) | `refactor: extract ServerService and ServerModule` | +| 16 | — | delete UserModule | `refactor: delete UserModule after full extraction` | +| 17 | rename UserService→WelcomeService | — | `refactor: rename UserService to WelcomeService` | + +## Notes + +- Every new module gets `[Group("UserModule"), Alias("")]` for `!help` + compatibility +- Every new service registered as singleton in `Program.cs` +- Build verified after every step +- Peer review at the end (or after each major batch) +- Audit doc S1 checkmark added on the final cleanup commit diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index e702a26a..1c9f60e7 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -12,119 +12,74 @@ metadata: data: Settings.json: | { - "token": "${BOT_TOKEN}", + "Token": "${BOT_TOKEN}", "DbConnectionString": "Host=postgresql;Port=5432;Database=udcbot;Username=udcbot;Password=${DB_PASSWORD}", - "invite": "InviteLink", // Currently Unused - /*Server Info*/ - "serverRootPath": "./SERVER", - "assetsRootPath": "./Assets", - /* Base info */ - "prefix": "!", - "Administrator": "838030241103478805", - "ModeratorRoleId": "769010537119088690", - "guildId": "566084539664039938", // Replace with your servers guild ID - /* All assignable roles as of 29/04/21 */ - "UserAssignableRoles": { - "desc": "All normal user assignable roles available", - "roles": [ - "Audio-Engineers", - "Technical-Artists", - "Animators", - "3D-Artists", - "2D-Artists", - "XR-Developers", - "Programmers", - "Writers", - "Game-Designers", - "Generalists", - "Hobbyists", - "Students" - ] - }, - /* Channel IDs for certain channels. */ - "generalChannel": { // Off-topic - "desc": "General-Chat Channel", - "id": "566084539664039944" - }, - "botAnnouncementChannel": { // Most bot logs will go here - "desc": "Bot-Announcement Channel", - "id": "567628191221547008" - }, - "announcementsChannel": { // Not used by bot - "desc": "General Announcement Channel", - "id": "838030934728376320" // Currently Unused 29/04/21 + "Invite": "InviteLink", + "ServerRootPath": "./SERVER", + "AssetsRootPath": "./Assets", + "Prefix": "!", + "GuildId": "566084539664039938", + "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", + + "Channels": { + "General": { "desc": "General-Chat Channel", "id": "566084539664039944" }, + "Introduction": { "desc": "Introduction Channel", "id": "1198575542467838044" }, + "BotAnnouncement": { "desc": "Bot-Announcement Channel", "id": "567628191221547008" }, + "BotCommands": { "desc": "Bot-Commands Channel", "id": "599583999379243008" }, + "UnityNews": { "desc": "Unity News Channel", "id": "1022102744552710154" }, + "UnityReleases": { "desc": "Unity Releases Channel", "id": "1022102744552710154" }, + "Rules": { "desc": "The Rules", "id": "825932695698669618" }, + "Recruitment": { "desc": "Channel for job postings", "id": "1134672948356202678" }, + "GenericHelp": { "desc": "Unity-Help Channel", "id": "1028254982748778516" }, + "BirthdayAnnouncement": { "desc": "Channel for birthday announcements", "id": "566084539664039944" }, + "ComplaintCategoryId": "874631331810799626", + "ComplaintPrefix": "Complaint", + "ClosedComplaintCategoryId": "0", + "ClosedComplaintPrefix": "Closed-" }, - "botCommandsChannel": { - "desc": "Bot-Commands Channel", - "id": "599583999379243008" + + "Roles": { + "Moderator": "769010537119088690", + "SubsReleases": "769870886743703584", + "SubsNews": "0", + "TipsUser": "603187742096228374" }, - "unityNewsChannel": { - "desc": "Unity News Channel", - "id": "1022102744552710154" + + "ApiKeys": { + "Weather": "${WEATHER_KEY}", + "Flight": "${FLIGHT_KEY}", + "FlightSecret": "${FLIGHT_SECRET}", + "AirLab": "${AIRLAB_KEY}" }, - "UnityReleasesChannel": { - "desc": "Unity Releases Channel", - "id": "1022102744552710154" + + "Recruitment": { + "Enabled": true, + "TagLookingToHire": "1134673961779724480", + "TagLookingForWork": "1134673990993051658", + "TagUnpaidCollab": "1134674009292804117", + "TagPositionFilled": "1134674041450545283" }, - "RulesChannel": { - "desc": "The Rules", - "id": "825932695698669618" + + "UnityHelp": { + "BabySitterEnabled": true, + "TagResolved": "1028255134356086784", + "TipImageDirectory": "tips" }, - "ReportedMessageChannel": { - "desc": "Reported Message Channel", - "id": "567628191221547008" - }, - /* Role Ids */ - "mutedRoleID": "682432235445682194", - "SubsReleasesRoleId": "769870886743703584", - "TipsUserRoleId": "603187742096228374", - "TipsAuthorRoleId": "603187742096228374", - /*Complaints Channels Stuff*/ - "complaintCategoryId": "874631331810799626", - "complaintChannelPrefix": "Complaint", - /*Commands Configuration*/ - "wikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", - "IPGeolocationAPIKey": "${IPGEO_KEY}", - "WeatherAPIKey": "${WEATHER_KEY}", - "FlightAPIKey": "${FLIGHT_KEY}", - "FlightAPISecret": "${FLIGHT_SECRET}", - "AirLabAPIKey": "${AIRLAB_KEY}", - /* Job Recruitment Service */ - "RecruitmentChannel": { - "desc": "Channel for job postings", - "id": "1134672948356202678" - }, - "RecruitmentServiceEnabled": true, - "TagLookingToHire": "1134673961779724480", - "TagLookingForWork": "1134673990993051658", - "TagUnpaidCollab": "1134674009292804117", - "TagPositionFilled": "1134674041450545283", - /* Unity Help Service */ - "UnityHelpBabySitterEnabled": true, - "genericHelpChannel": { // Unity-help - "desc": "Unity-Help Channel", - "id": "1028254982748778516" - }, - "TagUnitHelpResolvedTag": "1028255134356086784", - "IntroductionChannel": { - "desc": "Introduction Channel", - "id": "1198575542467838044" + + "FunCommands": { + "SlapObjectsTable": "Settings/udc-slap.txt", + "SlapChoices": [ + "developer manual", + "devonlepment server" + ], + "SlapFails": [ + "developing a rash", + "developing a skin condition" + ] }, - "IntroductionWatcherServiceEnabled": true, - "UserModuleSlapObjectsTable": "Settings/udc-slap.txt", - "UserModuleSlapChoices": [ - "developer manual", - "devonlepment server" - ], - "UserModuleSlapFails": [ - "developing a rash", - "developing a skin condition" - ], - "TipImageDirectory": "tips", - "BirthdayAnnouncementEnabled": true, - "BirthdayCheckIntervalMinutes": 1, - "BirthdayAnnouncementChannel": { - "desc": "Channel for birthday announcements", - "id": "566084539664039944" + + "Birthday": { + "Enabled": true, + "CheckIntervalMinutes": 1 } } diff --git a/k8s/dev/bot-settings-config.yaml b/k8s/dev/bot-settings-config.yaml index 445f79a0..39db80f9 100644 --- a/k8s/dev/bot-settings-config.yaml +++ b/k8s/dev/bot-settings-config.yaml @@ -197,7 +197,6 @@ data: "谢谢" ], "thanksCooldown": 60, //In seconds - "thanksReminderCooldown": 86400, //24 hours in seconds "thanksMinJoinTime": 600, /*Xp parameters*/ @@ -207,13 +206,7 @@ data: "xpMaxCooldown": 180, /*Code parameters*/ - "codeReminderCooldown": 86400, - - "isSomeoneThere": [ - "is anyone around?", - "can someone help?", - "can someone help me?" - ] + "codeReminderCooldown": 86400 } --- apiVersion: v1 diff --git a/k8s/prod/bot-config.yaml b/k8s/prod/bot-config.yaml index 5354d590..de4335cd 100644 --- a/k8s/prod/bot-config.yaml +++ b/k8s/prod/bot-config.yaml @@ -12,132 +12,81 @@ metadata: data: Settings.json: | { - "token": "${BOT_TOKEN}", + "Token": "${BOT_TOKEN}", "DbConnectionString": "Host=postgresql;Port=5432;Database=udcbot;Username=udcbot;Password=${DB_PASSWORD}", - "invite": "https://discord.gg/bu3bbby", // Currently Unused - /* DB Info*/ - /*Server Info*/ - "serverRootPath": "./SERVER", - "assetsRootPath": "./Assets", - /* Base info */ - "prefix": "!", - "Administrator": "493514411026153482", - "ModeratorRoleId": "493514490504019969", - "ModeratorCommandsEnabled": false, - "guildId": "493510779866316801", // Replace with your servers guild ID - /* All assignable roles as of 29/04/21 */ - "UserAssignableRoles": { - "desc": "All normal user assignable roles available", - "roles": [ - "Audio-Engineers", - "Technical-Artists", - "Animators", - "3D-Artists", - "2D-Artists", - "XR-Developers", - "Programmers", - "Writers", - "Game-Designers", - "Generalists", - "Hobbyists", - "Students" - ] - }, - /* Channel IDs for certain channels. */ - "generalChannel": { // Off-topic - "desc": "General-Chat Channel", - "id": "493511024037724180" - }, - "botAnnouncementChannel": { // Most bot logs will go here - "desc": "Bot-Announcement Channel", - "id": "493512007144833055" - }, - "announcementsChannel": { // Not used by bot - "desc": "General Announcement Channel", - "id": "493510992320528404" // Currently Unused 29/04/21 + "Invite": "https://discord.gg/bu3bbby", + "ServerRootPath": "./SERVER", + "AssetsRootPath": "./Assets", + "Prefix": "!", + "GuildId": "493510779866316801", + "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", + + "Channels": { + "General": { "desc": "General-Chat Channel", "id": "493511024037724180" }, + "Introduction": { "desc": "Introduction Channel", "id": "768488410959708210" }, + "BotAnnouncement": { "desc": "Bot-Announcement Channel", "id": "493512007144833055" }, + "BotCommands": { "desc": "Bot-Commands Channel", "id": "493512044973260811" }, + "UnityNews": { "desc": "Unity News Channel", "id": "1142423451383119975" }, + "UnityReleases": { "desc": "Unity Releases Channel", "id": "1142423451383119975" }, + "Rules": { "desc": "The Rules", "id": "519890141805019137" }, + "Recruitment": { "desc": "Channel for job postings", "id": "1019677109171527750" }, + "GenericHelp": { "desc": "Unity-Help Channel", "id": "1019663870798856212" }, + "BirthdayAnnouncement": { "desc": "Channel for birthday announcements", "id": "493511024037724180" }, + "ComplaintCategoryId": "520853507851681797", + "ComplaintPrefix": "Complaint", + "ClosedComplaintCategoryId": "662084543662129175", + "ClosedComplaintPrefix": "Closed-" }, - "botCommandsChannel": { - "desc": "Bot-Commands Channel", - "id": "493512044973260811" + + "Roles": { + "Moderator": "493514490504019969", + "SubsReleases": "523205962279157771", + "SubsNews": "1209260621342707772", + "TipsUser": "493514563736698880" }, - "RulesChannel": { - "desc": "The Rules", - "id": "519890141805019137" + + "ApiKeys": { + "Weather": "${WEATHER_KEY}", + "Flight": "${FLIGHT_KEY}", + "FlightSecret": "${FLIGHT_SECRET}", + "AirLab": "${AIRLAB_KEY}" }, - "ReportedMessageChannel": { - "desc": "Reported Message Channel", - "id": "993446104790220840" + + "Recruitment": { + "Enabled": false, + "TagLookingToHire": "1019680606067630151", + "TagLookingForWork": "1019680763756695653", + "TagUnpaidCollab": "1019680795641774110", + "TagPositionFilled": "1052258665530408991", + "EditPermissionAccessTimeMin": 3 }, - /* Role Ids */ - "mutedRoleID": "493514472942600202", - "SubsNewsRoleId": "1209260621342707772", - "SubsReleasesRoleId": "523205962279157771", - "TipsUserRoleId": "493514563736698880", - "TipsAuthorRoleId": "99999", - /*Complaints Channels Stuff*/ - "complaintCategoryId": "520853507851681797", - "complaintChannelPrefix": "Complaint", - "closedComplaintCategoryId": "662084543662129175", - "closedComplaintChannelPrefix": "Closed-", - /*Commands Configuration*/ - "wikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", - "IPGeolocationAPIKey": "${IPGEO_KEY}", - "WeatherAPIKey": "${WEATHER_KEY}", - "FlightAPIKey": "${FLIGHT_KEY}", - "FlightAPISecret": "${FLIGHT_SECRET}", - "AirLabAPIKey": "${AIRLAB_KEY}", - /* Feed Service */ - "UnityNewsChannel": { - "desc": "Unity News Channel", - "id": "1142423451383119975" + + "UnityHelp": { + "BabySitterEnabled": false, + "TagResolved": "1019672922811551815", + "TipImageDirectory": "tips" }, - "UnityReleasesChannel": { - "desc": "Unity Releases Channel", - "id": "1142423451383119975" - }, - /* Recruitment Service */ - "RecruitmentServiceEnabled": false, - "RecruitmentChannel": { - "desc": "Channel for job postings", - "id": "1019677109171527750" - }, - "TagLookingToHire": "1019680606067630151", - "TagLookingForWork": "1019680763756695653", - "TagUnpaidCollab": "1019680795641774110", - "TagPositionFilled": "1052258665530408991", - "EditPermissionAccessTimeMin": 3, - /* Unity Help Service */ - "UnityHelpBabySitterEnabled": false, - "GenericHelpChannel": { // Unity-help - "desc": "Unity-Help Channel", - "id": "1019663870798856212" - }, - "TagUnitHelpResolvedTag": "1019672922811551815", - "IntroductionChannel": { - "desc": "Introduction Channel", - "id": "768488410959708210" + + "FunCommands": { + "SlapObjectsTable": "Settings/udc-slap.txt", + "SlapChoices": [ + "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", + "life choice", "bug report", "unhandled exception", "null pointer", "keyboard", + "kinematic rigidbody", "gameobject", "trigger collider", "update cycle", "json file", + "large language model", "hosting invoice", "quality of life patch", + "game jam submission", "bucket of fried chicken", "anime waifu pillow", + "network-attached storage cabinet", "baguette", "moldy cheese", + "cup noodle", "game jam submission", "game library listing", + "cheese wheel", "banana peel", "unresolved bug", "low poly donut" + ], + "SlapFails": [ + "hurting themselves", "making themselves look foolish", "tripping on it", + "dropping it on their toes", "breaking their screen with it" + ] }, - "IntroductionWatcherServiceEnabled": true, - "UserModuleSlapObjectsTable": "Settings/udc-slap.txt", - "UserModuleSlapChoices": [ - "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", - "life choice", "bug report", "unhandled exception", "null pointer", "keyboard", - "kinematic rigidbody", "gameobject", "trigger collider", "update cycle", "json file", - "large language model", "hosting invoice", "quality of life patch", - "game jam submission", "bucket of fried chicken", "anime waifu pillow", - "network-attached storage cabinet", "baguette", "moldy cheese", - "cup noodle", "game jam submission", "game library listing", - "cheese wheel", "banana peel", "unresolved bug", "low poly donut" - ], - "UserModuleSlapFails": [ - "hurting themselves", "making themselves look foolish", "tripping on it", - "dropping it on their toes", "breaking their screen with it" - ], - "TipImageDirectory": "tips", - "BirthdayAnnouncementEnabled": true, - "BirthdayCheckIntervalMinutes": 60, - "BirthdayAnnouncementChannel": { - "desc": "Channel for birthday announcements", - "id": "493511024037724180" + + "Birthday": { + "Enabled": true, + "CheckIntervalMinutes": 60 } } diff --git a/k8s/prod/bot-settings-config.yaml b/k8s/prod/bot-settings-config.yaml index edc70cd4..399cfcc0 100644 --- a/k8s/prod/bot-settings-config.yaml +++ b/k8s/prod/bot-settings-config.yaml @@ -133,7 +133,6 @@ data: "Slàinte" ], "thanksCooldown": 60, //In seconds - "thanksReminderCooldown": 86400, //24 hours in seconds "thanksMinJoinTime": 600, /*Xp parameters*/ @@ -143,13 +142,7 @@ data: "xpMaxCooldown": 180, /*Code parameters*/ - "codeReminderCooldown": 86400, - - "isSomeoneThere": [ - "is anyone around?", - "can someone help?", - "can someone help me?" - ] + "codeReminderCooldown": 86400 } --- apiVersion: v1