diff --git a/.github/workflows/resharper.yml b/.github/workflows/resharper.yml index 82b2562..b0ec1a3 100644 --- a/.github/workflows/resharper.yml +++ b/.github/workflows/resharper.yml @@ -4,10 +4,12 @@ concurrency: cancel-in-progress: true on: - push: - branches: [ "master" ] pull_request: branches: [ "master" ] + merge_group: + types: [checks_requested] + push: + branches: [ "master" ] jobs: inspect-code: diff --git a/Boyfriend.csproj b/Boyfriend.csproj index f6cc7dc..a40b9a8 100644 --- a/Boyfriend.csproj +++ b/Boyfriend.csproj @@ -21,8 +21,12 @@ + - + + + + diff --git a/docs/README.md b/docs/README.md index 21ffbbf..0cde8ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,12 +1,6 @@ - - - - - - - Boyfriend Logo - - +

+ Boyfriend logo +

![GitHub License](https://img.shields.io/github/license/TeamOctolings/Boyfriend) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/TeamOctolings/Boyfriend/.github/workflows/resharper.yml?branch=master) diff --git a/docs/assets/boyfriend.png b/docs/assets/boyfriend.png new file mode 100644 index 0000000..a8a5d16 Binary files /dev/null and b/docs/assets/boyfriend.png differ diff --git a/src/Boyfriend.cs b/src/Boyfriend.cs index 6af4326..0731ccd 100644 --- a/src/Boyfriend.cs +++ b/src/Boyfriend.cs @@ -56,23 +56,28 @@ public class Boyfriend { | GatewayIntents.GuildMembers | GatewayIntents.GuildScheduledEvents); services.Configure( - settings => { - settings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1)); - settings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30)); - settings.SetAbsoluteExpiration(TimeSpan.FromDays(7)); - settings.SetSlidingExpiration(TimeSpan.FromDays(7)); + cSettings => { + cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1)); + cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30)); + cSettings.SetAbsoluteExpiration(TimeSpan.FromDays(7)); + cSettings.SetSlidingExpiration(TimeSpan.FromDays(7)); }); services.AddTransient() + // Init .AddDiscordCaching() .AddDiscordCommands(true) - .AddPreparationErrorEvent() - .AddPostExecutionEvent() + // Interactions .AddInteractivity() .AddInteractionGroup() + // Slash command event handlers + .AddPreparationErrorEvent() + .AddPostExecutionEvent() + // Services .AddSingleton() .AddSingleton() .AddHostedService() + // Slash commands .AddCommandTree() .WithCommandGroup() .WithCommandGroup() diff --git a/src/Commands/AboutCommandGroup.cs b/src/Commands/AboutCommandGroup.cs index 5ff757e..edb34d2 100644 --- a/src/Commands/AboutCommandGroup.cs +++ b/src/Commands/AboutCommandGroup.cs @@ -1,6 +1,8 @@ using System.ComponentModel; using System.Text; +using Boyfriend.Data; using Boyfriend.Services; +using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Rest; @@ -10,14 +12,12 @@ using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Formatting; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedMember.Global - namespace Boyfriend.Commands; /// /// Handles the command to show information about this bot: /about. /// +[UsedImplicitly] public class AboutCommandGroup : CommandGroup { private static readonly string[] Developers = { "Octol1ttle", "mctaylors", "neroduckale" }; private readonly ICommandContext _context; @@ -42,6 +42,7 @@ public class AboutCommandGroup : CommandGroup { /// [Command("about")] [Description("Shows Boyfriend's developers")] + [UsedImplicitly] public async Task SendAboutBotAsync() { if (!_context.TryGetContextIDs(out var guildId, out _, out _)) return Result.FromError( @@ -51,8 +52,8 @@ public class AboutCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); - Messages.Culture = cfg.GetCulture(); + var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers)); foreach (var dev in Developers) @@ -65,8 +66,7 @@ public class AboutCommandGroup : CommandGroup { var embed = new EmbedBuilder().WithSmallTitle(Messages.AboutBot, currentUser) .WithDescription(builder.ToString()) .WithColour(ColorsList.Cyan) - .WithImageUrl( - "https://media.discordapp.net/attachments/837385840946053181/1125009665592393738/boyfriend.png") + .WithImageUrl("https://cdn.upload.systems/uploads/JFAaX5vr.png") .Build(); if (!embed.IsDefined(out var built)) return Result.FromError(embed); diff --git a/src/Commands/BanCommandGroup.cs b/src/Commands/BanCommandGroup.cs index 02e0fa2..d2c1c76 100644 --- a/src/Commands/BanCommandGroup.cs +++ b/src/Commands/BanCommandGroup.cs @@ -1,11 +1,14 @@ using System.ComponentModel; using System.Text; +using Boyfriend.Data; using Boyfriend.Services; +using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Attributes; using Remora.Discord.Commands.Conditions; using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Feedback.Services; @@ -13,14 +16,12 @@ using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Formatting; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedMember.Global - namespace Boyfriend.Commands; /// /// Handles commands related to ban management: /ban and /unban. /// +[UsedImplicitly] public class BanCommandGroup : CommandGroup { private readonly IDiscordRestChannelAPI _channelApi; private readonly ICommandContext _context; @@ -58,10 +59,13 @@ public class BanCommandGroup : CommandGroup { /// /// [Command("ban", "бан")] + [DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)] + [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] [RequireDiscordPermission(DiscordPermission.BanMembers)] [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [Description("Ban user")] + [UsedImplicitly] public async Task BanUserAsync( [Description("User to ban")] IUser target, [Description("Ban reason")] string reason, @@ -76,8 +80,8 @@ public class BanCommandGroup : CommandGroup { return Result.FromError(currentUserResult); var data = await _dataService.GetData(guildId.Value, CancellationToken); - var cfg = data.Configuration; - Messages.Culture = data.Culture; + var cfg = data.Settings; + Messages.Culture = GuildSettings.Language.Get(cfg); var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken); if (existingBanResult.IsDefined()) { @@ -145,8 +149,10 @@ public class BanCommandGroup : CommandGroup { string.Format(Messages.UserBanned, target.GetTag()), target) .WithColour(ColorsList.Green).Build(); - if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) - || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) + || (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) { var logEmbed = new EmbedBuilder().WithSmallTitle( string.Format(Messages.UserBanned, target.GetTag()), target) .WithDescription(description) @@ -160,14 +166,14 @@ public class BanCommandGroup : CommandGroup { var builtArray = new[] { logBuilt }; // Not awaiting to reduce response time - if (cfg.PublicFeedbackChannel != channelId.Value) + if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); - if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel - && cfg.PrivateFeedbackChannel != channelId.Value) + if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg) + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); } } @@ -193,10 +199,13 @@ public class BanCommandGroup : CommandGroup { /// /// [Command("unban")] + [DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)] + [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] [RequireDiscordPermission(DiscordPermission.BanMembers)] [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [Description("Unban user")] + [UsedImplicitly] public async Task UnbanUserAsync( [Description("User to unban")] IUser target, [Description("Unban reason")] string reason) { @@ -209,8 +218,8 @@ public class BanCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); - Messages.Culture = cfg.GetCulture(); + var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken); if (!existingBanResult.IsDefined()) { @@ -238,8 +247,10 @@ public class BanCommandGroup : CommandGroup { string.Format(Messages.UserUnbanned, target.GetTag()), target) .WithColour(ColorsList.Green).Build(); - if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) - || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) + || (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) { var logEmbed = new EmbedBuilder().WithSmallTitle( string.Format(Messages.UserUnbanned, target.GetTag()), target) .WithDescription(string.Format(Messages.DescriptionActionReason, reason)) @@ -254,14 +265,14 @@ public class BanCommandGroup : CommandGroup { var builtArray = new[] { logBuilt }; // Not awaiting to reduce response time - if (cfg.PublicFeedbackChannel != channelId.Value) + if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); - if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel - && cfg.PrivateFeedbackChannel != channelId.Value) + if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg) + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); } diff --git a/src/Commands/ClearCommandGroup.cs b/src/Commands/ClearCommandGroup.cs index de44fbb..ede4d0b 100644 --- a/src/Commands/ClearCommandGroup.cs +++ b/src/Commands/ClearCommandGroup.cs @@ -1,6 +1,8 @@ using System.ComponentModel; using System.Text; +using Boyfriend.Data; using Boyfriend.Services; +using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; @@ -14,14 +16,12 @@ using Remora.Discord.Extensions.Formatting; using Remora.Rest.Core; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedMember.Global - namespace Boyfriend.Commands; /// /// Handles the command to clear messages in a channel: /clear. /// +[UsedImplicitly] public class ClearCommandGroup : CommandGroup { private readonly IDiscordRestChannelAPI _channelApi; private readonly ICommandContext _context; @@ -48,10 +48,13 @@ public class ClearCommandGroup : CommandGroup { /// were cleared and vice-versa. /// [Command("clear", "очистить")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] + [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] [RequireDiscordPermission(DiscordPermission.ManageMessages)] [RequireBotDiscordPermissions(DiscordPermission.ManageMessages)] [Description("Remove multiple messages")] + [UsedImplicitly] public async Task ClearMessagesAsync( [Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)] int amount) { @@ -64,8 +67,8 @@ public class ClearCommandGroup : CommandGroup { if (!messagesResult.IsDefined(out var messages)) return Result.FromError(messagesResult); - var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); - Messages.Culture = cfg.GetCulture(); + var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); var idList = new List(messages.Count); var builder = new StringBuilder().AppendLine(Mention.Channel(channelId.Value)).AppendLine(); @@ -93,7 +96,8 @@ public class ClearCommandGroup : CommandGroup { return Result.FromError(currentUserResult); var title = string.Format(Messages.MessagesCleared, amount.ToString()); - if (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value) { + if (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) { var logEmbed = new EmbedBuilder().WithSmallTitle(title, currentUser) .WithDescription(description) .WithActionFooter(user) @@ -105,9 +109,9 @@ public class ClearCommandGroup : CommandGroup { return Result.FromError(logEmbed); // Not awaiting to reduce response time - if (cfg.PrivateFeedbackChannel != channelId.Value) + if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { logBuilt }, + GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { logBuilt }, ct: CancellationToken); } diff --git a/src/Commands/ErrorLoggingEvents.cs b/src/Commands/ErrorLoggingEvents.cs index 30869b4..c5eba21 100644 --- a/src/Commands/ErrorLoggingEvents.cs +++ b/src/Commands/ErrorLoggingEvents.cs @@ -1,15 +1,16 @@ +using JetBrains.Annotations; using Microsoft.Extensions.Logging; using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Extensions; using Remora.Discord.Commands.Services; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global - namespace Boyfriend.Commands; /// /// Handles error logging for slash commands that couldn't be successfully prepared. /// +[UsedImplicitly] public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent { private readonly ILogger _logger; @@ -27,8 +28,11 @@ public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent { /// A result which has succeeded. public Task PreparationFailed( IOperationContext context, IResult preparationResult, CancellationToken ct = default) { - if (!preparationResult.IsSuccess) + if (!preparationResult.IsSuccess && !preparationResult.Error.IsUserOrEnvironmentError()) { _logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message); + if (preparationResult.Error is ExceptionError exerr) + _logger.LogError(exerr.Exception, "An exception has been thrown"); + } return Task.FromResult(Result.FromSuccess()); } @@ -37,6 +41,7 @@ public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent { /// /// Handles error logging for slash command groups. /// +[UsedImplicitly] public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent { private readonly ILogger _logger; @@ -54,8 +59,11 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent { /// A result which has succeeded. public Task AfterExecutionAsync( ICommandContext context, IResult commandResult, CancellationToken ct = default) { - if (!commandResult.IsSuccess) + if (!commandResult.IsSuccess && !commandResult.Error.IsUserOrEnvironmentError()) { _logger.LogWarning("Error in slash command execution.\n{ErrorMessage}", commandResult.Error.Message); + if (commandResult.Error is ExceptionError exerr) + _logger.LogError(exerr.Exception, "An exception has been thrown"); + } return Task.FromResult(Result.FromSuccess()); } diff --git a/src/Commands/KickCommandGroup.cs b/src/Commands/KickCommandGroup.cs index da7b2c5..5809677 100644 --- a/src/Commands/KickCommandGroup.cs +++ b/src/Commands/KickCommandGroup.cs @@ -1,24 +1,25 @@ using System.ComponentModel; +using Boyfriend.Data; using Boyfriend.Services; +using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Attributes; using Remora.Discord.Commands.Conditions; using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Extensions.Embeds; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedMember.Global - namespace Boyfriend.Commands; /// /// Handles the command to kick members of a guild: /kick. /// +[UsedImplicitly] public class KickCommandGroup : CommandGroup { private readonly IDiscordRestChannelAPI _channelApi; private readonly ICommandContext _context; @@ -54,10 +55,13 @@ public class KickCommandGroup : CommandGroup { /// was kicked and vice-versa. /// [Command("kick", "кик")] + [DiscordDefaultMemberPermissions(DiscordPermission.KickMembers)] + [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] [RequireDiscordPermission(DiscordPermission.KickMembers)] [RequireBotDiscordPermissions(DiscordPermission.KickMembers)] [Description("Kick member")] + [UsedImplicitly] public async Task KickUserAsync( [Description("Member to kick")] IUser target, [Description("Kick reason")] string reason) { @@ -71,8 +75,8 @@ public class KickCommandGroup : CommandGroup { return Result.FromError(currentUserResult); var data = await _dataService.GetData(guildId.Value, CancellationToken); - var cfg = data.Configuration; - Messages.Culture = cfg.GetCulture(); + var cfg = data.Settings; + Messages.Culture = GuildSettings.Language.Get(cfg); var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); if (!memberResult.IsSuccess) { @@ -129,8 +133,10 @@ public class KickCommandGroup : CommandGroup { string.Format(Messages.UserKicked, target.GetTag()), target) .WithColour(ColorsList.Green).Build(); - if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) - || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) + || (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) { var logEmbed = new EmbedBuilder().WithSmallTitle( string.Format(Messages.UserKicked, target.GetTag()), target) .WithDescription(string.Format(Messages.DescriptionActionReason, reason)) @@ -144,14 +150,14 @@ public class KickCommandGroup : CommandGroup { var builtArray = new[] { logBuilt }; // Not awaiting to reduce response time - if (cfg.PublicFeedbackChannel != channelId.Value) + if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); - if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel - && cfg.PrivateFeedbackChannel != channelId.Value) + if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg) + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); } } diff --git a/src/Commands/MuteCommandGroup.cs b/src/Commands/MuteCommandGroup.cs index 764b4f4..4338d09 100644 --- a/src/Commands/MuteCommandGroup.cs +++ b/src/Commands/MuteCommandGroup.cs @@ -1,11 +1,14 @@ using System.ComponentModel; using System.Text; +using Boyfriend.Data; using Boyfriend.Services; +using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Attributes; using Remora.Discord.Commands.Conditions; using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Feedback.Services; @@ -13,14 +16,12 @@ using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Formatting; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedMember.Global - namespace Boyfriend.Commands; /// /// Handles commands related to mute management: /mute and /unmute. /// +[UsedImplicitly] public class MuteCommandGroup : CommandGroup { private readonly IDiscordRestChannelAPI _channelApi; private readonly ICommandContext _context; @@ -58,10 +59,13 @@ public class MuteCommandGroup : CommandGroup { /// /// [Command("mute", "мут")] + [DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] + [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] [RequireDiscordPermission(DiscordPermission.ModerateMembers)] [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [Description("Mute member")] + [UsedImplicitly] public async Task MuteUserAsync( [Description("Member to mute")] IUser target, [Description("Mute reason")] string reason, @@ -93,8 +97,8 @@ public class MuteCommandGroup : CommandGroup { return Result.FromError(interactionResult); var data = await _dataService.GetData(guildId.Value, CancellationToken); - var cfg = data.Configuration; - Messages.Culture = data.Culture; + var cfg = data.Settings; + Messages.Culture = GuildSettings.Language.Get(cfg); Result responseEmbed; if (interactionResult.Entity is not null) { @@ -116,8 +120,10 @@ public class MuteCommandGroup : CommandGroup { string.Format(Messages.UserMuted, target.GetTag()), target) .WithColour(ColorsList.Green).Build(); - if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) - || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) + || (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) { var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason)) .Append( string.Format( @@ -136,14 +142,14 @@ public class MuteCommandGroup : CommandGroup { var builtArray = new[] { logBuilt }; // Not awaiting to reduce response time - if (cfg.PublicFeedbackChannel != channelId.Value) + if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); - if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel - && cfg.PrivateFeedbackChannel != channelId.Value) + if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg) + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); } } @@ -169,10 +175,13 @@ public class MuteCommandGroup : CommandGroup { /// /// [Command("unmute", "размут")] + [DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] + [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] [RequireDiscordPermission(DiscordPermission.ModerateMembers)] [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [Description("Unmute member")] + [UsedImplicitly] public async Task UnmuteUserAsync( [Description("Member to unmute")] IUser target, [Description("Unmute reason")] string reason) { @@ -185,8 +194,8 @@ public class MuteCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); - Messages.Culture = cfg.GetCulture(); + var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); if (!memberResult.IsSuccess) { @@ -220,8 +229,10 @@ public class MuteCommandGroup : CommandGroup { string.Format(Messages.UserUnmuted, target.GetTag()), target) .WithColour(ColorsList.Green).Build(); - if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value) - || (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) { + if ((!GuildSettings.PublicFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) + || (!GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty() + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value)) { var logEmbed = new EmbedBuilder().WithSmallTitle( string.Format(Messages.UserUnmuted, target.GetTag()), target) .WithDescription(string.Format(Messages.DescriptionActionReason, reason)) @@ -236,14 +247,14 @@ public class MuteCommandGroup : CommandGroup { var builtArray = new[] { logBuilt }; // Not awaiting to reduce response time - if (cfg.PublicFeedbackChannel != channelId.Value) + if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); - if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel - && cfg.PrivateFeedbackChannel != channelId.Value) + if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg) + && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) _ = _channelApi.CreateMessageAsync( - cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray, + GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray, ct: CancellationToken); } diff --git a/src/Commands/PingCommandGroup.cs b/src/Commands/PingCommandGroup.cs index 45d27e2..52d924f 100644 --- a/src/Commands/PingCommandGroup.cs +++ b/src/Commands/PingCommandGroup.cs @@ -1,5 +1,7 @@ using System.ComponentModel; +using Boyfriend.Data; using Boyfriend.Services; +using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Rest; @@ -9,14 +11,12 @@ using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedMember.Global - namespace Boyfriend.Commands; /// /// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping /// +[UsedImplicitly] public class PingCommandGroup : CommandGroup { private readonly IDiscordRestChannelAPI _channelApi; private readonly DiscordGatewayClient _client; @@ -44,6 +44,7 @@ public class PingCommandGroup : CommandGroup { /// [Command("ping", "пинг")] [Description("Get bot latency")] + [UsedImplicitly] public async Task SendPingAsync() { if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _)) return Result.FromError( @@ -53,8 +54,8 @@ public class PingCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); - Messages.Culture = cfg.GetCulture(); + var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); var latency = _client.Latency.TotalMilliseconds; if (latency is 0) { diff --git a/src/Commands/RemindCommandGroup.cs b/src/Commands/RemindCommandGroup.cs index d1c4519..7203fbd 100644 --- a/src/Commands/RemindCommandGroup.cs +++ b/src/Commands/RemindCommandGroup.cs @@ -1,23 +1,23 @@ using System.ComponentModel; using Boyfriend.Data; using Boyfriend.Services; +using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Formatting; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedMember.Global - namespace Boyfriend.Commands; /// /// Handles the command to manage reminders: /remind /// +[UsedImplicitly] public class RemindCommandGroup : CommandGroup { private readonly ICommandContext _context; private readonly GuildDataService _dataService; @@ -40,7 +40,9 @@ public class RemindCommandGroup : CommandGroup { /// The text of the reminder. /// A feedback sending result which may or may not have succeeded. [Command("remind")] + [DiscordDefaultDMPermission(false)] [Description("Create a reminder")] + [UsedImplicitly] public async Task AddReminderAsync( [Description("After what period of time mention the reminder")] TimeSpan @in, @@ -57,8 +59,8 @@ public class RemindCommandGroup : CommandGroup { (await _dataService.GetMemberData(guildId.Value, userId.Value, CancellationToken)).Reminders.Add( new Reminder { - RemindAt = remindAt, - Channel = channelId.Value, + At = remindAt, + Channel = channelId.Value.Value, Text = message }); diff --git a/src/Commands/SettingsCommandGroup.cs b/src/Commands/SettingsCommandGroup.cs index e519d37..5c4a505 100644 --- a/src/Commands/SettingsCommandGroup.cs +++ b/src/Commands/SettingsCommandGroup.cs @@ -1,26 +1,44 @@ using System.ComponentModel; -using System.Reflection; using System.Text; using Boyfriend.Data; +using Boyfriend.Data.Options; using Boyfriend.Services; +using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Formatting; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedMember.Global - namespace Boyfriend.Commands; /// /// Handles the commands to list and modify per-guild settings: /settings and /settings list. /// +[UsedImplicitly] public class SettingsCommandGroup : CommandGroup { + private static readonly IOption[] AllOptions = { + GuildSettings.Language, + GuildSettings.WelcomeMessage, + GuildSettings.ReceiveStartupMessages, + GuildSettings.RemoveRolesOnMute, + GuildSettings.ReturnRolesOnRejoin, + GuildSettings.AutoStartEvents, + GuildSettings.PublicFeedbackChannel, + GuildSettings.PrivateFeedbackChannel, + GuildSettings.EventNotificationChannel, + GuildSettings.DefaultRole, + GuildSettings.MuteRole, + GuildSettings.EventNotificationRole, + GuildSettings.EventEarlyNotificationOffset + }; + private readonly ICommandContext _context; private readonly GuildDataService _dataService; private readonly FeedbackService _feedbackService; @@ -36,13 +54,18 @@ public class SettingsCommandGroup : CommandGroup { } /// - /// A slash command that lists current per-guild settings. + /// A slash command that lists current per-guild GuildSettings. /// /// /// A feedback sending result which may or may not have succeeded. /// [Command("settingslist")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageGuild)] [Description("Shows settings list for this server")] + [UsedImplicitly] public async Task ListSettingsAsync() { if (!_context.TryGetContextIDs(out var guildId, out _, out _)) return Result.FromError( @@ -52,19 +75,15 @@ public class SettingsCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); - Messages.Culture = cfg.GetCulture(); + var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); var builder = new StringBuilder(); - foreach (var setting in typeof(GuildConfiguration).GetProperties()) { - builder.Append(Markdown.InlineCode(setting.Name)) + foreach (var option in AllOptions) { + builder.Append(Markdown.InlineCode(option.Name)) .Append(": "); - var something = setting.GetValue(cfg); - if (something!.GetType() == typeof(List)) { - var list = (something as List); - builder.AppendLine(string.Join(", ", list!.Select(v => Markdown.InlineCode(v.ToString())))); - } else { builder.AppendLine(Markdown.InlineCode(something.ToString()!)); } + builder.AppendLine(option.Display(cfg)); } var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingsListTitle, currentUser) @@ -77,13 +96,18 @@ public class SettingsCommandGroup : CommandGroup { } /// - /// A slash command that modifies per-guild settings. + /// A slash command that modifies per-guild GuildSettings. /// /// The setting to modify. /// The new value of the setting. /// A feedback sending result which may or may not have succeeded. [Command("settings")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageGuild)] [Description("Change settings for this server")] + [UsedImplicitly] public async Task EditSettingsAsync( [Description("The setting whose value you want to change")] string setting, @@ -96,40 +120,16 @@ public class SettingsCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken); - Messages.Culture = cfg.GetCulture(); + var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); - PropertyInfo? property = null; + var option = AllOptions.Single( + o => string.Equals(setting, o.Name, StringComparison.InvariantCultureIgnoreCase)); - try { - foreach (var prop in typeof(GuildConfiguration).GetProperties()) - if (string.Equals(setting, prop.Name, StringComparison.CurrentCultureIgnoreCase)) - property = prop; - if (property == null || !property.CanWrite) - throw new ApplicationException(Messages.SettingDoesntExist); - var type = property.PropertyType; - - if (value is "reset" or "default") { property.SetValue(cfg, null); } else if (type == typeof(string)) { - if (setting == "language" && value is not ("ru" or "en" or "mctaylors-ru")) - throw new ApplicationException(Messages.LanguageNotSupported); - property.SetValue(cfg, value); - } else { - try { - if (type == typeof(bool)) - property.SetValue(cfg, Convert.ToBoolean(value)); - - if (type == typeof(ulong)) { - var id = Convert.ToUInt64(value); - - property.SetValue(cfg, id); - } - } catch (Exception e) when (e is FormatException or OverflowException) { - throw new ApplicationException(Messages.InvalidSettingValue); - } - } - } catch (Exception e) { + var setResult = option.Set(cfg, value); + if (!setResult.IsSuccess) { var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, currentUser) - .WithDescription(e.Message) + .WithDescription(setResult.Error.Message) .WithColour(ColorsList.Red) .Build(); if (!failedEmbed.IsDefined(out var failedBuilt)) return Result.FromError(failedEmbed); @@ -139,9 +139,9 @@ public class SettingsCommandGroup : CommandGroup { var builder = new StringBuilder(); - builder.Append(Markdown.InlineCode(setting)) + builder.Append(Markdown.InlineCode(option.Name)) .Append($" {Messages.SettingIsNow} ") - .Append(Markdown.InlineCode(value)); + .Append(option.Display(cfg)); var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingSuccessfullyChanged, currentUser) .WithDescription(builder.ToString()) diff --git a/src/Data/GuildConfiguration.cs b/src/Data/GuildConfiguration.cs deleted file mode 100644 index 440e2b7..0000000 --- a/src/Data/GuildConfiguration.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Globalization; -using Remora.Discord.API.Abstractions.Objects; - -namespace Boyfriend.Data; - -/// -/// Stores per-guild settings that can be set by a member -/// with using the /settings command -/// -public class GuildConfiguration { - /// - /// Represents a scheduled event notification receiver. - /// - /// - /// Used to selectively mention guild members when a scheduled event has started or is about to start. - /// - public enum NotificationReceiver { - Interested, - Role - } - - public static readonly Dictionary CultureInfoCache = new() { - { "en", new CultureInfo("en-US") }, - { "ru", new CultureInfo("ru-RU") }, - { "mctaylors-ru", new CultureInfo("tt-RU") } - }; - - public string Language { get; set; } = "en"; - - /// - /// Controls what message should be sent in when a new member joins the server. - /// - /// - /// - /// No message will be sent if set to "off", "disable" or "disabled". - /// will be sent if set to "default" or "reset" - /// - /// - /// - public string WelcomeMessage { get; set; } = "default"; - - /// - /// Controls whether or not the message should be sent - /// in on startup. - /// - /// - public bool ReceiveStartupMessages { get; set; } - - public bool RemoveRolesOnMute { get; set; } - - /// - /// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back. - /// - /// Roles will not be returned if the member left the guild because of /ban or /kick. - public bool ReturnRolesOnRejoin { get; set; } - - public bool AutoStartEvents { get; set; } - - /// - /// Controls what channel should all public messages be sent to. - /// - public ulong PublicFeedbackChannel { get; set; } - - /// - /// Controls what channel should all private, moderator-only messages be sent to. - /// - public ulong PrivateFeedbackChannel { get; set; } - - public ulong EventNotificationChannel { get; set; } - public ulong DefaultRole { get; set; } - public ulong MuteRole { get; set; } - public ulong EventNotificationRole { get; set; } - - /// - /// Controls what guild members should be mentioned when a scheduled event has started or is about to start. - /// - /// - public List EventStartedReceivers { get; set; } - = new() { NotificationReceiver.Interested, NotificationReceiver.Role }; - - /// - /// Controls the amount of time before a scheduled event to send a reminder in . - /// - public TimeSpan EventEarlyNotificationOffset { get; set; } = TimeSpan.Zero; - - // Do not convert this to a property, else serialization will be attempted - public CultureInfo GetCulture() { - return CultureInfoCache[Language]; - } -} diff --git a/src/Data/GuildData.cs b/src/Data/GuildData.cs index 992adc0..7c81364 100644 --- a/src/Data/GuildData.cs +++ b/src/Data/GuildData.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Text.Json.Nodes; using Remora.Rest.Core; namespace Boyfriend.Data; @@ -8,29 +8,26 @@ namespace Boyfriend.Data; /// /// This information is stored on disk as a JSON file. public class GuildData { - public readonly GuildConfiguration Configuration; - public readonly string ConfigurationPath; - public readonly Dictionary MemberData; public readonly string MemberDataPath; public readonly Dictionary ScheduledEvents; public readonly string ScheduledEventsPath; + public readonly JsonNode Settings; + public readonly string SettingsPath; public GuildData( - GuildConfiguration configuration, string configurationPath, + JsonNode settings, string settingsPath, Dictionary scheduledEvents, string scheduledEventsPath, Dictionary memberData, string memberDataPath) { - Configuration = configuration; - ConfigurationPath = configurationPath; + Settings = settings; + SettingsPath = settingsPath; ScheduledEvents = scheduledEvents; ScheduledEventsPath = scheduledEventsPath; MemberData = memberData; MemberDataPath = memberDataPath; } - public CultureInfo Culture => Configuration.GetCulture(); - public MemberData GetMemberData(Snowflake userId) { if (MemberData.TryGetValue(userId.Value, out var existing)) return existing; diff --git a/src/Data/GuildSettings.cs b/src/Data/GuildSettings.cs new file mode 100644 index 0000000..07e43a6 --- /dev/null +++ b/src/Data/GuildSettings.cs @@ -0,0 +1,63 @@ +using Boyfriend.Data.Options; +using Boyfriend.Responders; +using Remora.Discord.API.Abstractions.Objects; + +namespace Boyfriend.Data; + +/// +/// Contains all per-guild settings that can be set by a member +/// with using the /settings command +/// +public static class GuildSettings { + public static readonly LanguageOption Language = new("Language", "en"); + + /// + /// Controls what message should be sent in when a new member joins the server. + /// + /// + /// + /// No message will be sent if set to "off", "disable" or "disabled". + /// will be sent if set to "default" or "reset" + /// + /// + /// + public static readonly Option WelcomeMessage = new("WelcomeMessage", "default"); + + /// + /// Controls whether or not the message should be sent + /// in on startup. + /// + /// + public static readonly BoolOption ReceiveStartupMessages = new("ReceiveStartupMessages", false); + + public static readonly BoolOption RemoveRolesOnMute = new("RemoveRolesOnMute", false); + + /// + /// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back. + /// + /// Roles will not be returned if the member left the guild because of /ban or /kick. + public static readonly BoolOption ReturnRolesOnRejoin = new("ReturnRolesOnRejoin", false); + + public static readonly BoolOption AutoStartEvents = new("AutoStartEvents", false); + + /// + /// Controls what channel should all public messages be sent to. + /// + public static readonly SnowflakeOption PublicFeedbackChannel = new("PublicFeedbackChannel"); + + /// + /// Controls what channel should all private, moderator-only messages be sent to. + /// + public static readonly SnowflakeOption PrivateFeedbackChannel = new("PrivateFeedbackChannel"); + + public static readonly SnowflakeOption EventNotificationChannel = new("EventNotificationChannel"); + public static readonly SnowflakeOption DefaultRole = new("DefaultRole"); + public static readonly SnowflakeOption MuteRole = new("MuteRole"); + public static readonly SnowflakeOption EventNotificationRole = new("EventNotificationRole"); + + /// + /// Controls the amount of time before a scheduled event to send a reminder in . + /// + public static readonly TimeSpanOption EventEarlyNotificationOffset = new( + "EventEarlyNotificationOffset", TimeSpan.Zero); +} diff --git a/src/Data/MemberData.cs b/src/Data/MemberData.cs index 72cbdec..7d49ec7 100644 --- a/src/Data/MemberData.cs +++ b/src/Data/MemberData.cs @@ -1,5 +1,3 @@ -using Remora.Rest.Core; - namespace Boyfriend.Data; /// @@ -13,6 +11,6 @@ public class MemberData { public ulong Id { get; } public DateTimeOffset? BannedUntil { get; set; } - public List Roles { get; set; } = new(); + public List Roles { get; set; } = new(); public List Reminders { get; } = new(); } diff --git a/src/Data/Options/BoolOption.cs b/src/Data/Options/BoolOption.cs new file mode 100644 index 0000000..a8ee954 --- /dev/null +++ b/src/Data/Options/BoolOption.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; +using Remora.Results; + +namespace Boyfriend.Data.Options; + +public class BoolOption : Option { + public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { } + + public override string Display(JsonNode settings) { + return Get(settings) ? Messages.Yes : Messages.No; + } + + public override Result Set(JsonNode settings, string from) { + if (!TryParseBool(from, out var value)) + return Result.FromError(new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue)); + + settings[Name] = value; + return Result.FromSuccess(); + } + + private static bool TryParseBool(string from, out bool value) { + value = false; + switch (from) { + case "1" or "y" or "yes" or "д" or "да": + value = true; + return true; + case "0" or "n" or "no" or "н" or "не" or "нет": + value = false; + return true; + default: + return false; + } + } +} diff --git a/src/Data/Options/IOption.cs b/src/Data/Options/IOption.cs new file mode 100644 index 0000000..fc0f747 --- /dev/null +++ b/src/Data/Options/IOption.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Nodes; +using Remora.Results; + +namespace Boyfriend.Data.Options; + +public interface IOption { + string Name { get; } + string Display(JsonNode settings); + Result Set(JsonNode settings, string from); +} diff --git a/src/Data/Options/LanguageOption.cs b/src/Data/Options/LanguageOption.cs new file mode 100644 index 0000000..6c4a49f --- /dev/null +++ b/src/Data/Options/LanguageOption.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; + +namespace Boyfriend.Data.Options; + +/// +public class LanguageOption : Option { + private static readonly Dictionary CultureInfoCache = new() { + { "en", new CultureInfo("en-US") }, + { "ru", new CultureInfo("ru-RU") }, + { "mctaylors-ru", new CultureInfo("tt-RU") } + }; + + public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { } + + public override string Display(JsonNode settings) { + return Markdown.InlineCode(settings[Name]?.GetValue() ?? "en"); + } + + /// + public override CultureInfo Get(JsonNode settings) { + var property = settings[Name]; + return property != null ? CultureInfoCache[property.GetValue()] : DefaultValue; + } + + /// + public override Result Set(JsonNode settings, string from) { + if (!CultureInfoCache.ContainsKey(from.ToLowerInvariant())) + return Result.FromError(new ArgumentInvalidError(nameof(from), Messages.LanguageNotSupported)); + + return base.Set(settings, from.ToLowerInvariant()); + } +} diff --git a/src/Data/Options/Option.cs b/src/Data/Options/Option.cs new file mode 100644 index 0000000..742d3a9 --- /dev/null +++ b/src/Data/Options/Option.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Nodes; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; + +namespace Boyfriend.Data.Options; + +/// +/// Represents an per-guild option. +/// +/// The type of the option. +public class Option : IOption +where T : notnull { + internal readonly T DefaultValue; + + public Option(string name, T defaultValue) { + Name = name; + DefaultValue = defaultValue; + } + + public string Name { get; } + + public virtual string Display(JsonNode settings) { + return Markdown.InlineCode(Get(settings).ToString()!); + } + + /// + /// Sets the value of the option from a to the provided JsonNode. + /// + /// The to set the value to. + /// The string from which the new value of the option will be parsed. + /// A value setting result which may or may not have succeeded. + public virtual Result Set(JsonNode settings, string from) { + settings[Name] = from; + return Result.FromSuccess(); + } + + /// + /// Gets the value of the option from the provided . + /// + /// The to get the value from. + /// The value of the option. + public virtual T Get(JsonNode settings) { + var property = settings[Name]; + return property != null ? property.GetValue() : DefaultValue; + } +} diff --git a/src/Data/Options/SnowflakeOption.cs b/src/Data/Options/SnowflakeOption.cs new file mode 100644 index 0000000..f65065c --- /dev/null +++ b/src/Data/Options/SnowflakeOption.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; + +namespace Boyfriend.Data.Options; + +public class SnowflakeOption : Option { + public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { } + + public override string Display(JsonNode settings) { + return Name.EndsWith("Channel") ? Mention.Channel(Get(settings)) : Mention.Role(Get(settings)); + } + + public override Snowflake Get(JsonNode settings) { + var property = settings[Name]; + return property != null ? property.GetValue().ToSnowflake() : DefaultValue; + } + + public override Result Set(JsonNode settings, string from) { + if (!ulong.TryParse(from, out var parsed)) + return Result.FromError(new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue)); + + settings[Name] = parsed; + return Result.FromSuccess(); + } +} diff --git a/src/Data/Options/TimeSpanOption.cs b/src/Data/Options/TimeSpanOption.cs new file mode 100644 index 0000000..659d88c --- /dev/null +++ b/src/Data/Options/TimeSpanOption.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Nodes; +using Remora.Commands.Parsers; +using Remora.Results; + +namespace Boyfriend.Data.Options; + +public class TimeSpanOption : Option { + private static readonly TimeSpanParser Parser = new(); + + public TimeSpanOption(string name, TimeSpan defaultValue) : base(name, defaultValue) { } + + public override TimeSpan Get(JsonNode settings) { + var property = settings[Name]; + return property != null ? ParseTimeSpan(property.GetValue()).Entity : DefaultValue; + } + + public override Result Set(JsonNode settings, string from) { + if (!ParseTimeSpan(from).IsDefined(out var span)) + return Result.FromError(new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue)); + + settings[Name] = span.ToString(); + return Result.FromSuccess(); + } + + private static Result ParseTimeSpan(string from) { + return Parser.TryParseAsync(from).AsTask().GetAwaiter().GetResult(); + } +} diff --git a/src/Data/Reminder.cs b/src/Data/Reminder.cs index 1d0410c..2246b5e 100644 --- a/src/Data/Reminder.cs +++ b/src/Data/Reminder.cs @@ -1,9 +1,7 @@ -using Remora.Rest.Core; - namespace Boyfriend.Data; public struct Reminder { - public DateTimeOffset RemindAt; + public DateTimeOffset At; public string Text; - public Snowflake Channel; + public ulong Channel; } diff --git a/src/EventResponders.cs b/src/EventResponders.cs deleted file mode 100644 index 708bbc1..0000000 --- a/src/EventResponders.cs +++ /dev/null @@ -1,335 +0,0 @@ -using Boyfriend.Data; -using Boyfriend.Services; -using DiffPlex.DiffBuilder; -using Microsoft.Extensions.Logging; -using Remora.Discord.API.Abstractions.Gateway.Events; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.Caching; -using Remora.Discord.Caching.Services; -using Remora.Discord.Extensions.Embeds; -using Remora.Discord.Extensions.Formatting; -using Remora.Discord.Gateway.Responders; -using Remora.Rest.Core; -using Remora.Results; - -// ReSharper disable UnusedType.Global - -namespace Boyfriend; - -/// -/// Handles sending a message to a guild that has just initialized if that guild -/// has enabled -/// -public class GuildCreateResponder : IResponder { - private readonly IDiscordRestChannelAPI _channelApi; - private readonly GuildDataService _dataService; - private readonly ILogger _logger; - private readonly IDiscordRestUserAPI _userApi; - - public GuildCreateResponder( - IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger logger, - IDiscordRestUserAPI userApi) { - _channelApi = channelApi; - _dataService = dataService; - _logger = logger; - _userApi = userApi; - } - - public async Task RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) { - if (!gatewayEvent.Guild.IsT0) return Result.FromSuccess(); // Guild isn't IAvailableGuild - - var guild = gatewayEvent.Guild.AsT0; - _logger.LogInformation("Joined guild \"{Name}\"", guild.Name); - - var guildConfig = await _dataService.GetConfiguration(guild.ID, ct); - if (!guildConfig.ReceiveStartupMessages) - return Result.FromSuccess(); - if (guildConfig.PrivateFeedbackChannel is 0) - return Result.FromSuccess(); - - var currentUserResult = await _userApi.GetCurrentUserAsync(ct); - if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - - Messages.Culture = guildConfig.GetCulture(); - var i = Random.Shared.Next(1, 4); - - var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser) - .WithTitle($"Beep{i}".Localized()) - .WithDescription(Messages.Ready) - .WithCurrentTimestamp() - .WithColour(ColorsList.Blue) - .Build(); - if (!embed.IsDefined(out var built)) return Result.FromError(embed); - - return (Result)await _channelApi.CreateMessageAsync( - guildConfig.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, ct: ct); - } -} - -/// -/// Handles logging the contents of a deleted message and the user who deleted the message -/// to a guild's if one is set. -/// -public class MessageDeletedResponder : IResponder { - private readonly IDiscordRestAuditLogAPI _auditLogApi; - private readonly IDiscordRestChannelAPI _channelApi; - private readonly GuildDataService _dataService; - private readonly IDiscordRestUserAPI _userApi; - - public MessageDeletedResponder( - IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi, - GuildDataService dataService, IDiscordRestUserAPI userApi) { - _auditLogApi = auditLogApi; - _channelApi = channelApi; - _dataService = dataService; - _userApi = userApi; - } - - public async Task RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) { - if (!gatewayEvent.GuildID.IsDefined(out var guildId)) return Result.FromSuccess(); - - var guildConfiguration = await _dataService.GetConfiguration(guildId, ct); - if (guildConfiguration.PrivateFeedbackChannel is 0) return Result.FromSuccess(); - - var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct); - if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult); - if (string.IsNullOrWhiteSpace(message.Content)) return Result.FromSuccess(); - - var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync( - guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct); - if (!auditLogResult.IsDefined(out var auditLogPage)) return Result.FromError(auditLogResult); - - var auditLog = auditLogPage.AuditLogEntries.Single(); - if (!auditLog.Options.IsDefined(out var options)) - return Result.FromError(new ArgumentNullError(nameof(auditLog.Options))); - - var user = message.Author; - if (options.ChannelID == gatewayEvent.ChannelID - && DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) { - var userResult = await _userApi.GetUserAsync(auditLog.UserID!.Value, ct); - if (!userResult.IsDefined(out user)) return Result.FromError(userResult); - } - - Messages.Culture = guildConfiguration.GetCulture(); - - var embed = new EmbedBuilder() - .WithSmallTitle( - string.Format( - Messages.CachedMessageDeleted, - message.Author.GetTag()), message.Author) - .WithDescription( - $"{Mention.Channel(gatewayEvent.ChannelID)}\n{message.Content.InBlockCode()}") - .WithActionFooter(user) - .WithTimestamp(message.Timestamp) - .WithColour(ColorsList.Red) - .Build(); - if (!embed.IsDefined(out var built)) return Result.FromError(embed); - - return (Result)await _channelApi.CreateMessageAsync( - guildConfiguration.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, - allowedMentions: Boyfriend.NoMentions, ct: ct); - } -} - -/// -/// Handles logging the difference between an edited message's old and new content -/// to a guild's if one is set. -/// -public class MessageEditedResponder : IResponder { - private readonly CacheService _cacheService; - private readonly IDiscordRestChannelAPI _channelApi; - private readonly GuildDataService _dataService; - private readonly IDiscordRestUserAPI _userApi; - - public MessageEditedResponder( - CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService dataService, - IDiscordRestUserAPI userApi) { - _cacheService = cacheService; - _channelApi = channelApi; - _dataService = dataService; - _userApi = userApi; - } - - public async Task RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) { - if (!gatewayEvent.GuildID.IsDefined(out var guildId)) - return Result.FromSuccess(); - var guildConfiguration = await _dataService.GetConfiguration(guildId, ct); - if (guildConfiguration.PrivateFeedbackChannel is 0) - return Result.FromSuccess(); - if (!gatewayEvent.Content.IsDefined(out var newContent)) - return Result.FromSuccess(); - if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp)) - return Result.FromSuccess(); // The message wasn't actually edited - - if (!gatewayEvent.ChannelID.IsDefined(out var channelId)) - return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID))); - if (!gatewayEvent.ID.IsDefined(out var messageId)) - return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ID))); - - var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId); - var messageResult = await _cacheService.TryGetValueAsync( - cacheKey, ct); - if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult); - if (message.Content == newContent) return Result.FromSuccess(); - - // Custom event responders are called earlier than responders responsible for message caching - // This means that subsequent edit logs may contain the wrong content - // We can work around this by evicting the message from the cache - await _cacheService.EvictAsync(cacheKey, ct); - // However, since we evicted the message, subsequent edits won't have a cached instance to work with - // Getting the message will put it back in the cache, resolving all issues - // We don't need to await this since the result is not needed - // NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages - // NOTE: Awaiting this might not even solve this if the same responder is called asynchronously - _ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct); - - var currentUserResult = await _userApi.GetCurrentUserAsync(ct); - if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - - var diff = InlineDiffBuilder.Diff(message.Content, newContent); - - Messages.Culture = guildConfiguration.GetCulture(); - - var embed = new EmbedBuilder() - .WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author) - .WithDescription($"https://discord.com/channels/{guildId}/{channelId}/{messageId}\n{diff.AsMarkdown()}") - .WithUserFooter(currentUser) - .WithTimestamp(timestamp.Value) - .WithColour(ColorsList.Yellow) - .Build(); - if (!embed.IsDefined(out var built)) return Result.FromError(embed); - - return (Result)await _channelApi.CreateMessageAsync( - guildConfiguration.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, - allowedMentions: Boyfriend.NoMentions, ct: ct); - } -} - -/// -/// Handles sending a guild's if one is set. -/// If is enabled, roles will be returned. -/// -/// -public class GuildMemberAddResponder : IResponder { - private readonly IDiscordRestChannelAPI _channelApi; - private readonly GuildDataService _dataService; - private readonly IDiscordRestGuildAPI _guildApi; - - public GuildMemberAddResponder( - IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi) { - _channelApi = channelApi; - _dataService = dataService; - _guildApi = guildApi; - } - - public async Task RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) { - if (!gatewayEvent.User.IsDefined(out var user)) - return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.User))); - var data = await _dataService.GetData(gatewayEvent.GuildID, ct); - var cfg = data.Configuration; - if (cfg.PublicFeedbackChannel is 0 || cfg.WelcomeMessage is "off" or "disable" or "disabled") - return Result.FromSuccess(); - if (cfg.ReturnRolesOnRejoin) { - var result = await _guildApi.ModifyGuildMemberAsync( - gatewayEvent.GuildID, user.ID, roles: data.GetMemberData(user.ID).Roles, ct: ct); - if (!result.IsSuccess) return Result.FromError(result.Error); - } - - Messages.Culture = data.Culture; - var welcomeMessage = cfg.WelcomeMessage is "default" or "reset" - ? Messages.DefaultWelcomeMessage - : cfg.WelcomeMessage; - - var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct); - if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult); - - var embed = new EmbedBuilder() - .WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user) - .WithGuildFooter(guild) - .WithTimestamp(gatewayEvent.JoinedAt) - .WithColour(ColorsList.Green) - .Build(); - if (!embed.IsDefined(out var built)) return Result.FromError(embed); - - return (Result)await _channelApi.CreateMessageAsync( - cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, - allowedMentions: Boyfriend.NoMentions, ct: ct); - } -} - -/// -/// Handles sending a notification when a scheduled event has been cancelled -/// in a guild's if one is set. -/// -public class GuildScheduledEventDeleteResponder : IResponder { - private readonly IDiscordRestChannelAPI _channelApi; - private readonly GuildDataService _dataService; - - public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) { - _channelApi = channelApi; - _dataService = dataService; - } - - public async Task RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) { - var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct); - guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value); - - if (guildData.Configuration.EventNotificationChannel is 0) - return Result.FromSuccess(); - - var embed = new EmbedBuilder() - .WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name)) - .WithDescription(":(") - .WithColour(ColorsList.Red) - .WithCurrentTimestamp() - .Build(); - - if (!embed.IsDefined(out var built)) return Result.FromError(embed); - - return (Result)await _channelApi.CreateMessageAsync( - guildData.Configuration.EventNotificationChannel.ToDiscordSnowflake(), embeds: new[] { built }, ct: ct); - } -} - -/// -/// Handles updating when a guild member is updated. -/// -public class GuildMemberUpdateResponder : IResponder { - private readonly GuildDataService _dataService; - - public GuildMemberUpdateResponder(GuildDataService dataService) { - _dataService = dataService; - } - - public async Task RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) { - var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct); - memberData.Roles = gatewayEvent.Roles.ToList(); - return Result.FromSuccess(); - } -} - -/// -/// Handles sending replies to easter egg messages. -/// -public class MessageCreateResponder : IResponder { - private readonly IDiscordRestChannelAPI _channelApi; - - public MessageCreateResponder(IDiscordRestChannelAPI channelApi) { - _channelApi = channelApi; - } - - public Task RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) { - _ = _channelApi.CreateMessageAsync( - gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content switch { - "whoami" => "`nobody`", - "сука !!" => "`root`", - "воооо" => "`removing /...`", - "пон" => - "https://cdn.discordapp.com/attachments/837385840946053181/1087236080950055023/vUORS10xPaY-1.jpg", - "++++" => "#", - _ => default(Optional) - }); - return Task.FromResult(Result.FromSuccess()); - } -} diff --git a/src/Extensions.cs b/src/Extensions.cs index 95500ac..18a3c06 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -170,7 +170,7 @@ public static class Extensions { return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}"; } - public static Snowflake ToDiscordSnowflake(this ulong id) { + public static Snowflake ToSnowflake(this ulong id) { return DiscordSnowflake.New(id); } @@ -190,4 +190,8 @@ public static class Extensions { && context.TryGetChannelID(out channelId) && context.TryGetUserID(out userId); } + + public static bool Empty(this Snowflake snowflake) { + return snowflake.Value is 0; + } } diff --git a/src/InteractionResponders.cs b/src/InteractionResponders.cs index 231df31..6f40d2a 100644 --- a/src/InteractionResponders.cs +++ b/src/InteractionResponders.cs @@ -1,17 +1,16 @@ +using JetBrains.Annotations; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.Commands.Feedback.Messages; using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Interactivity; using Remora.Results; -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable UnusedMember.Global - namespace Boyfriend; /// /// Handles responding to various interactions. /// +[UsedImplicitly] public class InteractionResponders : InteractionGroup { private readonly FeedbackService _feedbackService; @@ -25,6 +24,7 @@ public class InteractionResponders : InteractionGroup { /// The ID of the guild and scheduled event, encoded as "guildId:eventId". /// An ephemeral feedback sending result which may or may not have succeeded. [Button("scheduled-event-details")] + [UsedImplicitly] public async Task OnStatefulButtonClicked(string? state = null) { if (state is null) return Result.FromError(new ArgumentNullError(nameof(state))); diff --git a/locale/Messages.Designer.cs b/src/Messages.Designer.cs similarity index 99% rename from locale/Messages.Designer.cs rename to src/Messages.Designer.cs index de6955b..42a05be 100644 --- a/locale/Messages.Designer.cs +++ b/src/Messages.Designer.cs @@ -8,9 +8,6 @@ //------------------------------------------------------------------------------ namespace Boyfriend { - using System; - - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [System.Diagnostics.DebuggerNonUserCodeAttribute()] [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] diff --git a/src/Responders/GuildLoadedResponder.cs b/src/Responders/GuildLoadedResponder.cs new file mode 100644 index 0000000..081f526 --- /dev/null +++ b/src/Responders/GuildLoadedResponder.cs @@ -0,0 +1,63 @@ +using Boyfriend.Data; +using Boyfriend.Services; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Gateway.Events; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Boyfriend.Responders; + +/// +/// Handles sending a message to a guild that has just initialized if that guild +/// has enabled +/// +[UsedImplicitly] +public class GuildLoadedResponder : IResponder { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + private readonly ILogger _logger; + private readonly IDiscordRestUserAPI _userApi; + + public GuildLoadedResponder( + IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger logger, + IDiscordRestUserAPI userApi) { + _channelApi = channelApi; + _dataService = dataService; + _logger = logger; + _userApi = userApi; + } + + public async Task RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) { + if (!gatewayEvent.Guild.IsT0) return Result.FromSuccess(); // Guild is not IAvailableGuild + + var guild = gatewayEvent.Guild.AsT0; + _logger.LogInformation("Joined guild \"{Name}\"", guild.Name); + + var cfg = await _dataService.GetSettings(guild.ID, ct); + if (!GuildSettings.ReceiveStartupMessages.Get(cfg)) + return Result.FromSuccess(); + if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) + return Result.FromSuccess(); + + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + + Messages.Culture = GuildSettings.Language.Get(cfg); + var i = Random.Shared.Next(1, 4); + + var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser) + .WithTitle($"Beep{i}".Localized()) + .WithDescription(Messages.Ready) + .WithCurrentTimestamp() + .WithColour(ColorsList.Blue) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, ct: ct); + } +} diff --git a/src/Responders/GuildMemberJoinedResponder.cs b/src/Responders/GuildMemberJoinedResponder.cs new file mode 100644 index 0000000..c61e500 --- /dev/null +++ b/src/Responders/GuildMemberJoinedResponder.cs @@ -0,0 +1,65 @@ +using Boyfriend.Data; +using Boyfriend.Services; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Boyfriend.Responders; + +/// +/// Handles sending a guild's if one is set. +/// If is enabled, roles will be returned. +/// +/// +[UsedImplicitly] +public class GuildMemberJoinedResponder : IResponder { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + private readonly IDiscordRestGuildAPI _guildApi; + + public GuildMemberJoinedResponder( + IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi) { + _channelApi = channelApi; + _dataService = dataService; + _guildApi = guildApi; + } + + public async Task RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) { + if (!gatewayEvent.User.IsDefined(out var user)) + return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.User))); + var data = await _dataService.GetData(gatewayEvent.GuildID, ct); + var cfg = data.Settings; + if (GuildSettings.PublicFeedbackChannel.Get(cfg).Empty() + || GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled") + return Result.FromSuccess(); + if (GuildSettings.ReturnRolesOnRejoin.Get(cfg)) { + var result = await _guildApi.ModifyGuildMemberAsync( + gatewayEvent.GuildID, user.ID, + roles: data.GetMemberData(user.ID).Roles.ConvertAll(r => r.ToSnowflake()), ct: ct); + if (!result.IsSuccess) return Result.FromError(result.Error); + } + + Messages.Culture = GuildSettings.Language.Get(cfg); + var welcomeMessage = GuildSettings.WelcomeMessage.Get(cfg) is "default" or "reset" + ? Messages.DefaultWelcomeMessage + : GuildSettings.WelcomeMessage.Get(cfg); + + var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct); + if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult); + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user) + .WithGuildFooter(guild) + .WithTimestamp(gatewayEvent.JoinedAt) + .WithColour(ColorsList.Green) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: new[] { built }, + allowedMentions: Boyfriend.NoMentions, ct: ct); + } +} diff --git a/src/Responders/GuildMemberRolesUpdatedResponder.cs b/src/Responders/GuildMemberRolesUpdatedResponder.cs new file mode 100644 index 0000000..b61ce32 --- /dev/null +++ b/src/Responders/GuildMemberRolesUpdatedResponder.cs @@ -0,0 +1,26 @@ +using Boyfriend.Data; +using Boyfriend.Services; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Boyfriend.Responders; + +/// +/// Handles updating when a guild member is updated. +/// +[UsedImplicitly] +public class GuildMemberUpdateResponder : IResponder { + private readonly GuildDataService _dataService; + + public GuildMemberUpdateResponder(GuildDataService dataService) { + _dataService = dataService; + } + + public async Task RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) { + var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct); + memberData.Roles = gatewayEvent.Roles.ToList().ConvertAll(r => r.Value); + return Result.FromSuccess(); + } +} diff --git a/src/Responders/MessageDeletedResponder.cs b/src/Responders/MessageDeletedResponder.cs new file mode 100644 index 0000000..903db84 --- /dev/null +++ b/src/Responders/MessageDeletedResponder.cs @@ -0,0 +1,78 @@ +using Boyfriend.Data; +using Boyfriend.Services; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Boyfriend.Responders; + +/// +/// Handles logging the contents of a deleted message and the user who deleted the message +/// to a guild's if one is set. +/// +[UsedImplicitly] +public class MessageDeletedResponder : IResponder { + private readonly IDiscordRestAuditLogAPI _auditLogApi; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + private readonly IDiscordRestUserAPI _userApi; + + public MessageDeletedResponder( + IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi, + GuildDataService dataService, IDiscordRestUserAPI userApi) { + _auditLogApi = auditLogApi; + _channelApi = channelApi; + _dataService = dataService; + _userApi = userApi; + } + + public async Task RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) { + if (!gatewayEvent.GuildID.IsDefined(out var guildId)) return Result.FromSuccess(); + + var cfg = await _dataService.GetSettings(guildId, ct); + if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) return Result.FromSuccess(); + + var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct); + if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult); + if (string.IsNullOrWhiteSpace(message.Content)) return Result.FromSuccess(); + + var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync( + guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct); + if (!auditLogResult.IsDefined(out var auditLogPage)) return Result.FromError(auditLogResult); + + var auditLog = auditLogPage.AuditLogEntries.Single(); + if (!auditLog.Options.IsDefined(out var options)) + return Result.FromError(new ArgumentNullError(nameof(auditLog.Options))); + + var user = message.Author; + if (options.ChannelID == gatewayEvent.ChannelID + && DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) { + var userResult = await _userApi.GetUserAsync(auditLog.UserID!.Value, ct); + if (!userResult.IsDefined(out user)) return Result.FromError(userResult); + } + + Messages.Culture = GuildSettings.Language.Get(cfg); + + var embed = new EmbedBuilder() + .WithSmallTitle( + string.Format( + Messages.CachedMessageDeleted, + message.Author.GetTag()), message.Author) + .WithDescription( + $"{Mention.Channel(gatewayEvent.ChannelID)}\n{message.Content.InBlockCode()}") + .WithActionFooter(user) + .WithTimestamp(message.Timestamp) + .WithColour(ColorsList.Red) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, + allowedMentions: Boyfriend.NoMentions, ct: ct); + } +} diff --git a/src/Responders/MessageEditedResponder.cs b/src/Responders/MessageEditedResponder.cs new file mode 100644 index 0000000..0211170 --- /dev/null +++ b/src/Responders/MessageEditedResponder.cs @@ -0,0 +1,89 @@ +using Boyfriend.Data; +using Boyfriend.Services; +using DiffPlex.DiffBuilder; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Caching; +using Remora.Discord.Caching.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Boyfriend.Responders; + +/// +/// Handles logging the difference between an edited message's old and new content +/// to a guild's if one is set. +/// +[UsedImplicitly] +public class MessageEditedResponder : IResponder { + private readonly CacheService _cacheService; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + private readonly IDiscordRestUserAPI _userApi; + + public MessageEditedResponder( + CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService dataService, + IDiscordRestUserAPI userApi) { + _cacheService = cacheService; + _channelApi = channelApi; + _dataService = dataService; + _userApi = userApi; + } + + public async Task RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) { + if (!gatewayEvent.GuildID.IsDefined(out var guildId)) + return Result.FromSuccess(); + var cfg = await _dataService.GetSettings(guildId, ct); + if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) + return Result.FromSuccess(); + if (!gatewayEvent.Content.IsDefined(out var newContent)) + return Result.FromSuccess(); + if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp)) + return Result.FromSuccess(); // The message wasn't actually edited + + if (!gatewayEvent.ChannelID.IsDefined(out var channelId)) + return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID))); + if (!gatewayEvent.ID.IsDefined(out var messageId)) + return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ID))); + + var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId); + var messageResult = await _cacheService.TryGetValueAsync( + cacheKey, ct); + if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult); + if (message.Content == newContent) return Result.FromSuccess(); + + // Custom event responders are called earlier than responders responsible for message caching + // This means that subsequent edit logs may contain the wrong content + // We can work around this by evicting the message from the cache + await _cacheService.EvictAsync(cacheKey, ct); + // However, since we evicted the message, subsequent edits won't have a cached instance to work with + // Getting the message will put it back in the cache, resolving all issues + // We don't need to await this since the result is not needed + // NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages + // NOTE: Awaiting this might not even solve this if the same responder is called asynchronously + _ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct); + + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + + var diff = InlineDiffBuilder.Diff(message.Content, newContent); + + Messages.Culture = GuildSettings.Language.Get(cfg); + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author) + .WithDescription($"https://discord.com/channels/{guildId}/{channelId}/{messageId}\n{diff.AsMarkdown()}") + .WithUserFooter(currentUser) + .WithTimestamp(timestamp.Value) + .WithColour(ColorsList.Yellow) + .Build(); + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, + allowedMentions: Boyfriend.NoMentions, ct: ct); + } +} diff --git a/src/Responders/MessageReceivedResponder.cs b/src/Responders/MessageReceivedResponder.cs new file mode 100644 index 0000000..a45a2f4 --- /dev/null +++ b/src/Responders/MessageReceivedResponder.cs @@ -0,0 +1,34 @@ +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Gateway.Responders; +using Remora.Rest.Core; +using Remora.Results; + +namespace Boyfriend.Responders; + +/// +/// Handles sending replies to easter egg messages. +/// +[UsedImplicitly] +public class MessageCreateResponder : IResponder { + private readonly IDiscordRestChannelAPI _channelApi; + + public MessageCreateResponder(IDiscordRestChannelAPI channelApi) { + _channelApi = channelApi; + } + + public Task RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) { + _ = _channelApi.CreateMessageAsync( + gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content.ToLowerInvariant() switch { + "whoami" => "`nobody`", + "сука !!" => "`root`", + "воооо" => "`removing /...`", + "пон" => "https://cdn.upload.systems/uploads/2LNfUSwM.jpg", + "++++" => "#", + "осу" => "https://github.com/ppy/osu", + _ => default(Optional) + }); + return Task.FromResult(Result.FromSuccess()); + } +} diff --git a/src/Responders/ScheduledEventCancelledResponder.cs b/src/Responders/ScheduledEventCancelledResponder.cs new file mode 100644 index 0000000..86453ef --- /dev/null +++ b/src/Responders/ScheduledEventCancelledResponder.cs @@ -0,0 +1,45 @@ +using Boyfriend.Data; +using Boyfriend.Services; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Boyfriend.Responders; + +/// +/// Handles sending a notification when a scheduled event has been cancelled +/// in a guild's if one is set. +/// +[UsedImplicitly] +public class GuildScheduledEventDeleteResponder : IResponder { + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _dataService; + + public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) { + _channelApi = channelApi; + _dataService = dataService; + } + + public async Task RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) { + var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct); + guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value); + + if (GuildSettings.EventNotificationChannel.Get(guildData.Settings).Empty()) + return Result.FromSuccess(); + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name)) + .WithDescription(":(") + .WithColour(ColorsList.Red) + .WithCurrentTimestamp() + .Build(); + + if (!embed.IsDefined(out var built)) return Result.FromError(embed); + + return (Result)await _channelApi.CreateMessageAsync( + GuildSettings.EventNotificationChannel.Get(guildData.Settings), embeds: new[] { built }, ct: ct); + } +} diff --git a/src/Services/GuildDataService.cs b/src/Services/GuildDataService.cs index be873f1..990e731 100644 --- a/src/Services/GuildDataService.cs +++ b/src/Services/GuildDataService.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text.Json; +using System.Text.Json.Nodes; using Boyfriend.Data; using Microsoft.Extensions.Hosting; using Remora.Discord.API.Abstractions.Rest; @@ -36,8 +37,8 @@ public class GuildDataService : IHostedService { private async Task SaveAsync(CancellationToken ct) { var tasks = new List(); foreach (var data in _datas.Values) { - await using var configStream = File.OpenWrite(data.ConfigurationPath); - tasks.Add(JsonSerializer.SerializeAsync(configStream, data.Configuration, cancellationToken: ct)); + await using var settingsStream = File.OpenWrite(data.SettingsPath); + tasks.Add(JsonSerializer.SerializeAsync(settingsStream, data.Settings, cancellationToken: ct)); await using var eventsStream = File.OpenWrite(data.ScheduledEventsPath); tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct)); @@ -58,17 +59,16 @@ public class GuildDataService : IHostedService { private async Task InitializeData(Snowflake guildId, CancellationToken ct = default) { var idString = $"{guildId}"; var memberDataPath = $"{guildId}/MemberData"; - var configurationPath = $"{guildId}/Configuration.json"; + var settingsPath = $"{guildId}/Settings.json"; var scheduledEventsPath = $"{guildId}/ScheduledEvents.json"; if (!Directory.Exists(idString)) Directory.CreateDirectory(idString); if (!Directory.Exists(memberDataPath)) Directory.CreateDirectory(memberDataPath); - if (!File.Exists(configurationPath)) await File.WriteAllTextAsync(configurationPath, "{}", ct); + if (!File.Exists(settingsPath)) await File.WriteAllTextAsync(settingsPath, "{}", ct); if (!File.Exists(scheduledEventsPath)) await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct); - await using var configurationStream = File.OpenRead(configurationPath); - var configuration - = JsonSerializer.DeserializeAsync( - configurationStream, cancellationToken: ct); + await using var settingsStream = File.OpenRead(settingsPath); + var jsonSettings + = JsonNode.Parse(settingsStream); await using var eventsStream = File.OpenRead(scheduledEventsPath); var events @@ -80,23 +80,23 @@ public class GuildDataService : IHostedService { await using var dataStream = File.OpenRead(dataPath); var data = await JsonSerializer.DeserializeAsync(dataStream, cancellationToken: ct); if (data is null) continue; - var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToDiscordSnowflake(), ct); + var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToSnowflake(), ct); if (memberResult.IsSuccess) - data.Roles = memberResult.Entity.Roles.ToList(); + data.Roles = memberResult.Entity.Roles.ToList().ConvertAll(r => r.Value); memberData.Add(data.Id, data); } var finalData = new GuildData( - await configuration ?? new GuildConfiguration(), configurationPath, + jsonSettings ?? new JsonObject(), settingsPath, await events ?? new Dictionary(), scheduledEventsPath, memberData, memberDataPath); while (!_datas.ContainsKey(guildId)) _datas.TryAdd(guildId, finalData); return finalData; } - public async Task GetConfiguration(Snowflake guildId, CancellationToken ct = default) { - return (await GetData(guildId, ct)).Configuration; + public async Task GetSettings(Snowflake guildId, CancellationToken ct = default) { + return (await GetData(guildId, ct)).Settings; } public async Task GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) { diff --git a/src/Services/GuildUpdateService.cs b/src/Services/GuildUpdateService.cs index 3d55f07..b119d90 100644 --- a/src/Services/GuildUpdateService.cs +++ b/src/Services/GuildUpdateService.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using Boyfriend.Data; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -94,9 +95,9 @@ public class GuildUpdateService : BackgroundService { /// This method does the following: /// /// Automatically unbans users once their ban period has expired. - /// Automatically grants members the guild's if one is set. + /// Automatically grants members the guild's if one is set. /// Sends reminders about an upcoming scheduled event. - /// Automatically starts scheduled events if is enabled. + /// Automatically starts scheduled events if is enabled. /// Sends scheduled event start notifications. /// Sends scheduled event completion notifications. /// Sends reminders to members. @@ -114,15 +115,15 @@ public class GuildUpdateService : BackgroundService { /// The cancellation token for this operation. private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) { var data = await _dataService.GetData(guildId, ct); - Messages.Culture = data.Culture; - var defaultRoleSnowflake = data.Configuration.DefaultRole.ToDiscordSnowflake(); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + var defaultRole = GuildSettings.DefaultRole.Get(data.Settings); foreach (var memberData in data.MemberData.Values) { - var userId = memberData.Id.ToDiscordSnowflake(); + var userId = memberData.Id.ToSnowflake(); - if (defaultRoleSnowflake.Value is not 0 && !memberData.Roles.Contains(defaultRoleSnowflake)) + if (defaultRole.Value is not 0 && !memberData.Roles.Contains(defaultRole.Value)) _ = _guildApi.AddGuildMemberRoleAsync( - guildId, userId, defaultRoleSnowflake, ct: ct); + guildId, userId, defaultRole, ct: ct); if (DateTimeOffset.UtcNow > memberData.BannedUntil) { var unbanResult = await _guildApi.RemoveGuildBanAsync( @@ -139,7 +140,7 @@ public class GuildUpdateService : BackgroundService { for (var i = memberData.Reminders.Count - 1; i >= 0; i--) { var reminder = memberData.Reminders[i]; - if (DateTimeOffset.UtcNow < reminder.RemindAt) continue; + if (DateTimeOffset.UtcNow < reminder.At) continue; var embed = new EmbedBuilder().WithSmallTitle( string.Format(Messages.Reminder, user.GetTag()), user) @@ -151,7 +152,7 @@ public class GuildUpdateService : BackgroundService { if (!embed.IsDefined(out var built)) continue; var messageResult = await _channelApi.CreateMessageAsync( - reminder.Channel, Mention.User(user), embeds: new[] { built }, ct: ct); + reminder.Channel.ToSnowflake(), Mention.User(user), embeds: new[] { built }, ct: ct); if (!messageResult.IsSuccess) _logger.LogWarning( "Error in reminder send.\n{ErrorMessage}", messageResult.Error.Message); @@ -163,7 +164,7 @@ public class GuildUpdateService : BackgroundService { var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); if (!eventsResult.IsDefined(out var events)) return; - if (data.Configuration.EventNotificationChannel is 0) return; + if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) return; foreach (var scheduledEvent in events) { if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) { @@ -172,7 +173,7 @@ public class GuildUpdateService : BackgroundService { var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; if (storedEvent.Status == scheduledEvent.Status) { if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) { - if (data.Configuration.AutoStartEvents + if (GuildSettings.AutoStartEvents.Get(data.Settings) && scheduledEvent.Status is not GuildScheduledEventStatus.Active) { var startResult = await _eventApi.ModifyGuildScheduledEventAsync( guildId, scheduledEvent.ID, @@ -182,10 +183,11 @@ public class GuildUpdateService : BackgroundService { "Error in automatic scheduled event start request.\n{ErrorMessage}", startResult.Error.Message); } - } else if (data.Configuration.EventEarlyNotificationOffset != TimeSpan.Zero + } else if (GuildSettings.EventEarlyNotificationOffset.Get(data.Settings) != TimeSpan.Zero && !storedEvent.EarlyNotificationSent && DateTimeOffset.UtcNow - >= scheduledEvent.ScheduledStartTime - data.Configuration.EventEarlyNotificationOffset) { + >= scheduledEvent.ScheduledStartTime + - GuildSettings.EventEarlyNotificationOffset.Get(data.Settings)) { var earlyResult = await SendScheduledEventUpdatedMessage(scheduledEvent, data, true, ct); if (earlyResult.IsSuccess) storedEvent.EarlyNotificationSent = true; @@ -203,7 +205,7 @@ public class GuildUpdateService : BackgroundService { var result = scheduledEvent.Status switch { GuildScheduledEventStatus.Scheduled => - await SendScheduledEventCreatedMessage(scheduledEvent, data.Configuration, ct), + await SendScheduledEventCreatedMessage(scheduledEvent, data.Settings, ct), GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed => await SendScheduledEventUpdatedMessage(scheduledEvent, data, false, ct), _ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status))) @@ -215,19 +217,17 @@ public class GuildUpdateService : BackgroundService { } /// - /// Handles sending a notification, mentioning the if one is + /// Handles sending a notification, mentioning the if one is /// set, /// when a scheduled event is created - /// in a guild's if one is set. + /// in a guild's if one is set. /// /// The scheduled event that has just been created. - /// The configuration of the guild containing the scheduled event. + /// The settings of the guild containing the scheduled event. /// The cancellation token for this operation. /// A notification sending result which may or may not have succeeded. private async Task SendScheduledEventCreatedMessage( - IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) { - var currentUserResult = await _userApi.GetCurrentUserAsync(ct); - if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) { if (!scheduledEvent.CreatorID.IsDefined(out var creatorId)) return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.CreatorID))); @@ -275,14 +275,13 @@ public class GuildUpdateService : BackgroundService { .WithTitle(scheduledEvent.Name) .WithDescription(embedDescription) .WithEventCover(scheduledEvent.ID, scheduledEvent.Image) - .WithUserFooter(currentUser) .WithCurrentTimestamp() .WithColour(ColorsList.White) .Build(); if (!embed.IsDefined(out var built)) return Result.FromError(embed); - var roleMention = config.EventNotificationRole is not 0 - ? Mention.Role(config.EventNotificationRole.ToDiscordSnowflake()) + var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty() + ? Mention.Role(GuildSettings.EventNotificationRole.Get(settings)) : string.Empty; var button = new ButtonComponent( @@ -294,14 +293,14 @@ public class GuildUpdateService : BackgroundService { ); return (Result)await _channelApi.CreateMessageAsync( - config.EventNotificationChannel.ToDiscordSnowflake(), roleMention, embeds: new[] { built }, + GuildSettings.EventNotificationChannel.Get(settings), roleMention, embeds: new[] { built }, components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct); } /// - /// Handles sending a notification, mentioning the s, + /// Handles sending a notification, mentioning the and event subscribers, /// when a scheduled event is about to start, has started or completed - /// in a guild's if one is set. + /// in a guild's if one is set. /// /// The scheduled event that is about to start, has started or completed. /// The data for the guild containing the scheduled event. @@ -353,7 +352,7 @@ public class GuildUpdateService : BackgroundService { } var contentResult = await _utility.GetEventNotificationMentions( - scheduledEvent, data.Configuration, ct); + scheduledEvent, data.Settings, ct); if (!contentResult.IsDefined(out content)) return Result.FromError(contentResult); @@ -383,7 +382,7 @@ public class GuildUpdateService : BackgroundService { if (!result.IsDefined(out var built)) return Result.FromError(result); return (Result)await _channelApi.CreateMessageAsync( - data.Configuration.EventNotificationChannel.ToDiscordSnowflake(), + GuildSettings.EventNotificationChannel.Get(data.Settings), content ?? default(Optional), embeds: new[] { built }, ct: ct); } } diff --git a/src/Services/UtilityService.cs b/src/Services/UtilityService.cs index b4ff6fb..3b6a2bf 100644 --- a/src/Services/UtilityService.cs +++ b/src/Services/UtilityService.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json.Nodes; using Boyfriend.Data; using Microsoft.Extensions.Hosting; using Remora.Discord.API.Abstractions.Objects; @@ -103,38 +104,32 @@ public class UtilityService : IHostedService { } /// - /// Gets the string mentioning all s related to a scheduled + /// Gets the string mentioning the and event subscribers related to a scheduled /// event. /// - /// - /// If the guild configuration enables , then the - /// will also be mentioned. - /// /// - /// The scheduled event whose subscribers will be mentioned if the guild configuration enables - /// . + /// The scheduled event whose subscribers will be mentioned. /// - /// The configuration of the guild containing the scheduled event + /// The settings of the guild containing the scheduled event /// The cancellation token for this operation. /// A result containing the string which may or may not have succeeded. public async Task> GetEventNotificationMentions( - IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) { + IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) { var builder = new StringBuilder(); - var receivers = config.EventStartedReceivers; - var role = config.EventNotificationRole.ToDiscordSnowflake(); + var role = GuildSettings.EventNotificationRole.Get(settings); var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync( scheduledEvent.GuildID, scheduledEvent.ID, withMember: true, ct: ct); if (!usersResult.IsDefined(out var users)) return Result.FromError(usersResult); - if (receivers.Contains(GuildConfiguration.NotificationReceiver.Role) && role.Value is not 0) + if (role.Value is not 0) builder.Append($"{Mention.Role(role)} "); - if (receivers.Contains(GuildConfiguration.NotificationReceiver.Interested)) - builder = users.Where( - user => { - if (!user.GuildMember.IsDefined(out var member)) return true; - return !member.Roles.Contains(role); - }) - .Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} ")); + + builder = users.Where( + user => { + if (!user.GuildMember.IsDefined(out var member)) return true; + return !member.Roles.Contains(role); + }) + .Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} ")); return builder.ToString(); } }