diff --git a/.github/workflows/resharper.yml b/.github/workflows/resharper.yml index f356672..860dd94 100644 --- a/.github/workflows/resharper.yml +++ b/.github/workflows/resharper.yml @@ -4,12 +4,12 @@ concurrency: cancel-in-progress: true on: + push: + branches: [ "*" ] pull_request: branches: [ "master" ] merge_group: types: [checks_requested] - push: - branches: [ "master" ] jobs: inspect-code: @@ -32,4 +32,5 @@ jobs: with: solutionPath: ./Boyfriend.sln ignoreIssueType: InvertIf, ConvertIfStatementToSwitchStatement + extensions: ReSharperPlugin.CognitiveComplexity solutionWideAnalysis: true diff --git a/src/Commands/AboutCommandGroup.cs b/src/Commands/AboutCommandGroup.cs index 760d96b..50e7de7 100644 --- a/src/Commands/AboutCommandGroup.cs +++ b/src/Commands/AboutCommandGroup.cs @@ -5,6 +5,7 @@ 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; @@ -47,7 +48,7 @@ public class AboutCommandGroup : CommandGroup { [RequireContext(ChannelContext.Guild)] [Description("Shows Boyfriend's developers")] [UsedImplicitly] - public async Task SendAboutBotAsync() { + public async Task ExecuteAboutAsync() { if (!_context.TryGetContextIDs(out var guildId, out _, out _)) return Result.FromError( new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); @@ -59,6 +60,10 @@ public class AboutCommandGroup : CommandGroup { var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); Messages.Culture = GuildSettings.Language.Get(cfg); + return await SendAboutBotAsync(currentUser, CancellationToken); + } + + private async Task SendAboutBotAsync(IUser currentUser, CancellationToken ct = default) { var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers)); foreach (var dev in Developers) builder.AppendLine($"@{dev} — {$"AboutDeveloper@{dev}".Localized()}"); @@ -73,6 +78,6 @@ public class AboutCommandGroup : CommandGroup { .WithImageUrl("https://cdn.upload.systems/uploads/JFAaX5vr.png") .Build(); - return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } } diff --git a/src/Commands/BanCommandGroup.cs b/src/Commands/BanCommandGroup.cs index 14f6d4f..a8672a2 100644 --- a/src/Commands/BanCommandGroup.cs +++ b/src/Commands/BanCommandGroup.cs @@ -66,7 +66,7 @@ public class BanCommandGroup : CommandGroup { [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [Description("Ban user")] [UsedImplicitly] - public async Task ExecuteBan( + public async Task ExecuteBanAsync( [Description("User to ban")] IUser target, [Description("Ban reason")] string reason, [Description("Ban duration")] TimeSpan? duration = null) { @@ -84,26 +84,26 @@ public class BanCommandGroup : CommandGroup { if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult); - return await BanUserAsync(target, reason, duration, guild, channelId.Value, user, currentUser); + var data = await _dataService.GetData(guild.ID, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await BanUserAsync( + target, reason, duration, guild, data, channelId.Value, user, currentUser, CancellationToken); } private async Task BanUserAsync( - IUser target, string reason, TimeSpan? duration, IGuild guild, Snowflake channelId, - IUser user, IUser currentUser) { - var data = await _dataService.GetData(guild.ID, CancellationToken); - var cfg = data.Settings; - Messages.Culture = GuildSettings.Language.Get(cfg); - - var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, CancellationToken); + IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data, Snowflake channelId, + IUser user, IUser currentUser, CancellationToken ct = default) { + var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, ct); if (existingBanResult.IsDefined()) { var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserAlreadyBanned, currentUser) .WithColour(ColorsList.Red).Build(); - return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); } var interactionResult - = await _utility.CheckInteractionsAsync(guild.ID, user.ID, target.ID, "Ban", CancellationToken); + = await _utility.CheckInteractionsAsync(guild.ID, user.ID, target.ID, "Ban", ct); if (!interactionResult.IsSuccess) return Result.FromError(interactionResult); @@ -111,7 +111,7 @@ public class BanCommandGroup : CommandGroup { var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) .WithColour(ColorsList.Red).Build(); - return await _feedbackService.SendContextualEmbedResultAsync(errorEmbed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(errorEmbed, ct); } var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason)); @@ -123,7 +123,7 @@ public class BanCommandGroup : CommandGroup { var title = string.Format(Messages.UserBanned, target.GetTag()); var description = builder.ToString(); - var dmChannelResult = await _userApi.CreateDMAsync(target.ID, CancellationToken); + var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct); if (dmChannelResult.IsDefined(out var dmChannel)) { var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) .WithTitle(Messages.YouWereBanned) @@ -135,12 +135,12 @@ public class BanCommandGroup : CommandGroup { if (!dmEmbed.IsDefined(out var dmBuilt)) return Result.FromError(dmEmbed); - await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: CancellationToken); + await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: ct); } var banResult = await _guildApi.CreateGuildBanAsync( guild.ID, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(), - ct: CancellationToken); + ct: ct); if (!banResult.IsSuccess) return Result.FromError(banResult.Error); var memberData = data.GetMemberData(target.ID); @@ -152,11 +152,12 @@ public class BanCommandGroup : CommandGroup { title, target) .WithColour(ColorsList.Green).Build(); - var logResult = _utility.LogActionAsync(cfg, channelId, user, title, description, target, CancellationToken); + var logResult = _utility.LogActionAsync( + data.Settings, channelId, user, title, description, target, ct); if (!logResult.IsSuccess) return Result.FromError(logResult.Error); - return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } /// @@ -171,7 +172,7 @@ public class BanCommandGroup : CommandGroup { /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user /// was unbanned and vice-versa. /// - /// + /// /// [Command("unban")] [DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)] @@ -196,25 +197,27 @@ public class BanCommandGroup : CommandGroup { if (!userResult.IsDefined(out var user)) return Result.FromError(userResult); - return await UnbanUserAsync(target, reason, guildId.Value, channelId.Value, user, currentUser); + var data = await _dataService.GetData(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await UnbanUserAsync( + target, reason, guildId.Value, data, channelId.Value, user, currentUser, CancellationToken); } private async Task UnbanUserAsync( - IUser target, string reason, Snowflake guildId, Snowflake channelId, IUser user, IUser currentUser) { - var cfg = await _dataService.GetSettings(guildId, CancellationToken); - Messages.Culture = GuildSettings.Language.Get(cfg); - - var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, target.ID, CancellationToken); + IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId, IUser user, + IUser currentUser, CancellationToken ct = default) { + var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, target.ID, ct); if (!existingBanResult.IsDefined()) { var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserNotBanned, currentUser) .WithColour(ColorsList.Red).Build(); - return await _feedbackService.SendContextualEmbedResultAsync(errorEmbed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(errorEmbed, ct); } var unbanResult = await _guildApi.RemoveGuildBanAsync( guildId, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), - ct: CancellationToken); + ct); if (!unbanResult.IsSuccess) return Result.FromError(unbanResult.Error); @@ -224,10 +227,10 @@ public class BanCommandGroup : CommandGroup { var title = string.Format(Messages.UserUnbanned, target.GetTag()); var description = string.Format(Messages.DescriptionActionReason, reason); - var logResult = _utility.LogActionAsync(cfg, channelId, user, title, description, target, CancellationToken); + var logResult = _utility.LogActionAsync(data.Settings, channelId, user, title, description, target, ct); if (!logResult.IsSuccess) return Result.FromError(logResult.Error); - return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } } diff --git a/src/Commands/ClearCommandGroup.cs b/src/Commands/ClearCommandGroup.cs index ce1787c..46ddc24 100644 --- a/src/Commands/ClearCommandGroup.cs +++ b/src/Commands/ClearCommandGroup.cs @@ -76,15 +76,15 @@ public class ClearCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - return await ClearMessagesAsync(amount, guildId.Value, channelId.Value, messages, user, currentUser); + var data = await _dataService.GetData(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await ClearMessagesAsync(amount, data, channelId.Value, messages, user, currentUser, CancellationToken); } private async Task ClearMessagesAsync( - int amount, Snowflake guildId, Snowflake channelId, IReadOnlyList messages, - IUser user, IUser currentUser) { - var cfg = await _dataService.GetSettings(guildId, CancellationToken); - Messages.Culture = GuildSettings.Language.Get(cfg); - + int amount, GuildData data, Snowflake channelId, IReadOnlyList messages, + IUser user, IUser currentUser, CancellationToken ct = default) { var idList = new List(messages.Count); var builder = new StringBuilder().AppendLine(Mention.Channel(channelId)).AppendLine(); for (var i = messages.Count - 1; i >= 1; i--) { // '>= 1' to skip last message ('Boyfriend is thinking...') @@ -98,18 +98,18 @@ public class ClearCommandGroup : CommandGroup { var description = builder.ToString(); var deleteResult = await _channelApi.BulkDeleteMessagesAsync( - channelId, idList, user.GetTag().EncodeHeader(), CancellationToken); + channelId, idList, user.GetTag().EncodeHeader(), ct); if (!deleteResult.IsSuccess) return Result.FromError(deleteResult.Error); var logResult = _utility.LogActionAsync( - cfg, channelId, user, title, description, currentUser, CancellationToken); + data.Settings, channelId, user, title, description, currentUser, ct); if (!logResult.IsSuccess) return Result.FromError(logResult.Error); var embed = new EmbedBuilder().WithSmallTitle(title, currentUser) .WithColour(ColorsList.Green).Build(); - return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } } diff --git a/src/Commands/KickCommandGroup.cs b/src/Commands/KickCommandGroup.cs index 56154bd..9111b7f 100644 --- a/src/Commands/KickCommandGroup.cs +++ b/src/Commands/KickCommandGroup.cs @@ -6,12 +6,12 @@ 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.Rest.Core; using Remora.Results; namespace Boyfriend.Commands; @@ -62,21 +62,25 @@ public class KickCommandGroup : CommandGroup { [RequireBotDiscordPermissions(DiscordPermission.KickMembers)] [Description("Kick member")] [UsedImplicitly] - public async Task KickUserAsync( + public async Task ExecuteKick( [Description("Member to kick")] IUser target, [Description("Kick reason")] string reason) { if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) return Result.FromError( new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); - // The current user's avatar is used when sending error messages var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + var guildResult = await _guildApi.GetGuildAsync(guildId.Value, ct: CancellationToken); + if (!guildResult.IsDefined(out var guild)) + return Result.FromError(guildResult); var data = await _dataService.GetData(guildId.Value, CancellationToken); - var cfg = data.Settings; - Messages.Culture = GuildSettings.Language.Get(cfg); + Messages.Culture = GuildSettings.Language.Get(data.Settings); var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); if (!memberResult.IsSuccess) { @@ -86,79 +90,57 @@ public class KickCommandGroup : CommandGroup { return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); } + return await KickUserAsync(target, reason, guild, channelId.Value, data, user, currentUser, CancellationToken); + } + + private async Task KickUserAsync( + IUser target, string reason, IGuild guild, Snowflake channelId, GuildData data, IUser user, IUser currentUser, + CancellationToken ct = default) { var interactionResult - = await _utility.CheckInteractionsAsync(guildId.Value, userId.Value, target.ID, "Kick", CancellationToken); + = await _utility.CheckInteractionsAsync(guild.ID, user.ID, target.ID, "Kick", ct); if (!interactionResult.IsSuccess) return Result.FromError(interactionResult); - Result responseEmbed; if (interactionResult.Entity is not null) { - responseEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) + var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) .WithColour(ColorsList.Red).Build(); - } else { - var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); - if (!userResult.IsDefined(out var user)) - return Result.FromError(userResult); - var dmChannelResult = await _userApi.CreateDMAsync(target.ID, CancellationToken); - if (dmChannelResult.IsDefined(out var dmChannel)) { - var guildResult = await _guildApi.GetGuildAsync(guildId.Value, ct: CancellationToken); - if (!guildResult.IsDefined(out var guild)) - return Result.FromError(guildResult); - - var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) - .WithTitle(Messages.YouWereKicked) - .WithDescription(string.Format(Messages.DescriptionActionReason, reason)) - .WithActionFooter(user) - .WithCurrentTimestamp() - .WithColour(ColorsList.Red) - .Build(); - - if (!dmEmbed.IsDefined(out var dmBuilt)) - return Result.FromError(dmEmbed); - await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: CancellationToken); - } - - var kickResult = await _guildApi.RemoveGuildMemberAsync( - guildId.Value, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), - ct: CancellationToken); - if (!kickResult.IsSuccess) - return Result.FromError(kickResult.Error); - data.GetMemberData(target.ID).Roles.Clear(); - - responseEmbed = new EmbedBuilder().WithSmallTitle( - string.Format(Messages.UserKicked, target.GetTag()), target) - .WithColour(ColorsList.Green).Build(); - - 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)) - .WithActionFooter(user) - .WithCurrentTimestamp() - .WithColour(ColorsList.Red) - .Build(); - - if (!logEmbed.IsDefined(out var logBuilt)) - return Result.FromError(logEmbed); - - var builtArray = new[] { logBuilt }; - // Not awaiting to reduce response time - if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) - _ = _channelApi.CreateMessageAsync( - GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray, - ct: CancellationToken); - if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg) - && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) - _ = _channelApi.CreateMessageAsync( - GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray, - ct: CancellationToken); - } + return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); } - return await _feedbackService.SendContextualEmbedResultAsync(responseEmbed, CancellationToken); + var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct); + if (dmChannelResult.IsDefined(out var dmChannel)) { + var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) + .WithTitle(Messages.YouWereKicked) + .WithDescription(string.Format(Messages.DescriptionActionReason, reason)) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(ColorsList.Red) + .Build(); + + if (!dmEmbed.IsDefined(out var dmBuilt)) + return Result.FromError(dmEmbed); + await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: ct); + } + + var kickResult = await _guildApi.RemoveGuildMemberAsync( + guild.ID, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), + ct); + if (!kickResult.IsSuccess) + return Result.FromError(kickResult.Error); + data.GetMemberData(target.ID).Roles.Clear(); + + var title = string.Format(Messages.UserKicked, target.GetTag()); + var description = string.Format(Messages.DescriptionActionReason, reason); + var logResult = _utility.LogActionAsync( + data.Settings, channelId, user, title, description, target, ct); + if (!logResult.IsSuccess) + return Result.FromError(logResult.Error); + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserKicked, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } } diff --git a/src/Commands/MuteCommandGroup.cs b/src/Commands/MuteCommandGroup.cs index eff7029..14895d5 100644 --- a/src/Commands/MuteCommandGroup.cs +++ b/src/Commands/MuteCommandGroup.cs @@ -7,13 +7,13 @@ 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.Discord.Extensions.Formatting; +using Remora.Rest.Core; using Remora.Results; namespace Boyfriend.Commands; @@ -23,20 +23,17 @@ namespace Boyfriend.Commands; /// [UsedImplicitly] public class MuteCommandGroup : CommandGroup { - private readonly IDiscordRestChannelAPI _channelApi; - private readonly ICommandContext _context; - private readonly GuildDataService _dataService; - private readonly FeedbackService _feedbackService; - private readonly IDiscordRestGuildAPI _guildApi; - private readonly IDiscordRestUserAPI _userApi; - private readonly UtilityService _utility; + private readonly ICommandContext _context; + private readonly GuildDataService _dataService; + private readonly FeedbackService _feedbackService; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly IDiscordRestUserAPI _userApi; + private readonly UtilityService _utility; public MuteCommandGroup( - ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService, - FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, - UtilityService utility) { + ICommandContext context, GuildDataService dataService, FeedbackService feedbackService, + IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, UtilityService utility) { _context = context; - _channelApi = channelApi; _dataService = dataService; _feedbackService = feedbackService; _guildApi = guildApi; @@ -57,7 +54,7 @@ public class MuteCommandGroup : CommandGroup { /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member /// was muted and vice-versa. /// - /// + /// [Command("mute", "мут")] [DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] [DiscordDefaultDMPermission(false)] @@ -66,7 +63,7 @@ public class MuteCommandGroup : CommandGroup { [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [Description("Mute member")] [UsedImplicitly] - public async Task MuteUserAsync( + public async Task ExecuteMute( [Description("Member to mute")] IUser target, [Description("Mute reason")] string reason, [Description("Mute duration")] TimeSpan duration) { @@ -79,6 +76,13 @@ public class MuteCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + + var data = await _dataService.GetData(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); if (!memberResult.IsSuccess) { var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser) @@ -87,71 +91,49 @@ public class MuteCommandGroup : CommandGroup { return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); } + return await MuteUserAsync( + target, reason, duration, guildId.Value, data, channelId.Value, user, currentUser, CancellationToken); + } + + private async Task MuteUserAsync( + IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data, Snowflake channelId, + IUser user, IUser currentUser, CancellationToken ct = default) { var interactionResult = await _utility.CheckInteractionsAsync( - guildId.Value, userId.Value, target.ID, "Mute", CancellationToken); + guildId, user.ID, target.ID, "Mute", ct); if (!interactionResult.IsSuccess) return Result.FromError(interactionResult); - var data = await _dataService.GetData(guildId.Value, CancellationToken); - var cfg = data.Settings; - Messages.Culture = GuildSettings.Language.Get(cfg); - - Result responseEmbed; if (interactionResult.Entity is not null) { - responseEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) + var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) .WithColour(ColorsList.Red).Build(); - } else { - var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); - if (!userResult.IsDefined(out var user)) - return Result.FromError(userResult); - var until = DateTimeOffset.UtcNow.Add(duration); // >:) - var muteResult = await _guildApi.ModifyGuildMemberAsync( - guildId.Value, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(), - communicationDisabledUntil: until, ct: CancellationToken); - if (!muteResult.IsSuccess) - return Result.FromError(muteResult.Error); - - responseEmbed = new EmbedBuilder().WithSmallTitle( - string.Format(Messages.UserMuted, target.GetTag()), target) - .WithColour(ColorsList.Green).Build(); - - 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( - Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until))); - - var logEmbed = new EmbedBuilder().WithSmallTitle( - string.Format(Messages.UserMuted, target.GetTag()), target) - .WithDescription(builder.ToString()) - .WithActionFooter(user) - .WithCurrentTimestamp() - .WithColour(ColorsList.Red) - .Build(); - - if (!logEmbed.IsDefined(out var logBuilt)) - return Result.FromError(logEmbed); - - var builtArray = new[] { logBuilt }; - // Not awaiting to reduce response time - if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) - _ = _channelApi.CreateMessageAsync( - GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray, - ct: CancellationToken); - if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg) - && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) - _ = _channelApi.CreateMessageAsync( - GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray, - ct: CancellationToken); - } + return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); } - return await _feedbackService.SendContextualEmbedResultAsync(responseEmbed, CancellationToken); + var until = DateTimeOffset.UtcNow.Add(duration); // >:) + var muteResult = await _guildApi.ModifyGuildMemberAsync( + guildId, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(), + communicationDisabledUntil: until, ct: ct); + if (!muteResult.IsSuccess) + return Result.FromError(muteResult.Error); + + var title = string.Format(Messages.UserMuted, target.GetTag()); + var description = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason)) + .Append( + string.Format( + Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until))).ToString(); + + var logResult = _utility.LogActionAsync( + data.Settings, channelId, user, title, description, target, ct); + if (!logResult.IsSuccess) + return Result.FromError(logResult.Error); + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserMuted, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } /// @@ -166,7 +148,7 @@ public class MuteCommandGroup : CommandGroup { /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member /// was unmuted and vice-versa. /// - /// + /// /// [Command("unmute", "размут")] [DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] @@ -176,7 +158,7 @@ public class MuteCommandGroup : CommandGroup { [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [Description("Unmute member")] [UsedImplicitly] - public async Task UnmuteUserAsync( + public async Task ExecuteUnmute( [Description("Member to unmute")] IUser target, [Description("Unmute reason")] string reason) { if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId)) @@ -188,8 +170,13 @@ public class MuteCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); - Messages.Culture = GuildSettings.Language.Get(cfg); + // Needed to get the tag and avatar + var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); + if (!userResult.IsDefined(out var user)) + return Result.FromError(userResult); + + var data = await _dataService.GetData(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken); if (!memberResult.IsSuccess) { @@ -199,56 +186,43 @@ public class MuteCommandGroup : CommandGroup { return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); } + return await UnmuteUserAsync( + target, reason, guildId.Value, data, channelId.Value, user, currentUser, CancellationToken); + } + + private async Task UnmuteUserAsync( + IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId, IUser user, + IUser currentUser, CancellationToken ct = default) { var interactionResult = await _utility.CheckInteractionsAsync( - guildId.Value, userId.Value, target.ID, "Unmute", CancellationToken); + guildId, user.ID, target.ID, "Unmute", ct); if (!interactionResult.IsSuccess) return Result.FromError(interactionResult); - // Needed to get the tag and avatar - var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken); - if (!userResult.IsDefined(out var user)) - return Result.FromError(userResult); + if (interactionResult.Entity is not null) { + var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser) + .WithColour(ColorsList.Red).Build(); + + return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); + } var unmuteResult = await _guildApi.ModifyGuildMemberAsync( - guildId.Value, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), - communicationDisabledUntil: null, ct: CancellationToken); + guildId, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(), + communicationDisabledUntil: null, ct: ct); if (!unmuteResult.IsSuccess) return Result.FromError(unmuteResult.Error); - var responseEmbed = new EmbedBuilder().WithSmallTitle( + var title = string.Format(Messages.UserUnmuted, target.GetTag()); + var description = string.Format(Messages.DescriptionActionReason, reason); + var logResult = _utility.LogActionAsync( + data.Settings, channelId, user, title, description, target, ct); + if (!logResult.IsSuccess) + return Result.FromError(logResult.Error); + + var embed = new EmbedBuilder().WithSmallTitle( string.Format(Messages.UserUnmuted, target.GetTag()), target) .WithColour(ColorsList.Green).Build(); - 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)) - .WithActionFooter(user) - .WithCurrentTimestamp() - .WithColour(ColorsList.Green) - .Build(); - - if (!logEmbed.IsDefined(out var logBuilt)) - return Result.FromError(logEmbed); - - var builtArray = new[] { logBuilt }; - - // Not awaiting to reduce response time - if (GuildSettings.PublicFeedbackChannel.Get(cfg) != channelId.Value) - _ = _channelApi.CreateMessageAsync( - GuildSettings.PublicFeedbackChannel.Get(cfg), embeds: builtArray, - ct: CancellationToken); - if (GuildSettings.PrivateFeedbackChannel.Get(cfg) != GuildSettings.PublicFeedbackChannel.Get(cfg) - && GuildSettings.PrivateFeedbackChannel.Get(cfg) != channelId.Value) - _ = _channelApi.CreateMessageAsync( - GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: builtArray, - ct: CancellationToken); - } - - return await _feedbackService.SendContextualEmbedResultAsync(responseEmbed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } } diff --git a/src/Commands/PingCommandGroup.cs b/src/Commands/PingCommandGroup.cs index 5819336..83b04c9 100644 --- a/src/Commands/PingCommandGroup.cs +++ b/src/Commands/PingCommandGroup.cs @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,7 @@ using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway; +using Remora.Rest.Core; using Remora.Results; namespace Boyfriend.Commands; @@ -49,7 +51,7 @@ public class PingCommandGroup : CommandGroup { [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] [UsedImplicitly] - public async Task SendPingAsync() { + public async Task ExecutePingAsync() { if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _)) return Result.FromError( new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); @@ -61,11 +63,16 @@ public class PingCommandGroup : CommandGroup { var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); Messages.Culture = GuildSettings.Language.Get(cfg); + return await SendLatencyAsync(channelId.Value, currentUser, CancellationToken); + } + + private async Task SendLatencyAsync( + Snowflake channelId, IUser currentUser, CancellationToken ct = default) { var latency = _client.Latency.TotalMilliseconds; if (latency is 0) { // No heartbeat has occurred, estimate latency from local time and "Boyfriend is thinking..." message var lastMessageResult = await _channelApi.GetChannelMessagesAsync( - channelId.Value, limit: 1, ct: CancellationToken); + channelId, limit: 1, ct: ct); if (!lastMessageResult.IsDefined(out var lastMessage)) return Result.FromError(lastMessageResult); latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds; @@ -78,6 +85,6 @@ public class PingCommandGroup : CommandGroup { .WithCurrentTimestamp() .Build(); - return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } } diff --git a/src/Commands/RemindCommandGroup.cs b/src/Commands/RemindCommandGroup.cs index 954e84a..90016f6 100644 --- a/src/Commands/RemindCommandGroup.cs +++ b/src/Commands/RemindCommandGroup.cs @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,7 @@ using Remora.Discord.Commands.Contexts; using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; using Remora.Results; namespace Boyfriend.Commands; @@ -45,7 +47,7 @@ public class RemindCommandGroup : CommandGroup { [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] [UsedImplicitly] - public async Task AddReminderAsync( + public async Task ExecuteReminderAsync( [Description("After what period of time mention the reminder")] TimeSpan @in, [Description("Reminder message")] string message) { @@ -57,12 +59,21 @@ public class RemindCommandGroup : CommandGroup { if (!userResult.IsDefined(out var user)) return Result.FromError(userResult); + var data = await _dataService.GetData(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await AddReminderAsync(@in, message, data, channelId.Value, user, CancellationToken); + } + + private async Task AddReminderAsync( + TimeSpan @in, string message, GuildData data, + Snowflake channelId, IUser user, CancellationToken ct = default) { var remindAt = DateTimeOffset.UtcNow.Add(@in); - (await _dataService.GetMemberData(guildId.Value, userId.Value, CancellationToken)).Reminders.Add( + data.GetMemberData(user.ID).Reminders.Add( new Reminder { At = remindAt, - Channel = channelId.Value.Value, + Channel = channelId.Value, Text = message }); @@ -71,6 +82,6 @@ public class RemindCommandGroup : CommandGroup { .WithColour(ColorsList.Green) .Build(); - return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } } diff --git a/src/Commands/SettingsCommandGroup.cs b/src/Commands/SettingsCommandGroup.cs index d2d28be..741f10e 100644 --- a/src/Commands/SettingsCommandGroup.cs +++ b/src/Commands/SettingsCommandGroup.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Text; +using System.Text.Json.Nodes; using Boyfriend.Data; using Boyfriend.Data.Options; using Boyfriend.Services; @@ -66,7 +67,7 @@ public class SettingsCommandGroup : CommandGroup { [RequireDiscordPermission(DiscordPermission.ManageGuild)] [Description("Shows settings list for this server")] [UsedImplicitly] - public async Task ListSettingsAsync() { + public async Task ExecuteSettingsListAsync() { if (!_context.TryGetContextIDs(out var guildId, out _, out _)) return Result.FromError( new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context")); @@ -78,6 +79,10 @@ public class SettingsCommandGroup : CommandGroup { var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); Messages.Culture = GuildSettings.Language.Get(cfg); + return await SendSettingsListAsync(cfg, currentUser, CancellationToken); + } + + private async Task SendSettingsListAsync(JsonNode cfg, IUser currentUser, CancellationToken ct = default) { var builder = new StringBuilder(); foreach (var option in AllOptions) { @@ -91,7 +96,7 @@ public class SettingsCommandGroup : CommandGroup { .WithColour(ColorsList.Default) .Build(); - return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } /// @@ -107,7 +112,7 @@ public class SettingsCommandGroup : CommandGroup { [RequireDiscordPermission(DiscordPermission.ManageGuild)] [Description("Change settings for this server")] [UsedImplicitly] - public async Task EditSettingsAsync( + public async Task ExecuteSettingsAsync( [Description("The setting whose value you want to change")] string setting, [Description("Setting value")] string value) { @@ -119,33 +124,38 @@ public class SettingsCommandGroup : CommandGroup { if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); - var cfg = await _dataService.GetSettings(guildId.Value, CancellationToken); - Messages.Culture = GuildSettings.Language.Get(cfg); + var data = await _dataService.GetData(guildId.Value, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + return await EditSettingAsync(setting, value, data, currentUser, CancellationToken); + } + + private async Task EditSettingAsync( + string setting, string value, GuildData data, IUser currentUser, CancellationToken ct = default) { var option = AllOptions.Single( o => string.Equals(setting, o.Name, StringComparison.InvariantCultureIgnoreCase)); - var setResult = option.Set(cfg, value); + var setResult = option.Set(data.Settings, value); if (!setResult.IsSuccess) { var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, currentUser) .WithDescription(setResult.Error.Message) .WithColour(ColorsList.Red) .Build(); - return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(failedEmbed, ct); } var builder = new StringBuilder(); builder.Append(Markdown.InlineCode(option.Name)) .Append($" {Messages.SettingIsNow} ") - .Append(option.Display(cfg)); + .Append(option.Display(data.Settings)); var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingSuccessfullyChanged, currentUser) .WithDescription(builder.ToString()) .WithColour(ColorsList.Green) .Build(); - return await _feedbackService.SendContextualEmbedResultAsync(embed, CancellationToken); + return await _feedbackService.SendContextualEmbedResultAsync(embed, ct); } } diff --git a/src/InteractionResponders.cs b/src/InteractionResponders.cs index 6f40d2a..00ef0b2 100644 --- a/src/InteractionResponders.cs +++ b/src/InteractionResponders.cs @@ -31,6 +31,6 @@ public class InteractionResponders : InteractionGroup { var idArray = state.Split(':'); return (Result)await _feedbackService.SendContextualAsync( $"https://discord.com/events/{idArray[0]}/{idArray[1]}", - options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral)); + options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral), ct: CancellationToken); } } diff --git a/src/Services/GuildUpdateService.cs b/src/Services/GuildUpdateService.cs index b119d90..866fcb8 100644 --- a/src/Services/GuildUpdateService.cs +++ b/src/Services/GuildUpdateService.cs @@ -116,106 +116,131 @@ public class GuildUpdateService : BackgroundService { private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) { var data = await _dataService.GetData(guildId, ct); 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.ToSnowflake(); + var userResult = await _userApi.GetUserAsync(memberData.Id.ToSnowflake(), ct); + if (!userResult.IsDefined(out var user)) return; - if (defaultRole.Value is not 0 && !memberData.Roles.Contains(defaultRole.Value)) - _ = _guildApi.AddGuildMemberRoleAsync( - guildId, userId, defaultRole, ct: ct); - - if (DateTimeOffset.UtcNow > memberData.BannedUntil) { - var unbanResult = await _guildApi.RemoveGuildBanAsync( - guildId, userId, Messages.PunishmentExpired.EncodeHeader(), ct); - if (unbanResult.IsSuccess) - memberData.BannedUntil = null; - else - _logger.LogWarning( - "Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message); - } - - var userResult = await _userApi.GetUserAsync(userId, ct); - if (!userResult.IsDefined(out var user)) continue; - - for (var i = memberData.Reminders.Count - 1; i >= 0; i--) { - var reminder = memberData.Reminders[i]; - if (DateTimeOffset.UtcNow < reminder.At) continue; - - var embed = new EmbedBuilder().WithSmallTitle( - string.Format(Messages.Reminder, user.GetTag()), user) - .WithDescription( - string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text))) - .WithColour(ColorsList.Magenta) - .Build(); - - if (!embed.IsDefined(out var built)) continue; - - var messageResult = await _channelApi.CreateMessageAsync( - 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); - - memberData.Reminders.Remove(reminder); - } + await TickMemberAsync(guildId, user, memberData, defaultRole, ct); } var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); - if (!eventsResult.IsDefined(out var events)) return; - - if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) return; + if (!eventsResult.IsSuccess) + _logger.LogWarning("Error retrieving scheduled events.\n{ErrorMessage}", eventsResult.Error.Message); + else if (!GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) + await TickScheduledEventsAsync(guildId, data, eventsResult.Entity, ct); + } + private async Task TickScheduledEventsAsync( + Snowflake guildId, GuildData data, IEnumerable events, CancellationToken ct) { foreach (var scheduledEvent in events) { - if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) { + if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status)); - } else { - var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; - if (storedEvent.Status == scheduledEvent.Status) { - if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) { - if (GuildSettings.AutoStartEvents.Get(data.Settings) - && scheduledEvent.Status is not GuildScheduledEventStatus.Active) { - var startResult = await _eventApi.ModifyGuildScheduledEventAsync( - guildId, scheduledEvent.ID, - status: GuildScheduledEventStatus.Active, ct: ct); - if (!startResult.IsSuccess) - _logger.LogWarning( - "Error in automatic scheduled event start request.\n{ErrorMessage}", - startResult.Error.Message); - } - } else if (GuildSettings.EventEarlyNotificationOffset.Get(data.Settings) != TimeSpan.Zero - && !storedEvent.EarlyNotificationSent - && DateTimeOffset.UtcNow - >= scheduledEvent.ScheduledStartTime - - GuildSettings.EventEarlyNotificationOffset.Get(data.Settings)) { - var earlyResult = await SendScheduledEventUpdatedMessage(scheduledEvent, data, true, ct); - if (earlyResult.IsSuccess) - storedEvent.EarlyNotificationSent = true; - else - _logger.LogWarning( - "Error in scheduled event early notification sender.\n{ErrorMessage}", - earlyResult.Error.Message); - } - continue; - } - - storedEvent.Status = scheduledEvent.Status; + var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; + if (storedEvent.Status == scheduledEvent.Status) { + await TickScheduledEventAsync(guildId, data, scheduledEvent, storedEvent, ct); + continue; } - var result = scheduledEvent.Status switch { + storedEvent.Status = scheduledEvent.Status; + + var statusChangedResponseResult = storedEvent.Status switch { GuildScheduledEventStatus.Scheduled => await SendScheduledEventCreatedMessage(scheduledEvent, data.Settings, ct), GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed => - await SendScheduledEventUpdatedMessage(scheduledEvent, data, false, ct), + await SendScheduledEventUpdatedMessage(scheduledEvent, data, ct), _ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status))) }; - if (!result.IsSuccess) - _logger.LogWarning("Error in guild update.\n{ErrorMessage}", result.Error.Message); + if (!statusChangedResponseResult.IsSuccess) + _logger.LogWarning( + "Error handling scheduled event status update.\n{ErrorMessage}", + statusChangedResponseResult.Error.Message); } } + private async Task TickScheduledEventAsync( + Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData, + CancellationToken ct) { + if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) { + await TryAutoStartEventAsync(guildId, data, scheduledEvent, ct); + return; + } + + if (GuildSettings.EventEarlyNotificationOffset.Get(data.Settings) == TimeSpan.Zero + || eventData.EarlyNotificationSent + || DateTimeOffset.UtcNow + < scheduledEvent.ScheduledStartTime + - GuildSettings.EventEarlyNotificationOffset.Get(data.Settings)) return; + + var earlyResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct); + if (earlyResult.IsSuccess) { + eventData.EarlyNotificationSent = true; + return; + } + + _logger.LogWarning( + "Error in scheduled event early notification sender.\n{ErrorMessage}", + earlyResult.Error.Message); + } + + private async Task TryAutoStartEventAsync( + Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, CancellationToken ct) { + if (GuildSettings.AutoStartEvents.Get(data.Settings) + && scheduledEvent.Status is not GuildScheduledEventStatus.Active) { + var startResult = await _eventApi.ModifyGuildScheduledEventAsync( + guildId, scheduledEvent.ID, + status: GuildScheduledEventStatus.Active, ct: ct); + if (!startResult.IsSuccess) + _logger.LogWarning( + "Error in automatic scheduled event start request.\n{ErrorMessage}", + startResult.Error.Message); + } + } + + private async Task TickMemberAsync( + Snowflake guildId, IUser user, MemberData memberData, Snowflake defaultRole, CancellationToken ct) { + if (defaultRole.Value is not 0 && !memberData.Roles.Contains(defaultRole.Value)) + _ = _guildApi.AddGuildMemberRoleAsync( + guildId, user.ID, defaultRole, ct: ct); + + if (DateTimeOffset.UtcNow > memberData.BannedUntil) { + var unbanResult = await _guildApi.RemoveGuildBanAsync( + guildId, user.ID, Messages.PunishmentExpired.EncodeHeader(), ct); + if (unbanResult.IsSuccess) + memberData.BannedUntil = null; + else + _logger.LogWarning( + "Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message); + } + + for (var i = memberData.Reminders.Count - 1; i >= 0; i--) + await TickReminderAsync(memberData.Reminders[i], user, memberData, ct); + } + + private async Task TickReminderAsync(Reminder reminder, IUser user, MemberData memberData, CancellationToken ct) { + if (DateTimeOffset.UtcNow < reminder.At) return; + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.Reminder, user.GetTag()), user) + .WithDescription( + string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text))) + .WithColour(ColorsList.Magenta) + .Build(); + + if (!embed.IsDefined(out var built)) return; + + var messageResult = await _channelApi.CreateMessageAsync( + 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); + + memberData.Reminders.Remove(reminder); + } + /// /// Handles sending a notification, mentioning the if one is /// set, @@ -228,47 +253,23 @@ public class GuildUpdateService : BackgroundService { /// A notification sending result which may or may not have succeeded. private async Task SendScheduledEventCreatedMessage( IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) { + if (!scheduledEvent.Creator.IsDefined(out var creator)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.Creator))); - if (!scheduledEvent.CreatorID.IsDefined(out var creatorId)) - return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.CreatorID))); - var creatorResult = await _userApi.GetUserAsync(creatorId.Value, ct); - if (!creatorResult.IsDefined(out var creator)) return Result.FromError(creatorResult); - - string embedDescription; + Result embedDescriptionResult; var eventDescription = scheduledEvent.Description is { HasValue: true, Value: not null } ? scheduledEvent.Description.Value : string.Empty; - switch (scheduledEvent.EntityType) { - case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice: - if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) - return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID))); + embedDescriptionResult = scheduledEvent.EntityType switch { + GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice => + GetLocalEventCreatedEmbedDescription(scheduledEvent, eventDescription), + GuildScheduledEventEntityType.External => GetExternalScheduledEventCreatedEmbedDescription( + scheduledEvent, eventDescription), + _ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))) + }; - embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote( - string.Format( - Messages.DescriptionLocalEventCreated, - Markdown.Timestamp(scheduledEvent.ScheduledStartTime), - Mention.Channel(channelId) - ))}"; - break; - case GuildScheduledEventEntityType.External: - if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) - return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata))); - if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) - return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime))); - if (!metadata.Location.IsDefined(out var location)) - return Result.FromError(new ArgumentNullError(nameof(metadata.Location))); - - embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote( - string.Format( - Messages.DescriptionExternalEventCreated, - Markdown.Timestamp(scheduledEvent.ScheduledStartTime), - Markdown.Timestamp(endTime), - Markdown.InlineCode(location) - ))}"; - break; - default: - return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))); - } + if (!embedDescriptionResult.IsDefined(out var embedDescription)) + return Result.FromError(embedDescriptionResult); var embed = new EmbedBuilder() .WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator) @@ -297,92 +298,153 @@ public class GuildUpdateService : BackgroundService { components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct); } + private static Result GetExternalScheduledEventCreatedEmbedDescription( + IGuildScheduledEvent scheduledEvent, string eventDescription) { + Result embedDescription; + if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata))); + if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime))); + if (!metadata.Location.IsDefined(out var location)) + return Result.FromError(new ArgumentNullError(nameof(metadata.Location))); + + embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote( + string.Format( + Messages.DescriptionExternalEventCreated, + Markdown.Timestamp(scheduledEvent.ScheduledStartTime), + Markdown.Timestamp(endTime), + Markdown.InlineCode(location) + ))}"; + return embedDescription; + } + + private static Result GetLocalEventCreatedEmbedDescription( + IGuildScheduledEvent scheduledEvent, string eventDescription) { + if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID))); + + return $"{eventDescription}\n\n{Markdown.BlockQuote( + string.Format( + Messages.DescriptionLocalEventCreated, + Markdown.Timestamp(scheduledEvent.ScheduledStartTime), + Mention.Channel(channelId) + ))}"; + } + /// /// Handles sending a notification, mentioning the and event subscribers, - /// when a scheduled event is about to start, has started or completed + /// when a scheduled event has started or completed /// 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. - /// Controls whether or not a reminder for the scheduled event should be sent instead of the event started/completed notification /// The cancellation token for this operation /// A reminder/notification sending result which may or may not have succeeded. private async Task SendScheduledEventUpdatedMessage( - IGuildScheduledEvent scheduledEvent, GuildData data, bool early, CancellationToken ct = default) { - var currentUserResult = await _userApi.GetCurrentUserAsync(ct); - if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default) { + if (scheduledEvent.Status == GuildScheduledEventStatus.Active) { + data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow; - var embed = new EmbedBuilder(); - string? content = null; - if (early) - embed.WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser) - .WithColour(ColorsList.Default); - else - switch (scheduledEvent.Status) { - case GuildScheduledEventStatus.Active: - data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow; + var embedDescriptionResult = scheduledEvent.EntityType switch { + GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice => + GetLocalEventStartedEmbedDescription(scheduledEvent), + GuildScheduledEventEntityType.External => GetExternalEventStartedEmbedDescription(scheduledEvent), + _ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))) + }; - string embedDescription; - switch (scheduledEvent.EntityType) { - case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice: - if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) - return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID))); + var contentResult = await _utility.GetEventNotificationMentions( + scheduledEvent, data.Settings, ct); + if (!contentResult.IsDefined(out var content)) + return Result.FromError(contentResult); + if (!embedDescriptionResult.IsDefined(out var embedDescription)) + return Result.FromError(embedDescriptionResult); - embedDescription = string.Format( - Messages.DescriptionLocalEventStarted, - Mention.Channel(channelId) - ); - break; - case GuildScheduledEventEntityType.External: - if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) - return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata))); - if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) - return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime))); - if (!metadata.Location.IsDefined(out var location)) - return Result.FromError(new ArgumentNullError(nameof(metadata.Location))); + var startedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name)) + .WithDescription(embedDescription) + .WithColour(ColorsList.Green) + .WithCurrentTimestamp() + .Build(); - embedDescription = string.Format( - Messages.DescriptionExternalEventStarted, - Markdown.InlineCode(location), - Markdown.Timestamp(endTime) - ); - break; - default: - return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))); - } + if (!startedEmbed.IsDefined(out var startedBuilt)) return Result.FromError(startedEmbed); - var contentResult = await _utility.GetEventNotificationMentions( - scheduledEvent, data.Settings, ct); - if (!contentResult.IsDefined(out content)) - return Result.FromError(contentResult); + return (Result)await _channelApi.CreateMessageAsync( + GuildSettings.EventNotificationChannel.Get(data.Settings), + content, embeds: new[] { startedBuilt }, ct: ct); + } - embed.WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name)) - .WithDescription(embedDescription) - .WithColour(ColorsList.Green); - break; - case GuildScheduledEventStatus.Completed: - embed.WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name)) - .WithDescription( - string.Format( - Messages.EventDuration, - DateTimeOffset.UtcNow.Subtract( - data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime - ?? scheduledEvent.ScheduledStartTime).ToString())) - .WithColour(ColorsList.Black); + if (scheduledEvent.Status != GuildScheduledEventStatus.Completed) + return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status))); + data.ScheduledEvents.Remove(scheduledEvent.ID.Value); - data.ScheduledEvents.Remove(scheduledEvent.ID.Value); - break; - case GuildScheduledEventStatus.Canceled: - case GuildScheduledEventStatus.Scheduled: - default: return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status))); - } + var completedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name)) + .WithDescription( + string.Format( + Messages.EventDuration, + DateTimeOffset.UtcNow.Subtract( + data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime + ?? scheduledEvent.ScheduledStartTime).ToString())) + .WithColour(ColorsList.Black) + .WithCurrentTimestamp() + .Build(); - var result = embed.WithCurrentTimestamp().Build(); - - if (!result.IsDefined(out var built)) return Result.FromError(result); + if (!completedEmbed.IsDefined(out var completedBuilt)) + return Result.FromError(completedEmbed); return (Result)await _channelApi.CreateMessageAsync( GuildSettings.EventNotificationChannel.Get(data.Settings), - content ?? default(Optional), embeds: new[] { built }, ct: ct); + embeds: new[] { completedBuilt }, ct: ct); + } + + private static Result GetLocalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent) { + Result embedDescription; + if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID))); + + embedDescription = string.Format( + Messages.DescriptionLocalEventStarted, + Mention.Channel(channelId) + ); + return embedDescription; + } + + private static Result GetExternalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent) { + Result embedDescription; + if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata))); + if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) + return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime))); + if (!metadata.Location.IsDefined(out var location)) + return Result.FromError(new ArgumentNullError(nameof(metadata.Location))); + + embedDescription = string.Format( + Messages.DescriptionExternalEventStarted, + Markdown.InlineCode(location), + Markdown.Timestamp(endTime) + ); + return embedDescription; + } + + private async Task SendEarlyEventNotificationAsync( + IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct) { + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult); + + var contentResult = await _utility.GetEventNotificationMentions( + scheduledEvent, data.Settings, ct); + if (!contentResult.IsDefined(out var content)) + return Result.FromError(contentResult); + + var earlyResult = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser) + .WithColour(ColorsList.Default) + .WithCurrentTimestamp() + .Build(); + + if (!earlyResult.IsDefined(out var earlyBuilt)) return Result.FromError(earlyResult); + + return (Result)await _channelApi.CreateMessageAsync( + GuildSettings.EventNotificationChannel.Get(data.Settings), + content, + embeds: new[] { earlyBuilt }, ct: ct); } }