diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4bab0ed..b4a83fe 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,7 +16,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v2 with: - dotnet-version: 7.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/Asterion.Test/SplitExtensionTests.cs b/Asterion.Test/SplitExtensionTests.cs new file mode 100644 index 0000000..278a8bc --- /dev/null +++ b/Asterion.Test/SplitExtensionTests.cs @@ -0,0 +1,53 @@ +using Asterion.Extensions; + +namespace Asterion.Test; + +[TestFixture] +public class SplitExtensionTests +{ + [Test] + public void TestSliceOnEmptyArray() + { + var array = Array.Empty(); + + var result = array.Split(10).ToArray(); + + Assert.That(result, Is.Empty); + } + + [Test] + public void TestSplitEvenArray() + { + int[] numbers = {1, 2, 3, 4, 5, 6}; + var segments = numbers.Split(2).ToArray(); + + Assert.Multiple(() => + { + Assert.That(segments, Has.Length.EqualTo(3)); + Assert.That(segments[0], Has.Count.EqualTo(2)); + Assert.That(segments[0].Array, Is.Not.Null); + Assert.That(segments[0].Array![segments[0].Offset], Is.EqualTo(1)); + Assert.That(segments[0].Array![segments[0].Offset + 1], Is.EqualTo(2)); + }); + } + + [Test] + public void TestSplitOddArray() + { + int[] numbers = {1, 2, 3, 4, 5, 6, 7}; + var segments = numbers.Split(3).ToArray(); + + Assert.Multiple(() => + { + Assert.That(segments, Has.Length.EqualTo(3)); + Assert.That(segments[0], Has.Count.EqualTo(3)); + Assert.That(segments[0].Array, Is.Not.Null); + Assert.That(segments[0].Array![segments[0].Offset], Is.EqualTo(1)); + Assert.That(segments[0].Array![segments[0].Offset + 1], Is.EqualTo(2)); + Assert.That(segments[0].Array![segments[0].Offset + 2], Is.EqualTo(3)); + + // Last segment should have 1 element + Assert.That(segments[2], Has.Count.EqualTo(1)); + }); + } +} \ No newline at end of file diff --git a/Asterion/Asterion.cs b/Asterion/Asterion.cs index 7896c02..325d570 100644 --- a/Asterion/Asterion.cs +++ b/Asterion/Asterion.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Modrinth; +using Quartz; using Serilog; using RunMode = Discord.Commands.RunMode; @@ -19,9 +20,12 @@ namespace Asterion; public class Asterion { private readonly IConfiguration _config; + private int _shardId; - public Asterion() + public Asterion(int shardId) { + _shardId = shardId; + _config = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("config.json", false, true) @@ -78,12 +82,25 @@ public async Task MainAsync() await client.LoginAsync(TokenType.Bot, _config.GetValue("token")); await client.StartAsync(); + // We start the stats service after the client has been logged in + // so that we can get the correct guild count + services.GetRequiredService().Initialize(); + + // We start the scheduler after the client has been logged in + // so that we can get the correct guild count + var scheduler = await services.GetRequiredService().GetScheduler(); + await scheduler.Start(); + // Disconnect from Discord when pressing Ctrl+C Console.CancelKeyPress += (_, args) => { args.Cancel = true; + logger.LogInformation("{Key} pressed, exiting bot", args.SpecialKey); + logger.LogInformation("Stopping the scheduler"); + scheduler.Shutdown(true).Wait(); + logger.LogInformation("Logging out from Discord"); client.LogoutAsync().Wait(); logger.LogInformation("Stopping the client"); @@ -94,11 +111,7 @@ public async Task MainAsync() args.Cancel = false; }; - - // We start the stats service after the client has been logged in - // so that we can get the correct guild count - services.GetRequiredService().Initialize(); - + await Task.Delay(Timeout.Infinite); } @@ -140,9 +153,24 @@ private ServiceProvider ConfigureServices() .AddHttpClient() .AddDbContext() .AddSingleton() + .AddSingleton() .AddMemoryCache() .AddLogging(configure => configure.AddSerilog(dispose: true)); + services.AddQuartz(q => + { + q.UseInMemoryStore(); + }); + services.AddQuartzHostedService(options => + { + options.WaitForJobsToComplete = true; + }); + + services.AddLocalization(options => + { + options.ResourcesPath = "Resources"; + }); + if (IsDebug()) services.Configure(options => options.MinLevel = LogLevel.Debug); else diff --git a/Asterion/Asterion.csproj b/Asterion/Asterion.csproj index 53abc81..6856b9d 100644 --- a/Asterion/Asterion.csproj +++ b/Asterion/Asterion.csproj @@ -16,6 +16,7 @@ + @@ -32,6 +33,9 @@ + + + @@ -40,4 +44,19 @@ + + + ResXFileCodeGenerator + responses.en-US.Designer.cs + + + + + + True + True + responses.en-US.resx + + + diff --git a/Asterion/Common/AsterionInteractionModuleBase.cs b/Asterion/Common/AsterionInteractionModuleBase.cs new file mode 100644 index 0000000..77ff347 --- /dev/null +++ b/Asterion/Common/AsterionInteractionModuleBase.cs @@ -0,0 +1,58 @@ +using System.Globalization; +using Asterion.EmbedBuilders; +using Asterion.Interfaces; +using Discord; +using Discord.Interactions; +using Color = Discord.Color; + +namespace Asterion.Common; + +public class AsterionInteractionModuleBase : InteractionModuleBase +{ + protected CultureInfo? CommandCultureInfo { get; set; } + protected ILocalizationService LocalizationService { get; } + public AsterionInteractionModuleBase(ILocalizationService localizationService) + { + LocalizationService = localizationService; + } + + protected async Task FollowupWithSearchResultErrorAsync(Services.Modrinth.SearchResult status) + { + if (status.Success) + { + throw new ArgumentException("SearchResult was successful, but was expected to be an error"); + } + + string title = LocalizationService.Get("Modrinth_Search_Unsuccessful", CommandCultureInfo); + + string? description; + switch (status.SearchStatus) + { + case Services.Modrinth.SearchStatus.ApiDown: + description = LocalizationService.Get("Error_ModrinthApiUnavailable", CommandCultureInfo); + title += ". " + LocalizationService.Get("Error_TryAgainLater", CommandCultureInfo); + break; + case Services.Modrinth.SearchStatus.NoResult: + description = LocalizationService.Get("Modrinth_Search_NoResult_WithQuery", CommandCultureInfo, new object[] {status.Query}); + break; + case Services.Modrinth.SearchStatus.UnknownError: + description = LocalizationService.Get("Error_Unknown", CommandCultureInfo); + title += ". " + LocalizationService.Get("Error_TryAgainLater", CommandCultureInfo); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + var embed = CommonEmbedBuilder.GetErrorEmbedBuilder(title, description).Build(); + + await FollowupAsync(embeds: new[] {embed}); + } + + public override void BeforeExecute(ICommandInfo cmd) + { + // We currently set US culture for all commands + CommandCultureInfo = CultureInfo.GetCultureInfo("en-US"); + + base.BeforeExecute(cmd); + } +} \ No newline at end of file diff --git a/Asterion/EmbedBuilders/CommonEmbedBuilder.cs b/Asterion/EmbedBuilders/CommonEmbedBuilder.cs new file mode 100644 index 0000000..2ac44f8 --- /dev/null +++ b/Asterion/EmbedBuilders/CommonEmbedBuilder.cs @@ -0,0 +1,48 @@ +using Discord; +using Color = Discord.Color; + +namespace Asterion.EmbedBuilders; + +public static class CommonEmbedBuilder +{ + private static Color _errorColor = Color.Red; + private static Color _warningColor = Color.Orange; + private static Color _infoColor = Color.Blue; + private static Color _successColor = Color.Green; + + public static EmbedBuilder GetSuccessEmbedBuilder(string title, string description) + { + return new EmbedBuilder() + .WithTitle(title) + .WithDescription(description) + .WithColor(_successColor) + .WithCurrentTimestamp(); + } + + public static EmbedBuilder GetErrorEmbedBuilder(string title, string description) + { + return new EmbedBuilder() + .WithTitle(title) + .WithDescription(description) + .WithColor(_errorColor) + .WithCurrentTimestamp(); + } + + public static EmbedBuilder GetInfoEmbedBuilder(string title, string description) + { + return new EmbedBuilder() + .WithTitle(title) + .WithDescription(description) + .WithColor(_infoColor) + .WithCurrentTimestamp(); + } + + public static EmbedBuilder GetWarningEmbedBuilder(string title, string description) + { + return new EmbedBuilder() + .WithTitle(title) + .WithDescription(description) + .WithColor(_warningColor) + .WithCurrentTimestamp(); + } +} \ No newline at end of file diff --git a/Asterion/EmbedBuilders/ListEmbedBuilder.cs b/Asterion/EmbedBuilders/ListEmbedBuilder.cs index 11c620b..e4a0b5b 100644 --- a/Asterion/EmbedBuilders/ListEmbedBuilder.cs +++ b/Asterion/EmbedBuilders/ListEmbedBuilder.cs @@ -16,7 +16,7 @@ public static List CreateListEmbed(IList entries) var currentEntry = 0; - var numberOfEmbeds = entries.Count / 25 + 1; + var numberOfEmbeds = entries.Count / 25 + (entries.Count % 25 > 0 ? 1 : 0); for (var i = 0; i < numberOfEmbeds; i++) { diff --git a/Asterion/Extensions/ArrayExtensions.cs b/Asterion/Extensions/ArrayExtensions.cs new file mode 100644 index 0000000..947647f --- /dev/null +++ b/Asterion/Extensions/ArrayExtensions.cs @@ -0,0 +1,16 @@ +namespace Asterion.Extensions; + +public static class ArrayExtensions +{ + public static IEnumerable> Split(this T[] array, int blockSize) + { + var offset = 0; + while (offset < array.Length) + { + var remaining = array.Length - offset; + var blockSizeToUse = Math.Min(remaining, blockSize); + yield return new ArraySegment(array, offset, blockSizeToUse); + offset += blockSizeToUse; + } + } +} \ No newline at end of file diff --git a/Asterion/Interfaces/ILocalizationService.cs b/Asterion/Interfaces/ILocalizationService.cs new file mode 100644 index 0000000..9eef949 --- /dev/null +++ b/Asterion/Interfaces/ILocalizationService.cs @@ -0,0 +1,13 @@ +using System.Globalization; +using Discord.Interactions; +using Microsoft.Extensions.Localization; + +namespace Asterion.Interfaces; + +public interface ILocalizationService +{ + LocalizedString Get(string key); + LocalizedString Get(string key, CultureInfo? cultureInfo); + LocalizedString Get(string key, params object[] parameters); + LocalizedString Get(string key, CultureInfo? cultureInfo, params object[] parameters); +} \ No newline at end of file diff --git a/Asterion/Modules/BotCommands.cs b/Asterion/Modules/BotCommands.cs index d0a6418..35b0dd8 100644 --- a/Asterion/Modules/BotCommands.cs +++ b/Asterion/Modules/BotCommands.cs @@ -1,13 +1,20 @@ -using Discord.Interactions; +using Asterion.Common; +using Asterion.Interfaces; +using Discord.Interactions; +using Discord.WebSocket; namespace Asterion.Modules; -public class BotCommands : InteractionModuleBase +public class BotCommands : AsterionInteractionModuleBase { +#if DEBUG [SlashCommand("ping", "Pings the bot", runMode: RunMode.Async)] public async Task Ping() { - await RespondAsync($"Pong :ping_pong: It took me {Context.Client.Latency}ms to respond to you", - ephemeral: true); + await RespondAsync($"Pong! Latency: {Context.Client.Latency}ms"); + } +#endif + public BotCommands(ILocalizationService localizationService) : base(localizationService) + { } } \ No newline at end of file diff --git a/Asterion/Modules/BotManagement.cs b/Asterion/Modules/BotManagement.cs index 99d1407..8148557 100644 --- a/Asterion/Modules/BotManagement.cs +++ b/Asterion/Modules/BotManagement.cs @@ -1,4 +1,5 @@ using Asterion.AutocompleteHandlers; +using Asterion.Common; using Asterion.Interfaces; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; @@ -6,11 +7,11 @@ namespace Asterion.Modules; [RequireOwner] -public class BotManagement : InteractionModuleBase +public class BotManagement : AsterionInteractionModuleBase { private readonly IDataService _dataService; - public BotManagement(IServiceProvider serviceProvider) + public BotManagement(IServiceProvider serviceProvider, ILocalizationService localizationService) : base(localizationService) { _dataService = serviceProvider.GetRequiredService(); } diff --git a/Asterion/Modules/ChartModule.cs b/Asterion/Modules/ChartModule.cs index 0eef7ad..e8bbfd9 100644 --- a/Asterion/Modules/ChartModule.cs +++ b/Asterion/Modules/ChartModule.cs @@ -1,4 +1,6 @@ using Asterion.AutocompleteHandlers; +using Asterion.Common; +using Asterion.Interfaces; using Asterion.Services; using Asterion.Services.Modrinth; using Discord; @@ -12,13 +14,14 @@ namespace Asterion.Modules; -public class ChartModule : InteractionModuleBase +public class ChartModule : AsterionInteractionModuleBase { private readonly ILogger _logger; private readonly ModrinthService _modrinthService; private readonly ProjectStatisticsManager _projectStatisticsManager; - public ChartModule(IServiceProvider serviceProvider) + public ChartModule(IServiceProvider serviceProvider, ILocalizationService localizationService) : base( + localizationService) { _projectStatisticsManager = serviceProvider.GetRequiredService(); _modrinthService = serviceProvider.GetRequiredService(); @@ -29,7 +32,8 @@ public ChartModule(IServiceProvider serviceProvider) [Group("chart", "Creates charts of different statistics")] public class ChartType : ChartModule { - public ChartType(IServiceProvider serviceProvider) : base(serviceProvider) + public ChartType(IServiceProvider serviceProvider, ILocalizationService localizationService) : base( + serviceProvider, localizationService) { } diff --git a/Asterion/Modules/GuildManagement.cs b/Asterion/Modules/GuildManagement.cs index c03529d..e4cd412 100644 --- a/Asterion/Modules/GuildManagement.cs +++ b/Asterion/Modules/GuildManagement.cs @@ -1,5 +1,6 @@ using System.Text; using Asterion.Attributes; +using Asterion.Common; using Asterion.Interfaces; using Discord; using Discord.Interactions; @@ -7,11 +8,12 @@ namespace Asterion.Modules; [EnabledInDm(false)] -public class GuildManagement : InteractionModuleBase +public class GuildManagement : AsterionInteractionModuleBase { private readonly IDataService _dataService; - public GuildManagement(IDataService dataService) + public GuildManagement(IDataService dataService, ILocalizationService localizationService) : base( + localizationService) { _dataService = dataService; } diff --git a/Asterion/Modules/ModrinthInteractionModule.cs b/Asterion/Modules/ModrinthInteractionModule.cs index 7398773..a2e955b 100644 --- a/Asterion/Modules/ModrinthInteractionModule.cs +++ b/Asterion/Modules/ModrinthInteractionModule.cs @@ -1,4 +1,5 @@ using Asterion.Attributes; +using Asterion.Common; using Asterion.ComponentBuilders; using Asterion.Database.Models; using Asterion.EmbedBuilders; @@ -14,14 +15,15 @@ namespace Asterion.Modules; [EnabledInDm(false)] -public class ModrinthInteractionModule : InteractionModuleBase +public class ModrinthInteractionModule : AsterionInteractionModuleBase { private const string RequestError = "Sorry, there was an error processing your request, try again later"; private readonly IDataService _dataService; private readonly ILogger _logger; private readonly ModrinthService _modrinthService; - public ModrinthInteractionModule(IServiceProvider serviceProvider) + public ModrinthInteractionModule(IServiceProvider serviceProvider, ILocalizationService localizationService) : + base(localizationService) { _dataService = serviceProvider.GetRequiredService(); _modrinthService = serviceProvider.GetRequiredService(); @@ -40,7 +42,7 @@ private ComponentBuilder GetButtons(Project project, GuildSettings guildSettings components.WithButton(ModrinthComponentBuilder.GetProjectLinkButton(project)) .WithButton(ModrinthComponentBuilder.GetUserToViewButton(Context.User.Id, team.GetOwner()?.User.Id, project.Id)); - + return components; } @@ -52,7 +54,7 @@ public async Task SubProject(string userId, string projectId) await DeferAsync(); var guildId = Context.Guild.Id; - var channel = await Context.Guild.GetTextChannelAsync(Context.Channel.Id); + var channel = Context.Guild.GetTextChannel(Context.Channel.Id); var subscribed = await _dataService.IsGuildSubscribedToProjectAsync(guildId, projectId); @@ -106,7 +108,7 @@ await FollowupAsync( ephemeral: true); - var guildChannels = await Context.Guild.GetTextChannelsAsync(); + var guildChannels = Context.Guild.TextChannels; var options = new SelectMenuBuilder { @@ -183,8 +185,6 @@ public async Task UnsubProject(string userId, string projectId) var guildId = Context.Guild.Id; - var subscribed = await _dataService.IsGuildSubscribedToProjectAsync(guildId, projectId); - var project = await _modrinthService.GetProject(projectId); var guild = await _dataService.GetGuildByIdAsync(guildId); @@ -201,6 +201,8 @@ public async Task UnsubProject(string userId, string projectId) await FollowupAsync(RequestError, ephemeral: true); return; } + + var subscribed = await _dataService.IsGuildSubscribedToProjectAsync(guildId, project.Id); var team = await _modrinthService.GetProjectsTeamMembersAsync(project.Id); diff --git a/Asterion/Modules/ModrinthModule.cs b/Asterion/Modules/ModrinthModule.cs index 2ab0ef1..6b12ea4 100644 --- a/Asterion/Modules/ModrinthModule.cs +++ b/Asterion/Modules/ModrinthModule.cs @@ -1,5 +1,6 @@ using Asterion.Attributes; using Asterion.AutocompleteHandlers; +using Asterion.Common; using Asterion.ComponentBuilders; using Asterion.Database.Models; using Asterion.EmbedBuilders; @@ -12,6 +13,7 @@ using Fergun.Interactive; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Modrinth; using Modrinth.Exceptions; using Modrinth.Models; @@ -22,16 +24,20 @@ namespace Asterion.Modules; [EnabledInDm(false)] [RequireContext(ContextType.Guild)] // ReSharper disable once ClassNeverInstantiated.Global -public class ModrinthModule : InteractionModuleBase +public class ModrinthModule : AsterionInteractionModuleBase { private readonly DiscordSocketClient _client; private readonly IDataService _dataService; private readonly InteractiveService _interactive; private readonly ILogger _logger; private readonly ModrinthService _modrinthService; + private readonly ILocalizationService _localizationService; + private readonly IModrinthClient _modrinthClient; - public ModrinthModule(IServiceProvider serviceProvider) + public ModrinthModule(IModrinthClient modrinthClient, IServiceProvider serviceProvider, ILocalizationService localizationService) : base(localizationService) { + _modrinthClient = modrinthClient; + _localizationService = serviceProvider.GetRequiredService(); _dataService = serviceProvider.GetRequiredService(); _modrinthService = serviceProvider.GetRequiredService(); _interactive = serviceProvider.GetRequiredService(); @@ -51,7 +57,7 @@ public async Task FindUser([Summary("Query", "ID or username")] [MaxLength(60)] _logger.LogDebug("Search for user '{Query}", query); var searchResult = await _modrinthService.FindUser(query); _logger.LogDebug("Search status: {SearchStatus}", searchResult.SearchStatus); - + switch (searchResult.SearchStatus) { case SearchStatus.ApiDown: @@ -88,26 +94,11 @@ public async Task SearchProject([Summary("Query", "Query, ID or slug")] [MaxLeng _logger.LogDebug("Search for query '{Query}'", query); var searchResult = await _modrinthService.FindProject(query); _logger.LogDebug("Search status: {SearchStatus}", searchResult.SearchStatus); - switch (searchResult.SearchStatus) + + if (searchResult.Success == false) { - case SearchStatus.ApiDown: - await ModifyOriginalResponseAsync(x => - { - x.Content = "Modrinth API is probably down, please try again later"; - }); - return; - case SearchStatus.NoResult: - await ModifyOriginalResponseAsync(x => { x.Content = $"No result for query '{query}'"; }); - return; - case SearchStatus.UnknownError: - await ModifyOriginalResponseAsync(x => { x.Content = "Unknown error, please try again later"; }); - return; - case SearchStatus.FoundById: - break; - case SearchStatus.FoundBySearch: - break; - default: - throw new ArgumentOutOfRangeException(); + await FollowupWithSearchResultErrorAsync(searchResult); + return; } var projectDto = searchResult.Payload; @@ -176,6 +167,17 @@ await ModifyOriginalResponseAsync(x => }); return; } + + var subscribed = await _dataService.IsGuildSubscribedToProjectAsync(Context.Guild.Id, project.Id); + + if (subscribed) + { + await ModifyOriginalResponseAsync(x => + { + x.Content = $"You're already subscribed to project ID {project.Id}"; + }); + return; + } var versions = await _modrinthService.GetVersionListAsync(project.Id); if (versions == null) @@ -359,7 +361,14 @@ public async Task LatestRelease(string slugOrId, MessageStyle style = MessageSty var guild = await _dataService.GetGuildByIdAsync(Context.Guild.Id); - var embed = ModrinthEmbedBuilder.VersionUpdateEmbed(guild?.GuildSettings, project, latestVersion, team); + if (guild is null) + { + // Try again later + await ModifyOriginalResponseAsync(x => { x.Content = "Internal error, please try again later"; }); + return; + } + + var embed = ModrinthEmbedBuilder.VersionUpdateEmbed(guild.GuildSettings, project, latestVersion, team); var buttons = new ComponentBuilder().WithButton( @@ -376,14 +385,13 @@ await ModifyOriginalResponseAsync(x => public async Task GetRandomProject() { await DeferAsync(); - - var api = _modrinthService.Api; + Project? randomProject; try { // Count to 70, as Modrinth currently returns less than 70 projects // If it's set to 1, it will sometimes return 0 projects - randomProject = (await api.Project.GetRandomAsync(70)).FirstOrDefault(); + randomProject = (await _modrinthClient.Project.GetRandomAsync(70)).FirstOrDefault(); } catch (ModrinthApiException e) { @@ -393,7 +401,7 @@ public async Task GetRandomProject() if (randomProject == null) throw new Exception("No projects found"); - var team = await api.Team.GetAsync(randomProject.Team); + var team = await _modrinthClient.Team.GetAsync(randomProject.Team); var embed = ModrinthEmbedBuilder.GetProjectEmbed(randomProject, team); diff --git a/Asterion/Modules/SettingsModule.cs b/Asterion/Modules/SettingsModule.cs index e9df792..0ae83ef 100644 --- a/Asterion/Modules/SettingsModule.cs +++ b/Asterion/Modules/SettingsModule.cs @@ -1,4 +1,5 @@ using Asterion.Attributes; +using Asterion.Common; using Asterion.ComponentBuilders; using Asterion.Database.Models; using Asterion.EmbedBuilders; @@ -12,12 +13,12 @@ namespace Asterion.Modules; [RequireUserPermission(GuildPermission.Administrator)] [RequireContext(ContextType.Guild)] -public class SettingsModule : InteractionModuleBase +public class SettingsModule : AsterionInteractionModuleBase { private readonly IDataService _dataService; private readonly ILogger _logger; - public SettingsModule(IServiceProvider serviceProvider) + public SettingsModule(ILocalizationService localizationService, IServiceProvider serviceProvider) : base(localizationService) { _logger = serviceProvider.GetRequiredService>(); _dataService = serviceProvider.GetRequiredService(); diff --git a/Asterion/Program.cs b/Asterion/Program.cs index ee16846..04435b2 100644 --- a/Asterion/Program.cs +++ b/Asterion/Program.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Figgle; using Serilog; namespace Asterion; @@ -9,6 +10,8 @@ private static void Main(string[] args) { if (args.Length > 0 && args[0] == "migration") return; + Console.WriteLine(FiggleFonts.Slant.Render("Asterion v3")); + Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo("en-US"); Log.Logger = new LoggerConfiguration() @@ -18,12 +21,13 @@ private static void Main(string[] args) .MinimumLevel.Debug() #else .MinimumLevel.Information() - .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", Serilog.Events.LogEventLevel.Warning) #endif + .MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Information) // Set the minimum log level for EF Core messages + .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", Serilog.Events.LogEventLevel.Warning) // Set the minimum log level for EF Core command messages .Enrich.FromLogContext() .WriteTo.Console() .CreateLogger(); - new Asterion().MainAsync().GetAwaiter().GetResult(); + new Asterion(0).MainAsync().GetAwaiter().GetResult(); } } \ No newline at end of file diff --git a/Asterion/Resources/responses.en-US.Designer.cs b/Asterion/Resources/responses.en-US.Designer.cs new file mode 100644 index 0000000..810b251 --- /dev/null +++ b/Asterion/Resources/responses.en-US.Designer.cs @@ -0,0 +1,98 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Asterion.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class responses_en_US { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal responses_en_US() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Asterion.Resources.responses.en-US", typeof(responses_en_US).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The Modrinth API is currently unavailable.. + /// + internal static string Error_ModrinthApiUnavailable { + get { + return ResourceManager.GetString("Error_ModrinthApiUnavailable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please try again later.. + /// + internal static string Error_TryAgainLater { + get { + return ResourceManager.GetString("Error_TryAgainLater", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unknown error. + /// + internal static string Error_Unknown { + get { + return ResourceManager.GetString("Error_Unknown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No results for query '{0}'. + /// + internal static string Modrinth_Search_NoResult { + get { + return ResourceManager.GetString("Modrinth_Search_NoResult", resourceCulture); + } + } + } +} diff --git a/Asterion/Resources/responses.en-US.resx b/Asterion/Resources/responses.en-US.resx new file mode 100644 index 0000000..40b726f --- /dev/null +++ b/Asterion/Resources/responses.en-US.resx @@ -0,0 +1,42 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The Modrinth API is currently unavailable + + + Please try again later + + + Unknown error + + + No results for query '{0}' + + + Search was not successful + + + Settings + + + Notification Style + + \ No newline at end of file diff --git a/Asterion/Services/BotStatsService.cs b/Asterion/Services/BotStatsService.cs index 1eef8fd..e3f554a 100644 --- a/Asterion/Services/BotStatsService.cs +++ b/Asterion/Services/BotStatsService.cs @@ -44,15 +44,13 @@ public async Task PublishToTopGg() _logger.LogInformation("Publishing bot stats to top.gg, server count: {ServerCount}", _discordClient.Guilds.Count); using var request = new HttpRequestMessage(HttpMethod.Post, - $"https://top.gg/api/bots/{_discordClient.CurrentUser.Id}/stats") + $"https://top.gg/api/bots/{_discordClient.CurrentUser.Id}/stats"); + request.Content = new StringContent(JsonConvert.SerializeObject(new { - Content = new StringContent(JsonConvert.SerializeObject(new - { - server_count = _discordClient.Guilds.Count, - // shard_count = 1, - shards = Array.Empty() - }), Encoding.UTF8, "application/json") - }; + server_count = _discordClient.Guilds.Count, + // shard_count = 1, + shards = Array.Empty() + }), Encoding.UTF8, "application/json"); request.Headers.Add("Authorization", _topGgToken); diff --git a/Asterion/Services/ClientService.cs b/Asterion/Services/ClientService.cs index d9d5149..20cd4db 100644 --- a/Asterion/Services/ClientService.cs +++ b/Asterion/Services/ClientService.cs @@ -3,6 +3,7 @@ using Asterion.Interfaces; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; +using Quartz; using Timer = System.Timers.Timer; namespace Asterion.Services; @@ -11,35 +12,38 @@ public class ClientService { private readonly DiscordSocketClient _client; private readonly IDataService _data; - private readonly BackgroundWorker _refreshWorker; + private readonly ISchedulerFactory _schedulerFactory; public ClientService(IServiceProvider serviceProvider) { _client = serviceProvider.GetRequiredService(); _data = serviceProvider.GetRequiredService(); - - _refreshWorker = new BackgroundWorker(); - _refreshWorker.DoWork += RefreshAsync; - - // Refresh status every 15 minutes - var checkTimer = new Timer(TimeSpan.FromMinutes(15).TotalMilliseconds); - checkTimer.Elapsed += checkTimer_Elapsed; - checkTimer.Start(); + _schedulerFactory = serviceProvider.GetRequiredService(); } public void Initialize() { _client.Ready += SetGameAsync; + ScheduleRefreshJob(); } - private async void RefreshAsync(object? sender, DoWorkEventArgs e) + private async void ScheduleRefreshJob() { - await SetGameAsync(); - } + var scheduler = await _schedulerFactory.GetScheduler(); + + IJobDetail job = JobBuilder.Create() + .WithIdentity("RefreshJob", "group1") + .Build(); - private void checkTimer_Elapsed(object? sender, ElapsedEventArgs e) - { - if (!_refreshWorker.IsBusy) _refreshWorker.RunWorkerAsync(); + ITrigger trigger = TriggerBuilder.Create() + .WithIdentity("RefreshTrigger", "group1") + .StartNow() + .WithSimpleSchedule(x => x + .WithIntervalInSeconds(15 * 60) // Every 15 minutes + .RepeatForever()) + .Build(); + + await scheduler.ScheduleJob(job, trigger); } public async Task SetGameAsync() @@ -49,4 +53,19 @@ public async Task SetGameAsync() await _client.SetGameAsync( $"Monitoring {count} project{(count == 1 ? null : 's')} for updates in {_client.Guilds.Count} servers"); } -} \ No newline at end of file + + private class RefreshJob : IJob + { + private readonly ClientService _clientService; + + public RefreshJob(ClientService clientService) + { + _clientService = clientService; + } + + public async Task Execute(IJobExecutionContext context) + { + await _clientService.SetGameAsync(); + } + } +} diff --git a/Asterion/Services/LocalizationService.cs b/Asterion/Services/LocalizationService.cs new file mode 100644 index 0000000..786fabb --- /dev/null +++ b/Asterion/Services/LocalizationService.cs @@ -0,0 +1,58 @@ +using System.Globalization; +using Asterion.Interfaces; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; + +namespace Asterion.Services; + +public class LocalizationService : ILocalizationService +{ + private readonly IStringLocalizer _localizer; + private readonly ILogger _logger; + private readonly CultureInfo _defaultCultureInfo = CultureInfo.GetCultureInfo("en-US"); + + public LocalizationService(IStringLocalizerFactory localizer, ILogger logger) + { + _localizer = localizer.Create("responses", "Asterion"); + _logger = logger; + } + + public LocalizedString Get(string key) + { + var localizedString = _localizer[key]; + + if (localizedString.ResourceNotFound) + { + _logger.LogWarning("Missing localization for key: {Key}, report this to the developers!", key); + return localizedString; + } + + return localizedString; + } + + public LocalizedString Get(string key, CultureInfo? cultureInfo) + { + // We have to set the culture of the current thread to the culture we want to use + SetCulture(cultureInfo); + + return _localizer[key]; + } + + public LocalizedString Get(string key, params object[] parameters) + { + return _localizer[key, parameters]; + } + + public LocalizedString Get(string key, CultureInfo? cultureInfo, params object[] parameters) + { + SetCulture(cultureInfo); + + return _localizer[key, parameters]; + } + + private void SetCulture(CultureInfo? cultureInfo) + { + CultureInfo.CurrentCulture = cultureInfo ?? _defaultCultureInfo; + CultureInfo.CurrentUICulture = cultureInfo ?? _defaultCultureInfo; + } +} \ No newline at end of file diff --git a/Asterion/Services/Modrinth/ModrinthService.Api.cs b/Asterion/Services/Modrinth/ModrinthService.Api.cs index 7b3ca45..1ac613b 100644 --- a/Asterion/Services/Modrinth/ModrinthService.Api.cs +++ b/Asterion/Services/Modrinth/ModrinthService.Api.cs @@ -27,7 +27,7 @@ public partial class ModrinthService Project? p; try { - p = await Api.Project.GetAsync(slugOrId); + p = await _api.Project.GetAsync(slugOrId); } catch (Exception e) { @@ -45,7 +45,7 @@ public partial class ModrinthService { try { - var searchResponse = await Api.Version.GetProjectVersionListAsync(slugOrId); + var searchResponse = await _api.Version.GetProjectVersionListAsync(slugOrId); return searchResponse; } catch (Exception e) @@ -60,7 +60,7 @@ public partial class ModrinthService { try { - var searchResponse = await Api.Version.GetMultipleAsync(versions); + var searchResponse = await _api.Version.GetMultipleAsync(versions); return searchResponse; } catch (ModrinthApiException e) @@ -86,7 +86,7 @@ public partial class ModrinthService try { _logger.LogDebug("Team members for project ID {ProjectId} are not in cache", projectId); - var team = await Api.Team.GetProjectTeamAsync(projectId); + var team = await _api.Team.GetProjectTeamAsync(projectId); _cache.Set($"project-team-members:{projectId}", team, TimeSpan.FromMinutes(30)); _logger.LogDebug("Saving team members for project ID {ProjectId} to cache", projectId); @@ -104,7 +104,7 @@ public partial class ModrinthService { try { - var searchResponse = await Api.Project.SearchAsync(query); + var searchResponse = await _api.Project.SearchAsync(query); return searchResponse; } catch (Exception e) @@ -119,7 +119,7 @@ public partial class ModrinthService { try { - var searchResponse = await Api.Project.GetMultipleAsync(projectIds); + var searchResponse = await _api.Project.GetMultipleAsync(projectIds); return searchResponse; } catch (Exception e) @@ -156,7 +156,7 @@ public partial class ModrinthService try { _logger.LogDebug("Game versions not in cache, fetching from api..."); - var gameVersions = await Api.Tag.GetGameVersionsAsync(); + var gameVersions = await _api.Tag.GetGameVersionsAsync(); _cache.Set("gameVersions", gameVersions, TimeSpan.FromHours(12)); diff --git a/Asterion/Services/Modrinth/ModrinthService.cs b/Asterion/Services/Modrinth/ModrinthService.cs index 8ebb1fa..2bfdbb9 100644 --- a/Asterion/Services/Modrinth/ModrinthService.cs +++ b/Asterion/Services/Modrinth/ModrinthService.cs @@ -1,12 +1,7 @@ using System.ComponentModel; using System.Net; -using System.Timers; -using Asterion.ComponentBuilders; -using Asterion.Database.Models; -using Asterion.EmbedBuilders; using Asterion.Extensions; using Asterion.Interfaces; -using Discord; using Discord.WebSocket; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; @@ -14,9 +9,7 @@ using Modrinth; using Modrinth.Exceptions; using Modrinth.Models; -using Array = System.Array; -using Timer = System.Timers.Timer; -using Version = Modrinth.Models.Version; +using Quartz; namespace Asterion.Services.Modrinth; @@ -24,275 +17,56 @@ public partial class ModrinthService { private readonly IMemoryCache _cache; private readonly MemoryCacheEntryOptions _cacheEntryOptions; - private readonly DiscordSocketClient _client; - private readonly IDataService _dataService; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; - private readonly ProjectStatisticsManager _projectStatisticsManager; - private readonly BackgroundWorker _updateWorker; + private readonly IScheduler _scheduler; + private readonly JobKey _jobKey; + private readonly IModrinthClient _api; - public ModrinthService(IServiceProvider serviceProvider, IHttpClientFactory httpClientFactory) + public ModrinthService(IServiceProvider serviceProvider, IHttpClientFactory httpClientFactory, ISchedulerFactory scheduler) { _httpClientFactory = httpClientFactory; - Api = serviceProvider.GetRequiredService(); + _api = serviceProvider.GetRequiredService(); _logger = serviceProvider.GetRequiredService>(); _cache = serviceProvider.GetRequiredService(); - _dataService = serviceProvider.GetRequiredService(); - _client = serviceProvider.GetRequiredService(); - _projectStatisticsManager = serviceProvider.GetRequiredService(); + _scheduler = scheduler.GetScheduler().GetAwaiter().GetResult(); _cacheEntryOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15) }; - _updateWorker = new BackgroundWorker(); - _updateWorker.DoWork += CheckUpdates; - - - var checkTimer = new Timer(MinutesToMilliseconds(10)); - checkTimer.Elapsed += checkTimer_Elapsed; - checkTimer.Start(); + var job = JobBuilder.Create() + .WithIdentity("SearchUpdatesJob", "Modrinth") + .Build(); + + _jobKey = job.Key; + + // Every 10 minutes + var trigger = TriggerBuilder.Create() + .WithIdentity("SearchUpdatesTrigger", "Modrinth") + .StartAt(DateBuilder.FutureDate(10, IntervalUnit.Minute)) // Start 10 minutes from now + .WithSimpleSchedule(x => x + .WithIntervalInMinutes(10) + .RepeatForever()) + .Build(); + + _scheduler.ScheduleJob(job, trigger); _logger.LogInformation("Modrinth service initialized"); } - public IModrinthClient Api { get; } - - private static double MinutesToMilliseconds(int minutes) - { - return TimeSpan.FromMinutes(minutes).TotalMilliseconds; - } - - /// - /// Checks updates for every project stored in database, sends notification to every guild who has subscribed for - /// updates - /// - /// - /// - private async void CheckUpdates(object? sender, DoWorkEventArgs e) - { - try - { - _logger.LogInformation("Running update check"); - - var databaseProjects = await _dataService.GetAllModrinthProjectsAsync(); - - var projectIds = databaseProjects.Select(x => x.ProjectId); - _logger.LogDebug("Getting multiple projects ({Count}) from Modrinth", databaseProjects.Count); - var apiProjects = await GetMultipleProjects(projectIds); - - if (apiProjects is null) - { - _logger.LogWarning("Could not get information from API, update search interrupted"); - return; - } - - // Update downloads data in database - before we remove projects which are not updated - _logger.LogDebug("Updating downloads in database"); - foreach (var project in apiProjects) - { - await _projectStatisticsManager.UpdateDownloadsAsync(project); - } - - // Remove projects which are not updated (last update timestamp is less or equal to the last update timestamp in database) - apiProjects = apiProjects.Where(x => databaseProjects.Any(y => y.ProjectId == x.Id && y.LastUpdated < x.Updated)).ToArray(); - - _logger.LogDebug("Got {Count} projects", apiProjects.Length); - - var versions = apiProjects.SelectMany(p => p.Versions).ToArray(); - - - const int splitBy = 100; - _logger.LogDebug("Getting multiple versions ({Count}) from Modrinth", versions.Length); - - // Make multiple requests to get all versions - we don't want to get 1500+ versions in one request - // We make sure to split the requests into chunks of 500 versions - var apiVersions = new List(); - // Split the array into chunks of 500, we use ArraySegment - var versionChunks = Array.Empty>(); - - for (var i = 0; i < versions.Length; i += splitBy) - { - _logger.LogDebug("Appending versions {Start} to {End}", i, Math.Min(splitBy, versions.Length - i)); - versionChunks = versionChunks - .Append(new ArraySegment(versions, i, Math.Min(splitBy, versions.Length - i))).ToArray(); - } - - foreach (var chunk in versionChunks) - { - _logger.LogDebug("Getting versions {Start} to {End}", chunk.Offset, chunk.Offset + chunk.Count); - var versionsChunk = await GetMultipleVersionsAsync(chunk); - if (versionsChunk is null) - { - _logger.LogWarning("Could not get information from API, update search interrupted"); - return; - } - - apiVersions.AddRange(versionsChunk); - } - - _logger.LogDebug("Got {Count} versions", apiVersions.Count); - - foreach (var project in apiProjects) - { - _logger.LogDebug("Checking new versions for project {Title} ID {ProjectId}", project.Title, project.Id); - var versionList = apiVersions.Where(x => x.ProjectId == project.Id).ToList(); - - var newVersions = await GetNewVersions(versionList, project.Id); - - if (newVersions is null) - { - _logger.LogWarning("There was an error while finding new versions for project ID {ID}, skipping...", - project.Id); - continue; - } - - if (newVersions.Length == 0) - { - _logger.LogDebug("No new versions for project {Title} ID {ID}", project.Title, project.Id); - continue; - } - - _logger.LogInformation("Found {Count} new versions for project {Title} ID {ID}", newVersions.Length, - project.Title, project.Id); - - // Update data in database - _logger.LogDebug("Updating data in database"); - await _dataService.UpdateModrinthProjectAsync(project.Id, newVersions[0].Id, project.Title, - project.Updated); - - var team = await GetProjectsTeamMembersAsync(project.Id); - - var guilds = await _dataService.GetAllGuildsSubscribedToProject(project.Id); - - await CheckGuilds(newVersions, project, guilds, team); - } - - _logger.LogInformation("Update check ended"); - } - catch (Exception exception) - { - _logger.LogCritical("Exception while checking for updates: {Exception} \n\nStackTrace: {StackTrace}", - exception.Message, exception.StackTrace); - } - } - - private async Task GetNewVersions(IEnumerable versionList, string projectId) - { - var dbProject = await _dataService.GetModrinthProjectByIdAsync(projectId); - - if (dbProject is null) - { - _logger.LogWarning("Project ID {ID} not found in database", projectId); - return null; - } - - // Ensures the data is chronologically ordered - var orderedVersions = versionList.OrderByDescending(x => x.DatePublished); - - // Take new versions from the latest to the one we already checked - var newVersions = orderedVersions.TakeWhile(version => - version.Id != dbProject.LastCheckVersion && version.DatePublished > dbProject.LastUpdated).ToArray(); - - return newVersions; - } - - /// - /// Will load guild's channel from custom field of entries in database and send updates - /// - /// - /// - /// - /// - private async Task CheckGuilds(Version[] versions, Project project, IEnumerable guilds, - TeamMember[]? teamMembers = null) - { - foreach (var guild in guilds) - { - var entry = await _dataService.GetModrinthEntryAsync(guild.GuildId, project.Id); - - // Channel is not set, skip sending updates to this guild - if (entry!.CustomUpdateChannel is null) - { - _logger.LogInformation( - "Guild ID {GuildID} has not yet set default update channel or custom channel for this project", - guild.GuildId); - continue; - } - - var channel = _client.GetGuild(guild.GuildId).GetTextChannel((ulong) entry.CustomUpdateChannel); - - _logger.LogInformation("Sending updates to guild ID {Id} and channel ID {Channel}", guild.GuildId, - channel.Id); - - // None of these can be null, everything is checked beforehand - await SendUpdatesToChannel(channel, project, versions, teamMembers, guild.GuildSettings); - } - } - - /// - /// Sends update information about every new version to specified Text Channel - /// - /// - /// - /// - /// - /// - private async Task SendUpdatesToChannel(SocketTextChannel textChannel, Project currentProject, - IEnumerable newVersions, TeamMember[]? team, GuildSettings guildSettings) - { - // Iterate versions - they are ordered from latest to oldest, we want to sent them chronologically - foreach (var version in newVersions.Reverse()) - { - var embed = ModrinthEmbedBuilder.VersionUpdateEmbed(guildSettings, currentProject, version, team); - var buttons = - new ComponentBuilder().WithButton( - ModrinthComponentBuilder.GetVersionUrlButton(currentProject, version)); - try - { - var pingRoleId = await _dataService.GetPingRoleIdAsync(textChannel.Guild.Id); - - SocketRole? pingRole = null; - if (pingRoleId is not null) pingRole = textChannel.Guild.GetRole((ulong) pingRoleId); - - await textChannel.SendMessageAsync(pingRole?.Mention, embed: embed.Build(), - components: buttons.Build()); - } - catch (Exception ex) - { - _logger.LogCritical("Error while sending message to guild {Guild}: {Exception}", textChannel.Guild.Id, - ex.Message); - } - } - } - /// /// Force check for updates, used for debugging /// /// False if worker is busy public bool ForceUpdate() { - if (_updateWorker.IsBusy) return false; - - _updateWorker.RunWorkerAsync(); - + _scheduler.TriggerJob(_jobKey); + return true; } - - private void checkTimer_Elapsed(object? sender, ElapsedEventArgs e) - { - try - { - if (!_updateWorker.IsBusy) _updateWorker.RunWorkerAsync(); - } - catch (Exception ex) - { - _logger.LogCritical("Error while checking for updates: {Exception}", ex.Message); - } - } - /// /// Tries to find project on Modrinth /// @@ -318,9 +92,9 @@ public async Task> FindProject(string query) var projectFoundById = false; try { - project = await Api.Project.GetAsync(query); + project = await _api.Project.GetAsync(query); - searchResponse = await Api.Project.SearchAsync(query); + searchResponse = await _api.Project.SearchAsync(query); // Won't be set if exception is thrown projectFoundById = true; } @@ -333,12 +107,12 @@ public async Task> FindProject(string query) catch (ModrinthApiException e) { _logger.LogDebug(e, "Error while searching for project '{Query}'", query); - return new SearchResult(new ProjectDto(), SearchStatus.ApiDown); + return new SearchResult(new ProjectDto(), SearchStatus.ApiDown, query); } catch (Exception e) { _logger.LogError(e, "Error while searching for project '{Query}'", query); - return new SearchResult(new ProjectDto(), SearchStatus.ApiDown); + return new SearchResult(new ProjectDto(), SearchStatus.ApiDown, query); } if (projectFoundById && project is not null) @@ -347,7 +121,7 @@ public async Task> FindProject(string query) { Project = project, SearchResponse = searchResponse - }, SearchStatus.FoundById); + }, SearchStatus.FoundById, query); SetSearchResultToCache(result, query); @@ -357,20 +131,20 @@ public async Task> FindProject(string query) try { - searchResponse = await Api.Project.SearchAsync(query); + searchResponse = await _api.Project.SearchAsync(query); // No search results if (searchResponse.TotalHits <= 0) - return new SearchResult(new ProjectDto(), SearchStatus.NoResult); + return new SearchResult(new ProjectDto(), SearchStatus.NoResult, query); // Return first result - project = await Api.Project.GetAsync(searchResponse.Hits[0].ProjectId); + project = await _api.Project.GetAsync(searchResponse.Hits[0].ProjectId); var result = new SearchResult(new ProjectDto { Project = project, SearchResponse = searchResponse - }, SearchStatus.FoundBySearch); + }, SearchStatus.FoundBySearch, query); SetSearchResultToCache(result, query); @@ -380,7 +154,7 @@ public async Task> FindProject(string query) { _logger.LogWarning("Could not get project information for query '{Query}', exception: {Message}", query, e.Message); - return new SearchResult(new ProjectDto(), SearchStatus.ApiDown); + return new SearchResult(new ProjectDto(), SearchStatus.ApiDown, query); } } @@ -405,31 +179,31 @@ public async Task> FindUser(string query) try { - user = await Api.User.GetAsync(query); + user = await _api.User.GetAsync(query); _logger.LogDebug("User query '{Query}' found", query); } catch (ModrinthApiException e) when (e.Response.StatusCode == HttpStatusCode.NotFound) { // Project not found by slug or id _logger.LogDebug("User not found '{Query}'", query); - return new SearchResult(new UserDto(), SearchStatus.NoResult); + return new SearchResult(new UserDto(), SearchStatus.NoResult, query); } catch (Exception) { - return new SearchResult(new UserDto(), SearchStatus.ApiDown); + return new SearchResult(new UserDto(), SearchStatus.ApiDown, query); } // User can't be null from here try { - var projects = await Api.User.GetProjectsAsync(user.Id); + var projects = await _api.User.GetProjectsAsync(user.Id); var searchResult = new SearchResult(new UserDto { User = user, Projects = projects, MajorColor = (await httpClient.GetMajorColorFromImageUrl(user.AvatarUrl)).ToDiscordColor() - }, SearchStatus.FoundBySearch); + }, SearchStatus.FoundBySearch, query); _cache.Set($"user-query:{user.Id}", searchResult, TimeSpan.FromMinutes(60)); _cache.Set($"user-query:{query}", searchResult, TimeSpan.FromMinutes(60)); @@ -438,7 +212,7 @@ public async Task> FindUser(string query) } catch (Exception) { - return new SearchResult(new UserDto(), SearchStatus.ApiDown); + return new SearchResult(new UserDto(), SearchStatus.ApiDown, query); } } } \ No newline at end of file diff --git a/Asterion/Services/Modrinth/SearchResult.cs b/Asterion/Services/Modrinth/SearchResult.cs index cb8ca52..b6aebf6 100644 --- a/Asterion/Services/Modrinth/SearchResult.cs +++ b/Asterion/Services/Modrinth/SearchResult.cs @@ -5,18 +5,19 @@ namespace Asterion.Services.Modrinth; public class SearchResult { - public SearchResult(T? payload, SearchStatus searchStatus) + public SearchResult(T? payload, SearchStatus searchStatus, string query) { Payload = payload; SearchStatus = searchStatus; SearchTime = DateTime.UtcNow; + Query = query; } /// /// Success in getting results /// public bool Success => SearchStatus is SearchStatus.FoundById or SearchStatus.FoundBySearch; - + public string Query { get; } public DateTime SearchTime { get; private set; } public SearchStatus SearchStatus { get; } public T? Payload { get; private set; } diff --git a/Asterion/Services/Modrinth/SearchUpdatesJob.cs b/Asterion/Services/Modrinth/SearchUpdatesJob.cs new file mode 100644 index 0000000..0c70018 --- /dev/null +++ b/Asterion/Services/Modrinth/SearchUpdatesJob.cs @@ -0,0 +1,201 @@ +using System.Text.Json; +using Asterion.Database.Models; +using Asterion.Extensions; +using Asterion.Interfaces; +using Microsoft.Extensions.Logging; +using Modrinth; +using Modrinth.Models; +using Quartz; +using Version = Modrinth.Models.Version; + +namespace Asterion.Services.Modrinth; + +public class SearchUpdatesJob : IJob +{ + private readonly IModrinthClient _client; + private readonly IDataService _dataService; + private readonly ILogger _logger; + private readonly ProjectStatisticsManager _projectStatisticsManager; + private readonly IScheduler _scheduler; + + private const int SplitSize = 100; + + public SearchUpdatesJob(ISchedulerFactory scheduler, IModrinthClient client, IDataService dataService, ILogger logger, ProjectStatisticsManager projectStatisticsManager) + { + _scheduler = scheduler.GetScheduler().GetAwaiter().GetResult(); + _client = client; + _dataService = dataService; + _logger = logger; + _projectStatisticsManager = projectStatisticsManager; + } + + public async Task Execute(IJobExecutionContext context) + { + _logger.LogInformation("Update search job started"); + var time = DateTime.UtcNow; + + try + { + await DoExecutionWork(); + } + // We should only catch exception that we know could be thrown, as Quartz will reschedule the job if we throw + finally + { + + } + } + + private async Task DoExecutionWork() + { + // We won't catch any exceptions here, it's not this methods responsibility to handle them + var projectsDto = await GetProjectsAsync(); + var projectIds = projectsDto.Select(p => p.ProjectId); + + var projects = await _client.Project.GetMultipleAsync(projectIds); + + // We let the statistics update + var copy = projects.ToList(); + await UpdateStatisticsData(copy); + + var updatedProjects = FilterUpdatedProjects(projectsDto, projects).ToArray(); + var versionsIds = updatedProjects.SelectMany(p => p.Versions).ToArray(); + + var versionsList = await GetAllVersionsAsync(versionsIds); + + Dictionary> projectVersions = new(); + + foreach (var project in updatedProjects) + { + var projectVersionsList = versionsList.Where(v => v.ProjectId == project.Id) + .OrderByDescending(v => v.DatePublished) + .ToList(); + projectVersions.Add(project, projectVersionsList); + } + + // This will modify the projectVersions dictionary in-place and only keep the projects and versions that have updates + await CheckForUpdatesAsync(projectVersions); + + if (projectVersions.Count > 0) + { + _logger.LogInformation("Found {Count} projects with updates", projectVersions.Count); + } + else + { + _logger.LogInformation("No projects with updates found"); + } + + // For each project, we'll create a Discord Notification job and pass it the information it needs + foreach (var (project, versions) in projectVersions) + { + _logger.LogInformation("Scheduling Discord notification for project {ProjectId} with {NewVersionsCount} new versions", project.Id, versions.Count); + var job = JobBuilder.Create() + //.WithIdentity($"discord-notification-{project.Id}", "modrinth") + .UsingJobData("project", JsonSerializer.Serialize(project)) + .UsingJobData("versions", JsonSerializer.Serialize(versions.ToArray())) + .Build(); + + var trigger = TriggerBuilder.Create() + //.WithIdentity($"discord-notification-{project.Id}", "modrinth") + .StartNow() + .Build(); + + await _scheduler.ScheduleJob(job, trigger); + } + } + + private async Task CheckForUpdatesAsync(Dictionary> projectVersions) + { + // This list will hold the keys to remove from the projectVersions dictionary + var keysToRemove = new List(); + + // Loop through the projects in the dictionary + foreach (var (project, versions) in projectVersions) + { + var latestVersion = versions.First(); + var dbProject = await _dataService.GetModrinthProjectByIdAsync(project.Id); + if (dbProject == null) + { + _logger.LogError("Failed to find project {ProjectId} in the database", project.Id); + keysToRemove.Add(project); + continue; + } + + if (dbProject.LastCheckVersion == latestVersion.Id) + { + keysToRemove.Add(project); + continue; + } + + _logger.LogInformation("Found update for project {ProjectId}", project.Id); + var success = await _dataService.UpdateModrinthProjectAsync(dbProject.ProjectId, latestVersion.Id, project.Title, + DateTime.UtcNow); + + if (!success) + { + _logger.LogError("Failed to update project {ProjectId} in the database", project.Id); + keysToRemove.Add(project); + continue; + } + + // Remove versions from the list that are the same or newer than the latest version + // This modifies the list associated with the project in the dictionary + var keepVersions = versions.Where(v => v.DatePublished <= latestVersion.DatePublished && v.DatePublished > dbProject.LastUpdated).ToList(); + projectVersions[project] = keepVersions; + } + + // Now remove the projects from the dictionary that were added to the keysToRemove list + // This modifies the dictionary in-place + foreach (var key in keysToRemove) + { + projectVersions.Remove(key); + } + } + + + private async Task> GetAllVersionsAsync(string[] versionIds) + { + var versionSegments = versionIds.Split(SplitSize).ToList(); + var versions = new List(); + + foreach (var segment in versionSegments) + { + var segmentVersions = await _client.Version.GetMultipleAsync(segment); + versions.AddRange(segmentVersions); + } + + return versions; + } + + private async Task UpdateStatisticsData(IEnumerable projects) + { + var projectList = projects.ToList(); + _logger.LogDebug("Updating statistics for {Count} projects", projectList.Count); + foreach (var project in projectList) + { + await _projectStatisticsManager.UpdateDownloadsAsync(project); + } + _logger.LogDebug("Statistics update finished"); + } + + private static IEnumerable FilterUpdatedProjects(IList projectsDto, IEnumerable projects) + { + var updatedProjects = new List(); + foreach (var project in projects) + { + var projectDto = projectsDto.First(p => p.ProjectId == project.Id); + if (projectDto.LastUpdated < project.Updated) + { + updatedProjects.Add(project); + } + } + + return updatedProjects; + } + + private async Task> GetProjectsAsync() + { + var projects = await _dataService.GetAllModrinthProjectsAsync(); + _logger.LogDebug("Found {Count} projects", projects.Count); + return projects; + } +} \ No newline at end of file diff --git a/Asterion/Services/Modrinth/SendDiscordNotificationJob.cs b/Asterion/Services/Modrinth/SendDiscordNotificationJob.cs new file mode 100644 index 0000000..1f21314 --- /dev/null +++ b/Asterion/Services/Modrinth/SendDiscordNotificationJob.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using Asterion.ComponentBuilders; +using Asterion.Database.Models; +using Asterion.EmbedBuilders; +using Asterion.Interfaces; +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; +using Modrinth; +using Modrinth.Models; +using Quartz; +using Version = Modrinth.Models.Version; + +namespace Asterion.Services.Modrinth; + +public class SendDiscordNotificationJob : IJob +{ + private readonly ILogger _logger; + private readonly IDataService _dataService; + private readonly DiscordSocketClient _client; + private readonly IModrinthClient _modrinthClient; + + private JobKey _jobKey = null!; + + public SendDiscordNotificationJob(ILogger logger, IDataService dataService, DiscordSocketClient client, IModrinthClient modrinthClient) + { + _modrinthClient = modrinthClient; + _client = client; + _logger = logger; + _dataService = dataService; + } + + public async Task Execute(IJobExecutionContext context) + { + _jobKey = context.JobDetail.Key; + + // We currently store the data there as JSON, so we need to deserialize it + var projectJson = context.JobDetail.JobDataMap.GetString("project"); + var versionListJson = context.JobDetail.JobDataMap.GetString("versions"); + + if (projectJson is null || versionListJson is null) + { + _logger.LogError("Project or version list was null"); + LogAbortJob(); + return; + } + + var project = JsonSerializer.Deserialize(projectJson); + var versionList = JsonSerializer.Deserialize(versionListJson); + + if (project is null || versionList is null) + { + _logger.LogError("Project or version list was null"); + LogAbortJob(); + return; + } + + _logger.LogInformation("Starting to send notifications for {ProjectName} ({ProjectID}) with {NewVersionsCount} new versions", project.Title, project.Id, versionList.Length); + await SendNotifications(project, versionList); + } + + private void LogAbortJob() + { + _logger.LogWarning("Aborting job ID {JobId}", _jobKey); + } + + private async Task SendNotifications(Project project, Version[] versions) + { + var guilds = await _dataService.GetAllGuildsSubscribedToProject(project.Id); + + if (guilds.Count <= 0) + { + _logger.LogWarning("No guilds subscribed to {ProjectName}, aborting job ID {JobId}", project.Title, _jobKey.Name); + return; + } + + // If the request fail, the job will be rescheduled so it should be fine to just throw here + var team = await _modrinthClient.Team.GetAsync(project.Team); + + foreach (var guild in guilds) + { + var entry = await _dataService.GetModrinthEntryAsync(guild.GuildId, project.Id); + + if (entry is null) + { + _logger.LogWarning( + "No entry found for guild {GuildId} and project {ProjectId} (might have unsubscribed)", + guild.GuildId, project.Id); + continue; + } + + var channel = _client.GetGuild(guild.GuildId)?.GetTextChannel((ulong) entry.CustomUpdateChannel!); + + if (channel is null) + { + _logger.LogWarning("Channel {ChannelId} not found in guild {GuildId}", entry.CustomUpdateChannel, guild.GuildId); + // TODO: Maybe notify the user that the channel was not found and remove the entry? + continue; + } + + var pingRole = guild.PingRole is null ? null : channel.Guild.GetRole((ulong) guild.PingRole); + + foreach (var version in versions.OrderBy(x => x.DatePublished)) + { + _logger.LogDebug("Sending notification for version {VersionId} of project {ProjectId} to guild {GuildId}", version.Id, project.Id, guild.GuildId); + var embed = ModrinthEmbedBuilder.VersionUpdateEmbed(guild.GuildSettings, project, version, team).Build(); + var buttons = new ComponentBuilder().WithButton(ModrinthComponentBuilder.GetVersionUrlButton(project, version)).Build(); + + await SendUpdateEmbedToChannel(channel, pingRole?.Mention ?? string.Empty, embed, buttons); + } + } + } + + private async Task SendUpdateEmbedToChannel(ISocketMessageChannel channel, string message, Embed embed, MessageComponent buttons) + { + try + { + await channel.SendMessageAsync(message, embed: embed, components: buttons); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send message to channel {ChannelId}", channel.Id); + } + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE index f288702..0ad25db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies @@ -7,17 +7,15 @@ Preamble - The GNU General Public License is a free, copyleft license for -software and other kinds of works. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to +our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. +software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you @@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. The precise terms and conditions for copying, distribution and modification follow. @@ -72,7 +60,7 @@ modification follow. 0. Definitions. - "This License" refers to version 3 of the GNU General Public License. + "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. @@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Use with the GNU Affero General Public License. + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single +under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General +Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published +GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's +versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. @@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. + GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see +For more information on this, and how to apply and follow the GNU AGPL, see . - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -.