diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 057a8b9..19d30ab 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,7 +16,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 7.0.x + dotnet-version: 8.0.x - name: Add NuGet source run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json" - name: Restore dependencies diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 9c76953..8292fef 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -17,7 +17,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 7.0.x + dotnet-version: 8.0.x - name: Add GitHub NuGet source run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e91b3a..dff8b5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 7.0.x + dotnet-version: 8.0.x - name: Add GitHub NuGet source run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json" diff --git a/Marco/Commands/MacroCommand.cs b/Marco/Commands/MacroCommand.cs index 02db2fa..ad5c4bd 100644 --- a/Marco/Commands/MacroCommand.cs +++ b/Marco/Commands/MacroCommand.cs @@ -10,14 +10,17 @@ namespace Marco.Commands; internal sealed class MacroCommand : ApplicationCommandModule { private readonly MacroService _macroService; + private readonly MacroCooldownService _cooldownService; /// /// Initializes a new instance of the class. /// /// The macro service. - public MacroCommand(MacroService macroService) + /// The cooldown service. + public MacroCommand(MacroService macroService, MacroCooldownService cooldownService) { _macroService = macroService; + _cooldownService = cooldownService; } [SlashCommand("macro", "Executes a macro.")] @@ -32,11 +35,18 @@ public async Task MacroAsync(InteractionContext context, return; } - if (macro.ChannelId.HasValue && macro.ChannelId.Value != context.Channel.Id) + DiscordChannel channel = context.Channel; + + if (macro.ChannelId.HasValue && macro.ChannelId.Value != channel.Id) { await context.CreateResponseAsync($"The macro `{macroName}` cannot be executed here.", true).ConfigureAwait(false); return; } + if (_cooldownService.IsOnCooldown(channel, macro)) + { + await context.CreateResponseAsync($"The macro `{macroName}` is on cooldown because it was very recently executed.", true).ConfigureAwait(false); + return; + } var builder = new DiscordInteractionResponseBuilder(); string response = macro.Response; @@ -49,5 +59,6 @@ public async Task MacroAsync(InteractionContext context, builder.WithContent(response); await context.CreateResponseAsync(builder).ConfigureAwait(false); + _cooldownService.UpdateCooldown(channel, macro); } } diff --git a/Marco/Logging/ColorfulConsoleTarget.cs b/Marco/Logging/ColorfulConsoleTarget.cs deleted file mode 100644 index 27bfc03..0000000 --- a/Marco/Logging/ColorfulConsoleTarget.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Text; -using NLog; -using NLog.Targets; -using LogLevel = NLog.LogLevel; - -namespace Marco.Logging; - -/// -/// Represents an NLog target which supports colorful output to stdout. -/// -internal sealed class ColorfulConsoleTarget : TargetWithLayout -{ - /// - /// Initializes a new instance of the class. - /// - /// The name of the log target. - public ColorfulConsoleTarget(string name) - { - Name = name; - } - - /// - protected override void Write(LogEventInfo logEvent) - { - var message = new StringBuilder(); - message.Append(Layout.Render(logEvent)); - - if (logEvent.Level == LogLevel.Warn) - Console.ForegroundColor = ConsoleColor.Yellow; - else if (logEvent.Level == LogLevel.Error || logEvent.Level == LogLevel.Fatal) - Console.ForegroundColor = ConsoleColor.Red; - - if (logEvent.Exception is { } exception) - message.Append($": {exception}"); - - Console.WriteLine(message); - Console.ResetColor(); - } -} diff --git a/Marco/Logging/LogFileTarget.cs b/Marco/Logging/LogFileTarget.cs deleted file mode 100644 index 3a5bfac..0000000 --- a/Marco/Logging/LogFileTarget.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text; -using Marco.Services; -using NLog; -using NLog.Targets; - -namespace Marco.Logging; - -/// -/// Represents an NLog target which writes its output to a log file on disk. -/// -internal sealed class LogFileTarget : TargetWithLayout -{ - private readonly LoggingService _loggingService; - - /// - /// Initializes a new instance of the class. - /// - /// The name of the log target. - /// The . - public LogFileTarget(string name, LoggingService loggingService) - { - _loggingService = loggingService; - Name = name; - } - - /// - protected override void Write(LogEventInfo logEvent) - { - _loggingService.ArchiveLogFilesAsync(false).GetAwaiter().GetResult(); - - using FileStream stream = _loggingService.LogFile.Open(FileMode.Append, FileAccess.Write); - using var writer = new StreamWriter(stream, Encoding.UTF8); - writer.Write(Layout.Render(logEvent)); - - if (logEvent.Exception is { } exception) - writer.Write($": {exception}"); - - writer.WriteLine(); - } -} diff --git a/Marco/Marco.csproj b/Marco/Marco.csproj index d185758..7bc8a48 100644 --- a/Marco/Marco.csproj +++ b/Marco/Marco.csproj @@ -2,11 +2,11 @@ Exe - net7.0 + net8.0 enable enable Linux - 2.1.1 + 2.2.0 @@ -32,15 +32,17 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + + + diff --git a/Marco/Program.cs b/Marco/Program.cs index 016ac53..2800e7e 100644 --- a/Marco/Program.cs +++ b/Marco/Program.cs @@ -5,39 +5,42 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using NLog.Extensions.Logging; +using Serilog; +using Serilog.Extensions.Logging; using X10D.Hosting.DependencyInjection; Directory.CreateDirectory("data"); -await Host.CreateDefaultBuilder(args) - .ConfigureAppConfiguration(builder => builder.AddJsonFile("data/config.json", true, true)) - .ConfigureLogging(builder => - { - builder.ClearProviders(); - builder.AddNLog(); - }) - .ConfigureServices(services => - { - services.AddSingleton(new DiscordClient(new DiscordConfiguration - { - Token = Environment.GetEnvironmentVariable("DISCORD_TOKEN"), - LoggerFactory = new NLogLoggerFactory(), - Intents = DiscordIntents.AllUnprivileged | DiscordIntents.GuildMessages | DiscordIntents.MessageContents - })); - - services.AddHostedSingleton(); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - services.AddHostedSingleton(); - services.AddHostedSingleton(); - - services.AddDbContext(); - - services.AddHostedSingleton(); - }) - .UseConsoleLifetime() - .RunConsoleAsync(); +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .WriteTo.File("logs/latest.log", rollingInterval: RollingInterval.Day) +#if DEBUG + .MinimumLevel.Debug() +#endif + .CreateLogger(); + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Configuration.AddJsonFile("data/config.json", true, true); +builder.Logging.ClearProviders(); +builder.Logging.AddSerilog(); + +builder.Services.AddSingleton(new DiscordClient(new DiscordConfiguration +{ + Token = Environment.GetEnvironmentVariable("DISCORD_TOKEN"), + LoggerFactory = new SerilogLoggerFactory(), + Intents = DiscordIntents.AllUnprivileged | DiscordIntents.GuildMessages | DiscordIntents.MessageContents +})); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddHostedSingleton(); +builder.Services.AddHostedSingleton(); + +builder.Services.AddDbContext(); + +builder.Services.AddHostedSingleton(); + +IHost app = builder.Build(); +await app.RunAsync(); diff --git a/Marco/Services/BotService.cs b/Marco/Services/BotService.cs index 8e5722a..eef4636 100644 --- a/Marco/Services/BotService.cs +++ b/Marco/Services/BotService.cs @@ -7,8 +7,7 @@ using DSharpPlus.SlashCommands; using Marco.Commands; using Microsoft.Extensions.Hosting; -using NLog; -using ILogger = NLog.ILogger; +using Microsoft.Extensions.Logging; namespace Marco.Services; @@ -17,18 +16,19 @@ namespace Marco.Services; /// internal sealed class BotService : BackgroundService { - private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); - + private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly DiscordClient _discordClient; /// /// Initializes a new instance of the class. /// + /// The logger. /// The service provider. /// The Discord client. - public BotService(IServiceProvider serviceProvider, DiscordClient discordClient) + public BotService(ILogger logger, IServiceProvider serviceProvider, DiscordClient discordClient) { + _logger = logger; _serviceProvider = serviceProvider; _discordClient = discordClient; @@ -58,7 +58,7 @@ public override Task StopAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken) { StartedAt = DateTimeOffset.UtcNow; - Logger.Info($"Marco v{Version} is starting..."); + _logger.LogInformation("Marco v{Version} is starting...", Version); _discordClient.UseInteractivity(); @@ -67,7 +67,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) Services = _serviceProvider }); - Logger.Info("Registering commands..."); + _logger.LogInformation("Registering commands..."); slashCommands.RegisterCommands(); slashCommands.RegisterCommands(); slashCommands.RegisterCommands(); @@ -75,7 +75,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) slashCommands.RegisterCommands(); slashCommands.RegisterCommands(); - Logger.Info("Connecting to Discord..."); + _logger.LogInformation("Connecting to Discord..."); _discordClient.Ready += OnReady; RegisterEvents(slashCommands); @@ -85,17 +85,17 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private Task OnReady(DiscordClient sender, ReadyEventArgs e) { - Logger.Info("Discord client ready"); + _logger.LogInformation("Discord client ready"); return Task.CompletedTask; } - private static void RegisterEvents(SlashCommandsExtension slashCommands) + private void RegisterEvents(SlashCommandsExtension slashCommands) { slashCommands.AutocompleteErrored += (_, args) => { - Logger.Error(args.Exception, "An exception was thrown when performing autocomplete"); + _logger.LogError(args.Exception, "An exception was thrown when performing autocomplete"); if (args.Exception is DiscordException discordException) - Logger.Error($"API response: {discordException.JsonMessage}"); + _logger.LogError("API response: {Message}", discordException.JsonMessage); return Task.CompletedTask; }; @@ -103,16 +103,19 @@ private static void RegisterEvents(SlashCommandsExtension slashCommands) slashCommands.SlashCommandInvoked += (_, args) => { var optionsString = ""; - if (args.Context.Interaction?.Data?.Options is { } options) + InteractionContext context = args.Context; + + if (context.Interaction?.Data?.Options is { } options) optionsString = $" {string.Join(" ", options.Select(o => $"{o?.Name}: '{o?.Value}'"))}"; - Logger.Info($"{args.Context.User} ran slash command /{args.Context.CommandName}{optionsString}"); + _logger.LogInformation("{User} ran slash command /{Command}{Options}", context.User, context.CommandName, optionsString); return Task.CompletedTask; }; slashCommands.ContextMenuInvoked += (_, args) => { - DiscordInteractionResolvedCollection? resolved = args.Context.Interaction?.Data?.Resolved; + ContextMenuContext context = args.Context; + DiscordInteractionResolvedCollection? resolved = context.Interaction?.Data?.Resolved; var properties = new List(); if (resolved?.Attachments?.Count > 0) properties.Add($"attachments: {string.Join(", ", resolved.Attachments.Select(a => a.Value.Url))}"); @@ -127,9 +130,7 @@ private static void RegisterEvents(SlashCommandsExtension slashCommands) if (resolved?.Users?.Count > 0) properties.Add($"users: {string.Join(", ", resolved.Users.Select(r => r.Value.Id))}"); - Logger.Info($"{args.Context.User} invoked context menu '{args.Context.CommandName}' with resolved " + - string.Join("; ", properties)); - + _logger.LogInformation("{User} invoked context menu '{CommandName}' with resolved {Properties}", context.User, context.CommandName, string.Join("; ", properties)); return Task.CompletedTask; }; @@ -143,9 +144,9 @@ private static void RegisterEvents(SlashCommandsExtension slashCommands) } string? name = context.Interaction.Data.Name; - Logger.Error(args.Exception, $"An exception was thrown when executing context menu '{name}'"); + _logger.LogError(args.Exception, "An exception was thrown when executing context menu \'{Name}\'", name); if (args.Exception is DiscordException discordException) - Logger.Error($"API response: {discordException.JsonMessage}"); + _logger.LogError("API response: {Message}", discordException.JsonMessage); return Task.CompletedTask; }; @@ -160,9 +161,9 @@ private static void RegisterEvents(SlashCommandsExtension slashCommands) } string? name = context.Interaction.Data.Name; - Logger.Error(args.Exception, $"An exception was thrown when executing slash command '{name}'"); + _logger.LogError(args.Exception, "An exception was thrown when executing slash command \'{Name}\'", name); if (args.Exception is DiscordException discordException) - Logger.Error($"API response: {discordException.JsonMessage}"); + _logger.LogError("API response: {Message}", discordException.JsonMessage); return Task.CompletedTask; }; diff --git a/Marco/Services/LoggingService.cs b/Marco/Services/LoggingService.cs deleted file mode 100644 index be07602..0000000 --- a/Marco/Services/LoggingService.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.IO.Compression; -using Marco.Logging; -using Microsoft.Extensions.Hosting; -using NLog; -using NLog.Config; -using NLog.LayoutRenderers; -using NLog.Layouts; -using LogLevel = NLog.LogLevel; - -namespace Marco.Services; - -/// -/// Represents a class which implements a logging service that supports multiple log targets. -/// -/// -/// This class implements a logging structure similar to that of Minecraft, where historic logs are compressed to a .gz and -/// the latest log is found in logs/latest.log. -/// -internal sealed class LoggingService : BackgroundService -{ - private const string LogFileName = "logs/latest.log"; - - /// - /// Initializes a new instance of the class. - /// - public LoggingService() - { - LogFile = new FileInfo(LogFileName); - } - - /// - /// Gets or sets the log file. - /// - /// The log file. - public FileInfo LogFile { get; set; } - - /// - /// Archives any existing log files. - /// - public async Task ArchiveLogFilesAsync(bool archiveToday = true) - { - var latestFile = new FileInfo(LogFile.FullName); - if (!latestFile.Exists) return; - - DateTime lastWrite = latestFile.LastWriteTime; - string lastWriteDate = $"{lastWrite:yyyy-MM-dd}"; - var version = 0; - string name; - - if (!archiveToday && lastWrite.Date == DateTime.Today) return; - - while (File.Exists(name = Path.Combine(LogFile.Directory!.FullName, $"{lastWriteDate}-{++version}.log.gz"))) - { - // body ignored - } - - await using (FileStream source = latestFile.OpenRead()) - { - await using FileStream output = File.Create(name); - await using var gzip = new GZipStream(output, CompressionMode.Compress); - await source.CopyToAsync(gzip); - } - - latestFile.Delete(); - } - - /// - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - LogFile.Directory?.Create(); - - LayoutRenderer.Register("TheTime", info => info.TimeStamp.ToString("HH:mm:ss")); - LayoutRenderer.Register("PluginName", info => info.LoggerName); - - Layout? layout = Layout.FromString("[${TheTime} ${level:uppercase=true}] [${PluginName}] ${message}"); - var config = new LoggingConfiguration(); - var fileLogger = new LogFileTarget("FileLogger", this) {Layout = layout}; - var consoleLogger = new ColorfulConsoleTarget("ConsoleLogger") {Layout = layout}; - -#if DEBUG - LogLevel minLevel = LogLevel.Debug; -#else - LogLevel minLevel = LogLevel.Info; - if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ENABLE_DEBUG_LOGGING"))) - minLevel = LogLevel.Debug; -#endif - config.AddRule(minLevel, LogLevel.Fatal, consoleLogger); - config.AddRule(minLevel, LogLevel.Fatal, fileLogger); - - LogManager.Configuration = config; - - return ArchiveLogFilesAsync(); - } -} diff --git a/Marco/Services/MacroListeningService.cs b/Marco/Services/MacroListeningService.cs index f37f6bb..a64902a 100644 --- a/Marco/Services/MacroListeningService.cs +++ b/Marco/Services/MacroListeningService.cs @@ -4,7 +4,7 @@ using Marco.Configuration; using Marco.Data; using Microsoft.Extensions.Hosting; -using NLog; +using Microsoft.Extensions.Logging; namespace Marco.Services; @@ -13,8 +13,7 @@ namespace Marco.Services; /// internal sealed class MacroListeningService : BackgroundService { - private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); - + private readonly ILogger _logger; private readonly DiscordClient _discordClient; private readonly ConfigurationService _configurationService; private readonly MacroService _macroService; @@ -23,17 +22,20 @@ internal sealed class MacroListeningService : BackgroundService /// /// Initializes a new instance of the class. /// + /// The logger. /// The Discord client. /// The configuration service. /// The macro service. /// The macro cooldown service. public MacroListeningService( + ILogger logger, DiscordClient discordClient, ConfigurationService configurationService, MacroService macroService, MacroCooldownService cooldownService ) { + _logger = logger; _discordClient = discordClient; _configurationService = configurationService; _macroService = macroService; @@ -81,14 +83,14 @@ private async Task OnMessageCreated(DiscordClient sender, MessageCreateEventArgs { if (_cooldownService.IsOnCooldown(channel, macro)) { - Logger.Info($"{e.Author} used channel macro '{command}' in {channel} but is on cooldown"); + _logger.LogInformation("{Author} used channel macro \'{Command}\' in {Channel} but is on cooldown", e.Author, command, channel); if (cooldownEmoji is not null) await e.Message.CreateReactionAsync(cooldownEmoji).ConfigureAwait(false); } else { - Logger.Info($"{e.Author} used channel macro '{command}' in {channel}"); + _logger.LogInformation("{Author} used channel macro \'{Command}\' in {Channel}", e.Author, command, channel); if (successEmoji is not null) await e.Message.CreateReactionAsync(successEmoji).ConfigureAwait(false); @@ -101,14 +103,14 @@ private async Task OnMessageCreated(DiscordClient sender, MessageCreateEventArgs { if (_cooldownService.IsOnCooldown(channel, macro)) { - Logger.Info($"{e.Author} used global macro '{command}' in {channel} but is on cooldown"); + _logger.LogInformation("{Author} used global macro \'{Command}\' in {Channel} but is on cooldown", e.Author, command, channel); if (cooldownEmoji is not null) await e.Message.CreateReactionAsync(cooldownEmoji).ConfigureAwait(false); } else { - Logger.Info($"{e.Author} used global macro '{command}' in {channel}"); + _logger.LogInformation("{Author} used global macro \'{Command}\' in {Channel}", e.Author, command, channel); if (successEmoji is not null) await e.Message.CreateReactionAsync(successEmoji).ConfigureAwait(false); @@ -140,7 +142,7 @@ private async Task OnMessageCreated(DiscordClient sender, MessageCreateEventArgs } else { - Logger.Info($"{e.Author} used unknown macro '{command}' in {channel}"); + _logger.LogInformation("{Author} used unknown macro \'{Command}\' in {Channel}", e.Author, command, channel); if (unknownEmoji is not null) await e.Message.CreateReactionAsync(unknownEmoji).ConfigureAwait(false); diff --git a/Marco/Services/MacroService.cs b/Marco/Services/MacroService.cs index 2e0dd4a..9419d05 100644 --- a/Marco/Services/MacroService.cs +++ b/Marco/Services/MacroService.cs @@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using NLog; +using Microsoft.Extensions.Logging; using X10D.Collections; namespace Marco.Services; @@ -17,7 +17,7 @@ namespace Marco.Services; /// internal sealed class MacroService : BackgroundService { - private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; private readonly DiscordClient _discordClient; private readonly Dictionary> _macros = new(); @@ -25,10 +25,12 @@ internal sealed class MacroService : BackgroundService /// /// Initializes a new instance of the class. /// + /// The logger. /// The scope factory. /// The Discord client. - public MacroService(IServiceScopeFactory scopeFactory, DiscordClient discordClient) + public MacroService(ILogger logger, IServiceScopeFactory scopeFactory, DiscordClient discordClient) { + _logger = logger; _scopeFactory = scopeFactory; _discordClient = discordClient; } @@ -324,7 +326,7 @@ public async Task UpdateFromDatabaseAsync(DiscordGuild guild) macros[aliases[index]] = macro; } - Logger.Info($"Loaded {macros.Count} macros for {guild}"); + _logger.LogInformation("Loaded {Count} macros for {Guild}", macros.Count, guild); } /// diff --git a/global.json b/global.json new file mode 100644 index 0000000..b5b37b6 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMajor", + "allowPrerelease": false + } +} \ No newline at end of file