diff --git a/.editorconfig b/.editorconfig index e2406b9..a301622 100644 --- a/.editorconfig +++ b/.editorconfig @@ -41,6 +41,12 @@ dotnet_style_qualification_for_property = true:error dotnet_style_qualification_for_method = true:error dotnet_style_qualification_for_event = true:error +# Disable primary constructors +dotnet_diagnostic.IDE0290.severity = none + +# Disable "Rename type name so it does not end in "EventHandler" +dotnet_diagnostic.CA1711.severity = none + # Language keywords vs BCL types preferences dotnet_style_predefined_type_for_locals_parameters_members = true:error dotnet_style_predefined_type_for_member_access = true:error diff --git a/src/Events/DiscordEventAttribute.cs b/src/Events/DiscordEventAttribute.cs new file mode 100644 index 0000000..202c9c6 --- /dev/null +++ b/src/Events/DiscordEventAttribute.cs @@ -0,0 +1,6 @@ +using System; + +namespace NotifierRedirecter.Events; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class DiscordEventAttribute : Attribute; diff --git a/src/Events/DiscordEventManager.cs b/src/Events/DiscordEventManager.cs new file mode 100644 index 0000000..472cfbe --- /dev/null +++ b/src/Events/DiscordEventManager.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace NotifierRedirecter.Events; + +public sealed class DiscordEventManager +{ + private readonly IServiceProvider ServiceProvider; + private readonly List EventHandlers = []; + + public DiscordEventManager(IServiceProvider serviceProvider) => this.ServiceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + + public void GatherEventHandlers(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + foreach (Type type in assembly.GetExportedTypes()) + { + foreach (MethodInfo methodInfo in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + if (methodInfo.GetCustomAttribute() is not null) + { + this.EventHandlers.Add(methodInfo); + } + } + } + } + + public void RegisterEventHandlers(object obj) + { + ArgumentNullException.ThrowIfNull(obj); + foreach (EventInfo eventInfo in obj.GetType().GetEvents(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + foreach (MethodInfo methodInfo in this.EventHandlers) + { + if (eventInfo.EventHandlerType!.GetGenericArguments().SequenceEqual(methodInfo.GetParameters().Select(parameter => parameter.ParameterType))) + { + Delegate handler = methodInfo.IsStatic + ? Delegate.CreateDelegate(eventInfo.EventHandlerType, methodInfo) + : Delegate.CreateDelegate(eventInfo.EventHandlerType, ActivatorUtilities.CreateInstance(this.ServiceProvider, methodInfo.DeclaringType!), methodInfo); + + eventInfo.AddEventHandler(obj, handler); + } + } + } + } +} diff --git a/src/Events/CommandErroredEventHandler.cs b/src/Events/Handlers/CommandErroredEventHandler.cs similarity index 98% rename from src/Events/CommandErroredEventHandler.cs rename to src/Events/Handlers/CommandErroredEventHandler.cs index 0a3fad0..ca9fe05 100644 --- a/src/Events/CommandErroredEventHandler.cs +++ b/src/Events/Handlers/CommandErroredEventHandler.cs @@ -10,7 +10,7 @@ using DSharpPlus.Exceptions; using Humanizer; -namespace NotifierRedirecter.Events; +namespace NotifierRedirecter.Events.Handlers; public sealed class CommandErroredEventHandler { diff --git a/src/Events/GuildDownloadCompletedEventHandler.cs b/src/Events/Handlers/GuildDownloadCompletedEventHandler.cs similarity index 70% rename from src/Events/GuildDownloadCompletedEventHandler.cs rename to src/Events/Handlers/GuildDownloadCompletedEventHandler.cs index ba345a2..7a871b1 100644 --- a/src/Events/GuildDownloadCompletedEventHandler.cs +++ b/src/Events/Handlers/GuildDownloadCompletedEventHandler.cs @@ -5,12 +5,13 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace NotifierRedirecter.Events; +namespace NotifierRedirecter.Events.Handlers; -public sealed class GuildDownloadCompletedEventHandler(ILogger? logger = null) +public sealed class GuildDownloadCompletedEventHandler { - private readonly ILogger _logger = logger ?? NullLogger.Instance; + private readonly ILogger _logger; + public GuildDownloadCompletedEventHandler(ILogger? logger = null) => this._logger = logger ?? NullLogger.Instance; public Task ExecuteAsync(DiscordClient _, GuildDownloadCompletedEventArgs eventArgs) { foreach (DiscordGuild guild in eventArgs.Guilds.Values) diff --git a/src/Events/MessageCreatedEventHandler.cs b/src/Events/Handlers/MessageCreatedEventHandler.cs similarity index 63% rename from src/Events/MessageCreatedEventHandler.cs rename to src/Events/Handlers/MessageCreatedEventHandler.cs index 33edde0..6eb9ad9 100644 --- a/src/Events/MessageCreatedEventHandler.cs +++ b/src/Events/Handlers/MessageCreatedEventHandler.cs @@ -9,31 +9,37 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace NotifierRedirecter.Events; +namespace NotifierRedirecter.Events.Handlers; -public sealed partial class MessageCreatedEventHandler(UserActivityTracker userActivityTracker, Database database, ILogger? logger = null) +public sealed partial class MessageCreatedEventHandler { - private readonly ILogger _logger = logger ?? NullLogger.Instance; + private readonly ILogger _logger; + private readonly UserActivityTracker _userActivityTracker; + private readonly Database _database; - public async Task ExecuteAsync(DiscordClient _, MessageCreateEventArgs eventArgs) + public MessageCreatedEventHandler(UserActivityTracker userActivityTracker, Database database, ILogger? logger = null) { - userActivityTracker.UpdateUser(eventArgs.Author.Id, eventArgs.Channel.Id); + this._userActivityTracker = userActivityTracker ?? throw new ArgumentNullException(nameof(userActivityTracker)); + this._database = database ?? throw new ArgumentNullException(nameof(database)); + this._logger = logger ?? NullLogger.Instance; + } + public async Task ExecuteAsync(DiscordClient _, MessageCreateEventArgs eventArgs) + { + this._userActivityTracker.UpdateUser(eventArgs.Author.Id, eventArgs.Channel.Id); bool shouldSilence = eventArgs.Message.Flags?.HasFlag(MessageFlags.SupressNotifications) ?? false; - DiscordMessage message = eventArgs.Message; - - // Explicitly cast to nullable to prevent erroneous compiler warning about it - // not being nullable. - DiscordMessage? reply = (DiscordMessage?)message.ReferencedMessage; // Ensure the channel is a redirect channel - if (!database.IsRedirect(message.Channel.Id)) + if (!this._database.IsRedirect(eventArgs.Message.Channel.Id)) { return; } - IEnumerable mentionedUsers = message.MentionedUsers; - if (reply is not null && reply.MentionedUsers.Contains(reply.Author)) + // Explicitly cast to nullable to prevent erroneous compiler + // warning about it not being nullable. + DiscordMessage? reply = (DiscordMessage?)eventArgs.Message.ReferencedMessage; + IEnumerable mentionedUsers = eventArgs.Message.MentionedUsers; + if (reply is not null && reply.MentionedUsers.Contains(reply.Author) && reply.Author != eventArgs.Message.Author) { mentionedUsers = mentionedUsers.Prepend(eventArgs.Message.ReferencedMessage.Author); } @@ -43,7 +49,11 @@ public async Task ExecuteAsync(DiscordClient _, MessageCreateEventArgs eventArgs { // Check if the user has explicitly opted out of being pinged. // Additionally check if the user has recently done activity within the channel. - if (user.IsBot || user == message.Author || await userActivityTracker.IsActiveAsync(user.Id, eventArgs.Channel.Id) || database.IsIgnoredUser(user.Id, eventArgs.Guild.Id, eventArgs.Channel.Id) || database.IsBlockedUser(user.Id, eventArgs.Guild.Id, eventArgs.Author.Id)) + if (user.IsBot + || user == eventArgs.Message.Author + || await this._userActivityTracker.IsActiveAsync(user.Id, eventArgs.Channel.Id) + || this._database.IsIgnoredUser(user.Id, eventArgs.Guild.Id, eventArgs.Channel.Id) + || this._database.IsBlockedUser(user.Id, eventArgs.Guild.Id, eventArgs.Author.Id)) { continue; } @@ -57,7 +67,7 @@ public async Task ExecuteAsync(DiscordClient _, MessageCreateEventArgs eventArgs } catch (NotFoundException error) { - this._logger.LogDebug(error, "User {UserId} doesn't exist!", user.Id); + this._logger.LogDebug(error, "User {UserId} could not be found.", user.Id); continue; } catch (DiscordException error) @@ -76,7 +86,7 @@ public async Task ExecuteAsync(DiscordClient _, MessageCreateEventArgs eventArgs try { DiscordMessageBuilder builder = new DiscordMessageBuilder() - .WithContent($"You were pinged by {message.Author.Mention} in {eventArgs.Channel.Mention}. [Jump! \u2197]({message.JumpLink})"); + .WithContent($"You were pinged by {eventArgs.Message.Author.Mention} in {eventArgs.Channel.Mention}. [Jump! \u2197]({eventArgs.Message.JumpLink})"); if (shouldSilence) { diff --git a/src/Events/Handlers/TypingStartedEventHandler.cs b/src/Events/Handlers/TypingStartedEventHandler.cs new file mode 100644 index 0000000..c3eaa47 --- /dev/null +++ b/src/Events/Handlers/TypingStartedEventHandler.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading.Tasks; +using DSharpPlus; +using DSharpPlus.EventArgs; + +namespace NotifierRedirecter.Events.Handlers; + +public sealed class TypingStartedEventHandler +{ + private readonly UserActivityTracker _userActivityTracker; + + public TypingStartedEventHandler(UserActivityTracker userActivityTracker) => this._userActivityTracker = userActivityTracker ?? throw new ArgumentNullException(nameof(userActivityTracker)); + public Task ExecuteAsync(DiscordClient _, TypingStartEventArgs eventArgs) + { + this._userActivityTracker.UpdateUser(eventArgs.User.Id, eventArgs.Channel.Id); + return Task.CompletedTask; + } +} diff --git a/src/Events/TypingStartedEventHandler.cs b/src/Events/TypingStartedEventHandler.cs deleted file mode 100644 index b72a6ca..0000000 --- a/src/Events/TypingStartedEventHandler.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Threading.Tasks; -using DSharpPlus; -using DSharpPlus.EventArgs; - -namespace NotifierRedirecter.Events; - -public sealed class TypingStartedEventHandler(UserActivityTracker userActivityTracker) -{ - public Task ExecuteAsync(DiscordClient _, TypingStartEventArgs eventArgs) - { - userActivityTracker.UpdateUser(eventArgs.User.Id, eventArgs.Channel.Id); - return Task.CompletedTask; - } -} diff --git a/src/Program.cs b/src/Program.cs index 77f142b..299dfe8 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using NotifierRedirecter.Events; +using NotifierRedirecter.Events.Handlers; using Serilog; using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; @@ -35,8 +35,17 @@ public static async Task Main(string[] args) serviceCollection.AddSingleton(configuration); serviceCollection.AddLogging(logger => { - string loggingFormat = configuration.GetValue("Logging:Format", "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u4}] {SourceContext}: {Message:lj}{NewLine}{Exception}") ?? throw new InvalidOperationException("Logging:Format is null"); - string filename = configuration.GetValue("Logging:Filename", "yyyy'-'MM'-'dd' 'HH'.'mm'.'ss") ?? throw new InvalidOperationException("Logging:Filename is null"); + string? loggingFormat = configuration.GetValue("Logging:Format"); + if (string.IsNullOrWhiteSpace(loggingFormat)) + { + loggingFormat = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u4}] {SourceContext}: {Message:lj}{NewLine}{Exception}"; + } + + string? filename = configuration.GetValue("Logging:Filename"); + if (!string.IsNullOrWhiteSpace(filename)) + { + filename = "yyyy'-'MM'-'dd' 'HH'.'mm'.'ss"; + } // Log both to console and the file LoggerConfiguration loggerConfiguration = new LoggerConfiguration() @@ -64,7 +73,7 @@ public static async Task Main(string[] args) [ConsoleThemeStyle.LevelFatal] = "\x1b[97;91m" })) .WriteTo.File( - $"logs/{DateTime.Now.ToUniversalTime().ToString("yyyy'-'MM'-'dd' 'HH'.'mm'.'ss", CultureInfo.InvariantCulture)}-.log", + $"logs/{DateTime.Now.ToUniversalTime().ToString(filename, CultureInfo.InvariantCulture)}-.log", formatProvider: CultureInfo.InvariantCulture, outputTemplate: loggingFormat, rollingInterval: RollingInterval.Day @@ -73,12 +82,10 @@ public static async Task Main(string[] args) // Allow specific namespace log level overrides, which allows us to hush output from things like the database basic SELECT queries on the Information level. foreach (IConfigurationSection logOverride in configuration.GetSection("logging:overrides").GetChildren()) { - if (logOverride.Value is null || !Enum.TryParse(logOverride.Value, out LogEventLevel logEventLevel)) + if (!string.IsNullOrWhiteSpace(logOverride.Value) && Enum.TryParse(logOverride.Value, out LogEventLevel logEventLevel)) { - continue; + loggerConfiguration.MinimumLevel.Override(logOverride.Key, logEventLevel); } - - loggerConfiguration.MinimumLevel.Override(logOverride.Key, logEventLevel); } logger.AddSerilog(loggerConfiguration.CreateLogger()); @@ -93,10 +100,18 @@ public static async Task Main(string[] args) // Register the Discord sharded client to the service collection serviceCollection.AddSingleton((serviceProvider) => { + ILogger logger = serviceProvider.GetRequiredService>(); IConfiguration configuration = serviceProvider.GetRequiredService(); + string? discordToken = configuration.GetValue("discord:token"); + if (string.IsNullOrWhiteSpace(discordToken)) + { + logger.LogCritical("Discord:Token was not provided to the application. Please provide a token in the configuration file, through an environment variable, or as a command line argument."); + Environment.Exit(1); + } + DiscordClient client = new(new DiscordConfiguration() { - Token = configuration.GetValue("discord:token") ?? throw new InvalidOperationException("Discord bot token is null."), + Token = discordToken, Intents = DiscordIntents.Guilds | DiscordIntents.GuildMessages | DiscordIntents.MessageContents, LoggerFactory = serviceProvider.GetRequiredService(), LogUnknownEvents = false diff --git a/src/UserActivityTracker.cs b/src/UserActivityTracker.cs index 4f8c081..46e517a 100644 --- a/src/UserActivityTracker.cs +++ b/src/UserActivityTracker.cs @@ -16,7 +16,6 @@ public sealed class UserActivityTracker private readonly ConcurrentDictionary _tracker = new(); public UserActivityTracker() => _ = this.CleanupInactiveUsersAsync(); - public void UpdateUser(ulong userId, ulong channelId) => this._tracker.AddOrUpdate(userId, (channelId, DateTimeOffset.UtcNow), (_, _) => (channelId, DateTimeOffset.UtcNow)); public async ValueTask IsActiveAsync(ulong userId, ulong channelId) {