diff --git a/.editorconfig b/.editorconfig index ff9c068..adbec5a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -42,7 +42,7 @@ csharp_space_between_square_brackets = false csharp_style_expression_bodied_accessors = false:warning csharp_style_expression_bodied_constructors = false:warning csharp_style_expression_bodied_methods = false:warning -csharp_style_expression_bodied_properties = false:warning +csharp_style_expression_bodied_properties = true:warning csharp_style_namespace_declarations = file_scoped:warning csharp_style_prefer_utf8_string_literals = true:warning csharp_style_var_elsewhere = true:warning diff --git a/Octobot.csproj b/Octobot.csproj index ab76400..bdfb46a 100644 --- a/Octobot.csproj +++ b/Octobot.csproj @@ -17,10 +17,12 @@ en A general-purpose Discord bot for moderation written in C# docs/octobot.ico + false + diff --git a/locale/Messages.resx b/locale/Messages.resx index 66355d0..4a20337 100644 --- a/locale/Messages.resx +++ b/locale/Messages.resx @@ -117,13 +117,13 @@ {0}, welcome to {1} - + Veemo! - + Woomy! - + Ngyes! @@ -204,6 +204,24 @@ You need to specify a user! + + You cannot ban users from this guild! + + + You cannot manage messages in this guild! + + + You cannot kick members from this guild! + + + You cannot mute members in this guild! + + + You cannot unmute members in this guild! + + + You cannot manage this guild! + I cannot ban users from this guild! @@ -546,6 +564,12 @@ Report an issue + + See you soon, {0}! + + + Leave message + Time specified incorrectly! @@ -618,4 +642,19 @@ Example of a valid input: `1h30m` + + Version: {0} + + + Welcome messages channel + + + Can't report an issue in the development version + + + Open Octobot's Wiki + + + Moderator role + diff --git a/locale/Messages.ru.resx b/locale/Messages.ru.resx index 6e0cddb..f6c0c0a 100644 --- a/locale/Messages.ru.resx +++ b/locale/Messages.ru.resx @@ -117,13 +117,13 @@ {0}, добро пожаловать на сервер {1} - + Виимо! - + Вууми! - + Нгьес! @@ -201,6 +201,24 @@ Надо указать пользователя! + + Ты не можешь банить пользователей на этом сервере! + + + Ты не можешь управлять сообщениями этого сервера! + + + Ты не можешь выгонять участников с этого сервера! + + + Ты не можешь глушить участников этого сервера! + + + Ты не можешь разглушать участников этого сервера! + + + Ты не можешь настраивать этот сервер! + Я не могу банить пользователей на этом сервере! @@ -546,6 +564,12 @@ Сообщить о проблеме + + До скорой встречи, {0}! + + + Сообщение о выходе + Неправильно указано время! @@ -618,4 +642,19 @@ Пример правильного ввода: `1ч30м` + + Версия: {0} + + + Канал для приветствий + + + Нельзя сообщить о проблеме в версии под разработкой + + + Открыть Octobot's Wiki + + + Роль модератора + diff --git a/locale/Messages.tt-ru.resx b/locale/Messages.tt-ru.resx index 2082cce..b987042 100644 --- a/locale/Messages.tt-ru.resx +++ b/locale/Messages.tt-ru.resx @@ -117,13 +117,13 @@ {0}, добро пожаловать на сервер {1} - + вииимо! - + вуууми! - + нгьес! @@ -204,6 +204,24 @@ укажи самого шизика + + бан + + + тебе нельзя иметь власть над сообщениями шизоидов + + + кик шизиков нельзя + + + тебе нельзя мутить шизоидов + + + тебе нельзя раззамучивать шизоидов + + + тебе нельзя редактировать дурку + я не могу ваще никого банить чел. @@ -546,6 +564,12 @@ зарепортить баг + + ну, мы потеряли {0} + + + до свидания (типо настройка) + ты там правильно напиши таймспан @@ -618,4 +642,19 @@ правильно пишут так: `1h30m` + + {0} + + + канал куда говорить здравствуйте + + + вот иди сам и почини что сломал + + + вики Octobot (жмак) + + + звание админа + diff --git a/src/Attributes/StaticCallersOnlyAttribute.cs b/src/Attributes/StaticCallersOnlyAttribute.cs new file mode 100644 index 0000000..e8787bf --- /dev/null +++ b/src/Attributes/StaticCallersOnlyAttribute.cs @@ -0,0 +1,8 @@ +namespace Octobot.Attributes; + +/// +/// Any property marked with should only be accessed by static methods. +/// Such properties may be used to provide dependencies where it is not possible to acquire them through normal means. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class StaticCallersOnlyAttribute : Attribute; diff --git a/src/BuildInfo.cs b/src/BuildInfo.cs new file mode 100644 index 0000000..2eb6059 --- /dev/null +++ b/src/BuildInfo.cs @@ -0,0 +1,18 @@ +namespace Octobot; + +public static class BuildInfo +{ + public const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot"; + + public const string IssuesUrl = $"{RepositoryUrl}/issues"; + + public const string WikiUrl = $"{RepositoryUrl}/wiki"; + + private const string Commit = ThisAssembly.Git.Commit; + + private const string Branch = ThisAssembly.Git.Branch; + + public static bool IsDirty => ThisAssembly.Git.IsDirty; + + public static string Version => IsDirty ? $"{Branch}-{Commit}-dirty" : $"{Branch}-{Commit}"; +} diff --git a/src/Commands/AboutCommandGroup.cs b/src/Commands/AboutCommandGroup.cs index e978ec9..027e7f8 100644 --- a/src/Commands/AboutCommandGroup.cs +++ b/src/Commands/AboutCommandGroup.cs @@ -73,7 +73,7 @@ public class AboutCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var cfg = await _guildData.GetSettings(guildId, CancellationToken); @@ -101,26 +101,37 @@ public class AboutCommandGroup : CommandGroup .WithDescription(builder.ToString()) .WithColour(ColorsList.Cyan) .WithImageUrl("https://i.ibb.co/fS6wZhh/octobot-banner.png") + .WithFooter(string.Format(Messages.Version, BuildInfo.Version)) .Build(); var repositoryButton = new ButtonComponent( ButtonComponentStyle.Link, Messages.ButtonOpenRepository, new PartialEmoji(Name: "🌐"), - URL: Octobot.RepositoryUrl + URL: BuildInfo.RepositoryUrl + ); + + var wikiButton = new ButtonComponent( + ButtonComponentStyle.Link, + Messages.ButtonOpenWiki, + new PartialEmoji(Name: "📖"), + URL: BuildInfo.WikiUrl ); var issuesButton = new ButtonComponent( ButtonComponentStyle.Link, - Messages.ButtonReportIssue, + BuildInfo.IsDirty + ? Messages.ButtonDirty + : Messages.ButtonReportIssue, new PartialEmoji(Name: "⚠️"), - URL: Octobot.IssuesUrl + URL: BuildInfo.IssuesUrl, + IsDisabled: BuildInfo.IsDirty ); return await _feedback.SendContextualEmbedResultAsync(embed, new FeedbackMessageOptions(MessageComponents: new[] { - new ActionRowComponent(new[] { repositoryButton, issuesButton }) + new ActionRowComponent(new[] { repositoryButton, wikiButton, issuesButton }) }), ct); } } diff --git a/src/Commands/BanCommandGroup.cs b/src/Commands/BanCommandGroup.cs index c350729..02a377a 100644 --- a/src/Commands/BanCommandGroup.cs +++ b/src/Commands/BanCommandGroup.cs @@ -28,6 +28,7 @@ namespace Octobot.Commands; [UsedImplicitly] public class BanCommandGroup : CommandGroup { + private readonly AccessControlService _access; private readonly IDiscordRestChannelAPI _channelApi; private readonly ICommandContext _context; private readonly IFeedbackService _feedback; @@ -36,16 +37,16 @@ public class BanCommandGroup : CommandGroup private readonly IDiscordRestUserAPI _userApi; private readonly Utility _utility; - public BanCommandGroup( - ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService guildData, - IFeedbackService feedback, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, - Utility utility) + public BanCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context, + IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData, + IDiscordRestUserAPI userApi, Utility utility) { - _context = context; + _access = access; _channelApi = channelApi; - _guildData = guildData; + _context = context; _feedback = feedback; _guildApi = guildApi; + _guildData = guildData; _userApi = userApi; _utility = utility; } @@ -65,10 +66,10 @@ public class BanCommandGroup : CommandGroup /// /// [Command("ban", "бан")] - [DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] - [RequireDiscordPermission(DiscordPermission.BanMembers)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [Description("Ban user")] [UsedImplicitly] @@ -88,19 +89,19 @@ public class BanCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); if (!guildResult.IsDefined(out var guild)) { - return Result.FromError(guildResult); + return ResultExtensions.FromError(guildResult); } var data = await _guildData.GetData(guild.ID, CancellationToken); @@ -128,7 +129,8 @@ public class BanCommandGroup : CommandGroup } private async Task BanUserAsync( - IUser executor, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data, Snowflake channelId, + IUser executor, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data, + Snowflake channelId, IUser bot, CancellationToken ct = default) { var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, ct); @@ -141,10 +143,10 @@ public class BanCommandGroup : CommandGroup } var interactionResult - = await _utility.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Ban", ct); + = await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Ban", ct); if (!interactionResult.IsSuccess) { - return Result.FromError(interactionResult); + return ResultExtensions.FromError(interactionResult); } if (interactionResult.Entity is not null) @@ -155,7 +157,8 @@ public class BanCommandGroup : CommandGroup return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct); } - var builder = new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)); + var builder = + new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)); if (duration is not null) { builder.AppendBulletPoint( @@ -181,17 +184,19 @@ public class BanCommandGroup : CommandGroup await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct); } + var memberData = data.GetOrCreateMemberData(target.ID); + memberData.BannedUntil + = duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue; + var banResult = await _guildApi.CreateGuildBanAsync( guild.ID, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), ct: ct); if (!banResult.IsSuccess) { - return Result.FromError(banResult.Error); + memberData.BannedUntil = null; + return ResultExtensions.FromError(banResult); } - var memberData = data.GetOrCreateMemberData(target.ID); - memberData.BannedUntil - = duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue; memberData.Roles.Clear(); var embed = new EmbedBuilder().WithSmallTitle( @@ -219,10 +224,10 @@ public class BanCommandGroup : CommandGroup /// /// [Command("unban")] - [DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] - [RequireDiscordPermission(DiscordPermission.BanMembers)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] [Description("Unban user")] [UsedImplicitly] @@ -240,14 +245,14 @@ public class BanCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } // Needed to get the tag and avatar var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -274,7 +279,7 @@ public class BanCommandGroup : CommandGroup ct); if (!unbanResult.IsSuccess) { - return Result.FromError(unbanResult.Error); + return ResultExtensions.FromError(unbanResult); } data.GetOrCreateMemberData(target.ID).BannedUntil = null; @@ -284,7 +289,8 @@ public class BanCommandGroup : CommandGroup .WithColour(ColorsList.Green).Build(); var title = string.Format(Messages.UserUnbanned, target.GetTag()); - var description = new StringBuilder().AppendBulletPoint(string.Format(Messages.DescriptionActionReason, reason)); + var description = + new StringBuilder().AppendBulletPoint(string.Format(Messages.DescriptionActionReason, reason)); _utility.LogAction( data.Settings, channelId, executor, title, description.ToString(), target, ColorsList.Green, ct: ct); diff --git a/src/Commands/ClearCommandGroup.cs b/src/Commands/ClearCommandGroup.cs index 395810f..84b69db 100644 --- a/src/Commands/ClearCommandGroup.cs +++ b/src/Commands/ClearCommandGroup.cs @@ -75,20 +75,20 @@ public class ClearCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var messagesResult = await _channelApi.GetChannelMessagesAsync( channelId, limit: amount + 1, ct: CancellationToken); if (!messagesResult.IsDefined(out var messages)) { - return Result.FromError(messagesResult); + return ResultExtensions.FromError(messagesResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -102,7 +102,9 @@ public class ClearCommandGroup : CommandGroup CancellationToken ct = default) { var idList = new List(messages.Count); - var builder = new StringBuilder().AppendLine(Mention.Channel(channelId)).AppendLine(); + + var logEntries = new List { new() }; + var currentLogEntry = 0; for (var i = messages.Count - 1; i >= 1; i--) // '>= 1' to skip last message ('Octobot is thinking...') { var message = messages[i]; @@ -112,8 +114,17 @@ public class ClearCommandGroup : CommandGroup } idList.Add(message.ID); - builder.AppendLine(string.Format(Messages.MessageFrom, Mention.User(message.Author))); - builder.Append(message.Content.InBlockCode()); + + var entry = logEntries[currentLogEntry]; + var str = $"{string.Format(Messages.MessageFrom, Mention.User(message.Author))}\n{message.Content.InBlockCode()}"; + if (entry.Builder.Length + str.Length > EmbedConstants.MaxDescriptionLength) + { + logEntries.Add(entry = new ClearedMessageEntry()); + currentLogEntry++; + } + + entry.Builder.Append(str); + entry.DeletedCount++; } if (idList.Count == 0) @@ -127,21 +138,32 @@ public class ClearCommandGroup : CommandGroup var title = author is not null ? string.Format(Messages.MessagesClearedFiltered, idList.Count.ToString(), author.GetTag()) : string.Format(Messages.MessagesCleared, idList.Count.ToString()); - var description = builder.ToString(); var deleteResult = await _channelApi.BulkDeleteMessagesAsync( channelId, idList, executor.GetTag().EncodeHeader(), ct); if (!deleteResult.IsSuccess) { - return Result.FromError(deleteResult.Error); + return ResultExtensions.FromError(deleteResult); } - _utility.LogAction( - data.Settings, channelId, executor, title, description, bot, ColorsList.Red, false, ct); + foreach (var log in logEntries) + { + _utility.LogAction( + data.Settings, channelId, executor, author is not null + ? string.Format(Messages.MessagesClearedFiltered, log.DeletedCount.ToString(), author.GetTag()) + : string.Format(Messages.MessagesCleared, log.DeletedCount.ToString()), + log.Builder.ToString(), bot, ColorsList.Red, false, ct); + } var embed = new EmbedBuilder().WithSmallTitle(title, bot) .WithColour(ColorsList.Green).Build(); return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); } + + private sealed class ClearedMessageEntry + { + public StringBuilder Builder { get; } = new(); + public int DeletedCount { get; set; } + } } diff --git a/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs b/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs index 87cfc84..5fa2ea8 100644 --- a/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs +++ b/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs @@ -20,8 +20,8 @@ namespace Octobot.Commands.Events; [UsedImplicitly] public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent { - private readonly ILogger _logger; private readonly IFeedbackService _feedback; + private readonly ILogger _logger; private readonly IDiscordRestUserAPI _userApi; public ErrorLoggingPostExecutionEvent(ILogger logger, IFeedbackService feedback, @@ -53,13 +53,13 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent if (result.IsSuccess) { - return Result.FromSuccess(); + return Result.Success; } var botResult = await _userApi.GetCurrentUserAsync(ct); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var embed = new EmbedBuilder().WithSmallTitle(Messages.CommandExecutionFailed, bot) @@ -70,15 +70,19 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent var issuesButton = new ButtonComponent( ButtonComponentStyle.Link, - Messages.ButtonReportIssue, + BuildInfo.IsDirty + ? Messages.ButtonDirty + : Messages.ButtonReportIssue, new PartialEmoji(Name: "⚠️"), - URL: Octobot.IssuesUrl + URL: BuildInfo.IssuesUrl, + IsDisabled: BuildInfo.IsDirty ); - return await _feedback.SendContextualEmbedResultAsync(embed, + return ResultExtensions.FromError(await _feedback.SendContextualEmbedResultAsync(embed, new FeedbackMessageOptions(MessageComponents: new[] { new ActionRowComponent(new[] { issuesButton }) - }), ct); + }), ct) + ); } } diff --git a/src/Commands/Events/LoggingPreparationErrorEvent.cs b/src/Commands/Events/LoggingPreparationErrorEvent.cs index be48e74..87b4090 100644 --- a/src/Commands/Events/LoggingPreparationErrorEvent.cs +++ b/src/Commands/Events/LoggingPreparationErrorEvent.cs @@ -33,6 +33,6 @@ public class LoggingPreparationErrorEvent : IPreparationErrorEvent { _logger.LogResult(preparationResult, "Error in slash command preparation."); - return Task.FromResult(Result.FromSuccess()); + return Task.FromResult(Result.Success); } } diff --git a/src/Commands/KickCommandGroup.cs b/src/Commands/KickCommandGroup.cs index 0faa1d3..87b915a 100644 --- a/src/Commands/KickCommandGroup.cs +++ b/src/Commands/KickCommandGroup.cs @@ -24,6 +24,7 @@ namespace Octobot.Commands; [UsedImplicitly] public class KickCommandGroup : CommandGroup { + private readonly AccessControlService _access; private readonly IDiscordRestChannelAPI _channelApi; private readonly ICommandContext _context; private readonly IFeedbackService _feedback; @@ -32,16 +33,16 @@ public class KickCommandGroup : CommandGroup private readonly IDiscordRestUserAPI _userApi; private readonly Utility _utility; - public KickCommandGroup( - ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService guildData, - IFeedbackService feedback, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, - Utility utility) + public KickCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context, + IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData, + IDiscordRestUserAPI userApi, Utility utility) { - _context = context; + _access = access; _channelApi = channelApi; - _guildData = guildData; + _context = context; _feedback = feedback; _guildApi = guildApi; + _guildData = guildData; _userApi = userApi; _utility = utility; } @@ -59,10 +60,10 @@ public class KickCommandGroup : CommandGroup /// was kicked and vice-versa. /// [Command("kick", "кик")] - [DiscordDefaultMemberPermissions(DiscordPermission.KickMembers)] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] - [RequireDiscordPermission(DiscordPermission.KickMembers)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] [RequireBotDiscordPermissions(DiscordPermission.KickMembers)] [Description("Kick member")] [UsedImplicitly] @@ -80,19 +81,19 @@ public class KickCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); if (!guildResult.IsDefined(out var guild)) { - return Result.FromError(guildResult); + return ResultExtensions.FromError(guildResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -115,10 +116,10 @@ public class KickCommandGroup : CommandGroup CancellationToken ct = default) { var interactionResult - = await _utility.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Kick", ct); + = await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Kick", ct); if (!interactionResult.IsSuccess) { - return Result.FromError(interactionResult); + return ResultExtensions.FromError(interactionResult); } if (interactionResult.Entity is not null) @@ -134,7 +135,8 @@ public class KickCommandGroup : CommandGroup { var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) .WithTitle(Messages.YouWereKicked) - .WithDescription(MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason))) + .WithDescription( + MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason))) .WithActionFooter(executor) .WithCurrentTimestamp() .WithColour(ColorsList.Red) @@ -143,17 +145,19 @@ public class KickCommandGroup : CommandGroup await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct); } + var memberData = data.GetOrCreateMemberData(target.ID); + memberData.Kicked = true; + var kickResult = await _guildApi.RemoveGuildMemberAsync( guild.ID, target.ID, $"({executor.GetTag()}) {reason}".EncodeHeader(), ct); if (!kickResult.IsSuccess) { - return Result.FromError(kickResult.Error); + memberData.Kicked = false; + return ResultExtensions.FromError(kickResult); } - var memberData = data.GetOrCreateMemberData(target.ID); memberData.Roles.Clear(); - memberData.Kicked = true; var title = string.Format(Messages.UserKicked, target.GetTag()); var description = MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason)); diff --git a/src/Commands/MuteCommandGroup.cs b/src/Commands/MuteCommandGroup.cs index c2542e8..ce0a296 100644 --- a/src/Commands/MuteCommandGroup.cs +++ b/src/Commands/MuteCommandGroup.cs @@ -28,6 +28,7 @@ namespace Octobot.Commands; [UsedImplicitly] public class MuteCommandGroup : CommandGroup { + private readonly AccessControlService _access; private readonly ICommandContext _context; private readonly IFeedbackService _feedback; private readonly IDiscordRestGuildAPI _guildApi; @@ -35,14 +36,14 @@ public class MuteCommandGroup : CommandGroup private readonly IDiscordRestUserAPI _userApi; private readonly Utility _utility; - public MuteCommandGroup( - ICommandContext context, GuildDataService guildData, IFeedbackService feedback, - IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, Utility utility) + public MuteCommandGroup(AccessControlService access, ICommandContext context, IFeedbackService feedback, + IDiscordRestGuildAPI guildApi, GuildDataService guildData, IDiscordRestUserAPI userApi, Utility utility) { + _access = access; _context = context; - _guildData = guildData; _feedback = feedback; _guildApi = guildApi; + _guildData = guildData; _userApi = userApi; _utility = utility; } @@ -62,10 +63,10 @@ public class MuteCommandGroup : CommandGroup /// /// [Command("mute", "мут")] - [DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] - [RequireDiscordPermission(DiscordPermission.ModerateMembers)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [Description("Mute member")] [UsedImplicitly] @@ -85,13 +86,13 @@ public class MuteCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -118,7 +119,8 @@ public class MuteCommandGroup : CommandGroup return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken); } - return await MuteUserAsync(executor, target, reason, duration, guildId, data, channelId, bot, CancellationToken); + return await MuteUserAsync(executor, target, reason, duration, guildId, data, channelId, bot, + CancellationToken); } private async Task MuteUserAsync( @@ -126,11 +128,11 @@ public class MuteCommandGroup : CommandGroup Snowflake channelId, IUser bot, CancellationToken ct = default) { var interactionResult - = await _utility.CheckInteractionsAsync( + = await _access.CheckInteractionsAsync( guildId, executor.ID, target.ID, "Mute", ct); if (!interactionResult.IsSuccess) { - return Result.FromError(interactionResult); + return ResultExtensions.FromError(interactionResult); } if (interactionResult.Entity is not null) @@ -143,14 +145,16 @@ public class MuteCommandGroup : CommandGroup var until = DateTimeOffset.UtcNow.Add(duration); // >:) - var muteMethodResult = await SelectMuteMethodAsync(executor, target, reason, duration, guildId, data, bot, until, ct); + var muteMethodResult = + await SelectMuteMethodAsync(executor, target, reason, duration, guildId, data, bot, until, ct); if (!muteMethodResult.IsSuccess) { - return muteMethodResult; + return ResultExtensions.FromError(muteMethodResult); } var title = string.Format(Messages.UserMuted, target.GetTag()); - var description = new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)) + var description = new StringBuilder() + .AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)) .AppendBulletPoint(string.Format( Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until))).ToString(); @@ -236,10 +240,10 @@ public class MuteCommandGroup : CommandGroup /// /// [Command("unmute", "размут")] - [DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] - [RequireDiscordPermission(DiscordPermission.ModerateMembers)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] [Description("Unmute member")] [UsedImplicitly] @@ -257,14 +261,14 @@ public class MuteCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } // Needed to get the tag and avatar var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -287,11 +291,11 @@ public class MuteCommandGroup : CommandGroup IUser bot, CancellationToken ct = default) { var interactionResult - = await _utility.CheckInteractionsAsync( + = await _access.CheckInteractionsAsync( guildId, executor.ID, target.ID, "Unmute", ct); if (!interactionResult.IsSuccess) { - return Result.FromError(interactionResult); + return ResultExtensions.FromError(interactionResult); } if (interactionResult.Entity is not null) @@ -324,14 +328,14 @@ public class MuteCommandGroup : CommandGroup await RemoveMuteRoleAsync(executor, target, reason, guildId, memberData, CancellationToken); if (!removeMuteRoleAsync.IsSuccess) { - return Result.FromError(removeMuteRoleAsync.Error); + return ResultExtensions.FromError(removeMuteRoleAsync); } var removeTimeoutResult = await RemoveTimeoutAsync(executor, target, reason, guildId, communicationDisabledUntil, CancellationToken); if (!removeTimeoutResult.IsSuccess) { - return Result.FromError(removeTimeoutResult.Error); + return ResultExtensions.FromError(removeTimeoutResult); } var title = string.Format(Messages.UserUnmuted, target.GetTag()); @@ -348,11 +352,12 @@ public class MuteCommandGroup : CommandGroup } private async Task RemoveMuteRoleAsync( - IUser executor, IUser target, string reason, Snowflake guildId, MemberData memberData, CancellationToken ct = default) + IUser executor, IUser target, string reason, Snowflake guildId, MemberData memberData, + CancellationToken ct = default) { if (memberData.MutedUntil is null) { - return Result.FromSuccess(); + return Result.Success; } var unmuteResult = await _guildApi.ModifyGuildMemberAsync( @@ -372,7 +377,7 @@ public class MuteCommandGroup : CommandGroup { if (communicationDisabledUntil is null) { - return Result.FromSuccess(); + return Result.Success; } var unmuteResult = await _guildApi.ModifyGuildMemberAsync( diff --git a/src/Commands/PingCommandGroup.cs b/src/Commands/PingCommandGroup.cs index 31fa6dc..d64c6dd 100644 --- a/src/Commands/PingCommandGroup.cs +++ b/src/Commands/PingCommandGroup.cs @@ -64,7 +64,7 @@ public class PingCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var cfg = await _guildData.GetSettings(guildId, CancellationToken); @@ -84,14 +84,14 @@ public class PingCommandGroup : CommandGroup channelId, limit: 1, ct: ct); if (!lastMessageResult.IsDefined(out var lastMessage)) { - return Result.FromError(lastMessageResult); + return ResultExtensions.FromError(lastMessageResult); } latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds; } var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot) - .WithTitle($"Sound{Random.Shared.Next(1, 4)}".Localized()) + .WithTitle($"Generic{Random.Shared.Next(1, 4)}".Localized()) .WithDescription($"{latency:F0}{Messages.Milliseconds}") .WithColour(latency < 250 ? ColorsList.Green : latency < 500 ? ColorsList.Yellow : ColorsList.Red) .WithCurrentTimestamp() diff --git a/src/Commands/RemindCommandGroup.cs b/src/Commands/RemindCommandGroup.cs index f9c006e..aa1ef7e 100644 --- a/src/Commands/RemindCommandGroup.cs +++ b/src/Commands/RemindCommandGroup.cs @@ -63,13 +63,13 @@ public class RemindCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -134,13 +134,13 @@ public class RemindCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -226,13 +226,13 @@ public class RemindCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -343,7 +343,7 @@ public class RemindCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var data = await _guildData.GetData(guildId, CancellationToken); diff --git a/src/Commands/SettingsCommandGroup.cs b/src/Commands/SettingsCommandGroup.cs index acfb8ed..a39e9c7 100644 --- a/src/Commands/SettingsCommandGroup.cs +++ b/src/Commands/SettingsCommandGroup.cs @@ -39,6 +39,7 @@ public class SettingsCommandGroup : CommandGroup [ GuildSettings.Language, GuildSettings.WelcomeMessage, + GuildSettings.LeaveMessage, GuildSettings.ReceiveStartupMessages, GuildSettings.RemoveRolesOnMute, GuildSettings.ReturnRolesOnRejoin, @@ -46,9 +47,11 @@ public class SettingsCommandGroup : CommandGroup GuildSettings.RenameHoistedUsers, GuildSettings.PublicFeedbackChannel, GuildSettings.PrivateFeedbackChannel, + GuildSettings.WelcomeMessagesChannel, GuildSettings.EventNotificationChannel, GuildSettings.DefaultRole, GuildSettings.MuteRole, + GuildSettings.ModeratorRole, GuildSettings.EventNotificationRole, GuildSettings.EventEarlyNotificationOffset ]; @@ -96,7 +99,7 @@ public class SettingsCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var cfg = await _guildData.GetSettings(guildId, CancellationToken); @@ -179,13 +182,13 @@ public class SettingsCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -239,7 +242,7 @@ public class SettingsCommandGroup : CommandGroup [DiscordDefaultDMPermission(false)] [RequireContext(ChannelContext.Guild)] [RequireDiscordPermission(DiscordPermission.ManageGuild)] - [Description("Reset settings for this server")] + [Description("Reset settings for this guild")] [UsedImplicitly] public async Task ExecuteResetSettingsAsync( [Description("Setting to reset")] AllOptionsEnum? setting = null) @@ -252,7 +255,7 @@ public class SettingsCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var cfg = await _guildData.GetSettings(guildId, CancellationToken); @@ -272,7 +275,7 @@ public class SettingsCommandGroup : CommandGroup var resetResult = option.Reset(cfg); if (!resetResult.IsSuccess) { - return Result.FromError(resetResult.Error); + return ResultExtensions.FromError(resetResult); } var embed = new EmbedBuilder().WithSmallTitle( diff --git a/src/Commands/ToolsCommandGroup.cs b/src/Commands/ToolsCommandGroup.cs index ea91e1e..d4f3f75 100644 --- a/src/Commands/ToolsCommandGroup.cs +++ b/src/Commands/ToolsCommandGroup.cs @@ -35,7 +35,7 @@ public class ToolsCommandGroup : CommandGroup public ToolsCommandGroup( ICommandContext context, IFeedbackService feedback, GuildDataService guildData, IDiscordRestGuildAPI guildApi, - IDiscordRestUserAPI userApi, IDiscordRestChannelAPI channelApi) + IDiscordRestUserAPI userApi) { _context = context; _guildData = guildData; @@ -81,13 +81,13 @@ public class ToolsCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -262,7 +262,7 @@ public class ToolsCommandGroup : CommandGroup /// [Command("guildinfo")] [DiscordDefaultDMPermission(false)] - [Description("Shows info current guild")] + [Description("Shows info about current guild")] [UsedImplicitly] public async Task ExecuteGuildInfoAsync() { @@ -274,13 +274,13 @@ public class ToolsCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); if (!guildResult.IsDefined(out var guild)) { - return Result.FromError(guildResult); + return ResultExtensions.FromError(guildResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -353,7 +353,7 @@ public class ToolsCommandGroup : CommandGroup var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -439,13 +439,13 @@ public class ToolsCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); if (!executorResult.IsDefined(out var executor)) { - return Result.FromError(executorResult); + return ResultExtensions.FromError(executorResult); } var data = await _guildData.GetData(guildId, CancellationToken); @@ -514,7 +514,7 @@ public class ToolsCommandGroup : CommandGroup [UsedImplicitly] public async Task ExecuteEightBallAsync( // let the user think he's actually asking the ball a question - string question) + [Description("Question to ask")] string question) { if (!_context.TryGetContextIDs(out var guildId, out _, out _)) { @@ -524,7 +524,7 @@ public class ToolsCommandGroup : CommandGroup var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } var data = await _guildData.GetData(guildId, CancellationToken); diff --git a/src/Data/GuildSettings.cs b/src/Data/GuildSettings.cs index cdaede6..a1d8d74 100644 --- a/src/Data/GuildSettings.cs +++ b/src/Data/GuildSettings.cs @@ -13,17 +13,29 @@ 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. + /// Controls what message should be sent in when a new member joins the guild. /// /// /// /// No message will be sent if set to "off", "disable" or "disabled". - /// will be sent if set to "default" or "reset" + /// will be sent if set to "default" or "reset". /// /// /// public static readonly Option WelcomeMessage = new("WelcomeMessage", "default"); + /// + /// Controls what message should be sent in when a member leaves the guild. + /// + /// + /// + /// 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 LeaveMessage = new("LeaveMessage", "default"); + /// /// Controls whether or not the message should be sent /// in on startup. @@ -56,9 +68,15 @@ public static class GuildSettings /// public static readonly SnowflakeOption PrivateFeedbackChannel = new("PrivateFeedbackChannel"); + /// + /// Controls what channel should welcome messages be sent to. + /// + public static readonly SnowflakeOption WelcomeMessagesChannel = new("WelcomeMessagesChannel"); + 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 ModeratorRole = new("ModeratorRole"); public static readonly SnowflakeOption EventNotificationRole = new("EventNotificationRole"); /// diff --git a/src/Data/MemberData.cs b/src/Data/MemberData.cs index 8e23e54..1b3d0d9 100644 --- a/src/Data/MemberData.cs +++ b/src/Data/MemberData.cs @@ -5,10 +5,9 @@ namespace Octobot.Data; /// public sealed class MemberData { - public MemberData(ulong id, DateTimeOffset? bannedUntil = null, List? reminders = null) + public MemberData(ulong id, List? reminders = null) { Id = id; - BannedUntil = bannedUntil; if (reminders is not null) { Reminders = reminders; diff --git a/src/Data/Options/AllOptionsEnum.cs b/src/Data/Options/AllOptionsEnum.cs index a96a9ac..d9e0c13 100644 --- a/src/Data/Options/AllOptionsEnum.cs +++ b/src/Data/Options/AllOptionsEnum.cs @@ -14,6 +14,7 @@ public enum AllOptionsEnum { [UsedImplicitly] Language, [UsedImplicitly] WelcomeMessage, + [UsedImplicitly] LeaveMessage, [UsedImplicitly] ReceiveStartupMessages, [UsedImplicitly] RemoveRolesOnMute, [UsedImplicitly] ReturnRolesOnRejoin, @@ -21,9 +22,11 @@ public enum AllOptionsEnum [UsedImplicitly] RenameHoistedUsers, [UsedImplicitly] PublicFeedbackChannel, [UsedImplicitly] PrivateFeedbackChannel, + [UsedImplicitly] WelcomeMessagesChannel, [UsedImplicitly] EventNotificationChannel, [UsedImplicitly] DefaultRole, [UsedImplicitly] MuteRole, + [UsedImplicitly] ModeratorRole, [UsedImplicitly] EventNotificationRole, [UsedImplicitly] EventEarlyNotificationOffset } diff --git a/src/Data/Options/BoolOption.cs b/src/Data/Options/BoolOption.cs index 130687e..6876164 100644 --- a/src/Data/Options/BoolOption.cs +++ b/src/Data/Options/BoolOption.cs @@ -20,7 +20,7 @@ public sealed class BoolOption : Option } settings[Name] = value; - return Result.FromSuccess(); + return Result.Success; } private static bool TryParseBool(string from, out bool value) diff --git a/src/Data/Options/Option.cs b/src/Data/Options/Option.cs index 0ba8ce1..5d703a8 100644 --- a/src/Data/Options/Option.cs +++ b/src/Data/Options/Option.cs @@ -35,7 +35,13 @@ public class Option : IOption public virtual Result Set(JsonNode settings, string from) { settings[Name] = from; - return Result.FromSuccess(); + return Result.Success; + } + + public Result Reset(JsonNode settings) + { + settings[Name] = null; + return Result.Success; } /// @@ -48,10 +54,4 @@ public class Option : IOption var property = settings[Name]; return property != null ? property.GetValue() : DefaultValue; } - - public Result Reset(JsonNode settings) - { - settings[Name] = null; - return Result.FromSuccess(); - } } diff --git a/src/Data/Options/SnowflakeOption.cs b/src/Data/Options/SnowflakeOption.cs index 66ada96..7118da8 100644 --- a/src/Data/Options/SnowflakeOption.cs +++ b/src/Data/Options/SnowflakeOption.cs @@ -32,7 +32,7 @@ public sealed partial class SnowflakeOption : Option } settings[Name] = parsed; - return Result.FromSuccess(); + return Result.Success; } [GeneratedRegex("[^0-9]")] diff --git a/src/Data/Options/TimeSpanOption.cs b/src/Data/Options/TimeSpanOption.cs index c81a02d..d237b6e 100644 --- a/src/Data/Options/TimeSpanOption.cs +++ b/src/Data/Options/TimeSpanOption.cs @@ -22,6 +22,6 @@ public sealed class TimeSpanOption : Option } settings[Name] = span.ToString(); - return Result.FromSuccess(); + return Result.Success; } } diff --git a/src/Extensions/ChannelApiExtensions.cs b/src/Extensions/ChannelApiExtensions.cs index 12ccf35..99eff67 100644 --- a/src/Extensions/ChannelApiExtensions.cs +++ b/src/Extensions/ChannelApiExtensions.cs @@ -20,7 +20,7 @@ public static class ChannelApiExtensions { if (!embedResult.IsDefined() || !embedResult.Value.IsDefined(out var embed)) { - return Result.FromError(embedResult.Value); + return ResultExtensions.FromError(embedResult.Value); } return (Result)await channelApi.CreateMessageAsync(channelId, message, nonce, isTextToSpeech, new[] { embed }, diff --git a/src/Extensions/CollectionExtensions.cs b/src/Extensions/CollectionExtensions.cs index 9c873f2..2369532 100644 --- a/src/Extensions/CollectionExtensions.cs +++ b/src/Extensions/CollectionExtensions.cs @@ -32,7 +32,7 @@ public static class CollectionExtensions { return list.Count switch { - 0 => Result.FromSuccess(), + 0 => Result.Success, 1 => list[0], _ => new AggregateError(list.Cast().ToArray()) }; diff --git a/src/Extensions/FeedbackServiceExtensions.cs b/src/Extensions/FeedbackServiceExtensions.cs index 40e0d53..e6ef376 100644 --- a/src/Extensions/FeedbackServiceExtensions.cs +++ b/src/Extensions/FeedbackServiceExtensions.cs @@ -13,7 +13,7 @@ public static class FeedbackServiceExtensions { if (!embedResult.IsDefined(out var embed)) { - return Result.FromError(embedResult); + return ResultExtensions.FromError(embedResult); } return (Result)await feedback.SendContextualEmbedAsync(embed, options, ct); diff --git a/src/Extensions/GuildScheduledEventExtensions.cs b/src/Extensions/GuildScheduledEventExtensions.cs index e3217e3..f1b6985 100644 --- a/src/Extensions/GuildScheduledEventExtensions.cs +++ b/src/Extensions/GuildScheduledEventExtensions.cs @@ -22,7 +22,7 @@ public static class GuildScheduledEventExtensions } return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime) - ? Result.FromSuccess() + ? Result.Success : new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); } } diff --git a/src/Extensions/LoggerExtensions.cs b/src/Extensions/LoggerExtensions.cs index 9df90b8..fca3702 100644 --- a/src/Extensions/LoggerExtensions.cs +++ b/src/Extensions/LoggerExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Logging; -using Remora.Discord.Commands.Extensions; using Remora.Results; namespace Octobot.Extensions; @@ -19,7 +18,7 @@ public static class LoggerExtensions /// The message to use if this result has failed. public static void LogResult(this ILogger logger, IResult result, string? message = "") { - if (result.IsSuccess || result.Error.IsUserOrEnvironmentError()) + if (result.IsSuccess) { return; } diff --git a/src/Extensions/ResultExtensions.cs b/src/Extensions/ResultExtensions.cs new file mode 100644 index 0000000..f456dac --- /dev/null +++ b/src/Extensions/ResultExtensions.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Remora.Results; + +namespace Octobot.Extensions; + +public static class ResultExtensions +{ + public static Result FromError(Result result) + { + LogResultStackTrace(result); + + return result; + } + + public static Result FromError(Result result) + { + var casted = (Result)result; + LogResultStackTrace(casted); + + return casted; + } + + [Conditional("DEBUG")] + private static void LogResultStackTrace(Result result) + { + if (Octobot.StaticLogger is null || result.IsSuccess) + { + return; + } + + Octobot.StaticLogger.LogError("{ErrorType}: {ErrorMessage}{NewLine}{StackTrace}", + result.Error.GetType().FullName, result.Error.Message, Environment.NewLine, ConstructStackTrace()); + + var inner = result.Inner; + while (inner is { IsSuccess: false }) + { + Octobot.StaticLogger.LogError("Caused by: {ResultType}: {ResultMessage}", + inner.Error.GetType().FullName, inner.Error.Message); + + inner = inner.Inner; + } + } + + private static string ConstructStackTrace() + { + var stackArray = new StackTrace(3, true).ToString().Split(Environment.NewLine).ToList(); + for (var i = stackArray.Count - 1; i >= 0; i--) + { + var frame = stackArray[i]; + var trimmed = frame.TrimStart(); + if (trimmed.StartsWith("at System.Threading", StringComparison.Ordinal) + || trimmed.StartsWith("at System.Runtime.CompilerServices", StringComparison.Ordinal)) + { + stackArray.RemoveAt(i); + } + } + + return string.Join(Environment.NewLine, stackArray); + } +} diff --git a/src/Messages.Designer.cs b/src/Messages.Designer.cs index 3105bd1..729fd95 100644 --- a/src/Messages.Designer.cs +++ b/src/Messages.Designer.cs @@ -66,21 +66,21 @@ namespace Octobot { } } - internal static string Sound1 { + internal static string Generic1 { get { - return ResourceManager.GetString("Sound1", resourceCulture); + return ResourceManager.GetString("Generic1", resourceCulture); } } - internal static string Sound2 { + internal static string Generic2 { get { - return ResourceManager.GetString("Sound2", resourceCulture); + return ResourceManager.GetString("Generic2", resourceCulture); } } - internal static string Sound3 { + internal static string Generic3 { get { - return ResourceManager.GetString("Sound3", resourceCulture); + return ResourceManager.GetString("Generic3", resourceCulture); } } @@ -959,18 +959,26 @@ namespace Octobot { } } - internal static string InvalidTimeSpan - { - get - { + internal static string DefaultLeaveMessage { + get { + return ResourceManager.GetString("DefaultLeaveMessage", resourceCulture); + } + } + + internal static string SettingsLeaveMessage { + get { + return ResourceManager.GetString("SettingsLeaveMessage", resourceCulture); + } + } + + internal static string InvalidTimeSpan { + get { return ResourceManager.GetString("InvalidTimeSpan", resourceCulture); } } - internal static string UserInfoKicked - { - get - { + internal static string UserInfoKicked { + get { return ResourceManager.GetString("UserInfoKicked", resourceCulture); } } @@ -1106,5 +1114,29 @@ namespace Octobot { return ResourceManager.GetString("TimeSpanExample", resourceCulture); } } + + internal static string Version { + get { + return ResourceManager.GetString("Version", resourceCulture); + } + } + + internal static string SettingsWelcomeMessagesChannel { + get { + return ResourceManager.GetString("SettingsWelcomeMessagesChannel", resourceCulture); + } + } + + internal static string ButtonDirty { + get { + return ResourceManager.GetString("ButtonDirty", resourceCulture); + } + } + + internal static string ButtonOpenWiki { + get { + return ResourceManager.GetString("ButtonOpenWiki", resourceCulture); + } + } } } diff --git a/src/Octobot.cs b/src/Octobot.cs index 1ebf7c3..065967e 100644 --- a/src/Octobot.cs +++ b/src/Octobot.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Octobot.Attributes; using Octobot.Commands.Events; using Octobot.Services; using Octobot.Services.Update; @@ -22,16 +23,17 @@ namespace Octobot; public sealed class Octobot { - public const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot"; - public const string IssuesUrl = $"{RepositoryUrl}/issues"; - public static readonly AllowedMentions NoMentions = new( Array.Empty(), Array.Empty(), Array.Empty()); + [StaticCallersOnly] + public static ILogger? StaticLogger { get; private set; } + public static async Task Main(string[] args) { var host = CreateHostBuilder(args).UseConsoleLifetime().Build(); var services = host.Services; + StaticLogger = services.GetRequiredService>(); var slashService = services.GetRequiredService(); // Providing a guild ID to this call will result in command duplicates! @@ -86,8 +88,9 @@ public sealed class Octobot .AddPreparationErrorEvent() .AddPostExecutionEvent() // Services - .AddSingleton() + .AddSingleton() .AddSingleton() + .AddSingleton() .AddHostedService(provider => provider.GetRequiredService()) .AddHostedService() .AddHostedService() diff --git a/src/Responders/GuildLoadedResponder.cs b/src/Responders/GuildLoadedResponder.cs index a1e7d16..b03fd3f 100644 --- a/src/Responders/GuildLoadedResponder.cs +++ b/src/Responders/GuildLoadedResponder.cs @@ -42,7 +42,7 @@ public class GuildLoadedResponder : IResponder { if (!gatewayEvent.Guild.IsT0) // Guild is not IAvailableGuild { - return Result.FromSuccess(); + return Result.Success; } var guild = gatewayEvent.Guild.AsT0; @@ -57,7 +57,7 @@ public class GuildLoadedResponder : IResponder var botResult = await _userApi.GetCurrentUserAsync(ct); if (!botResult.IsDefined(out var bot)) { - return Result.FromError(botResult); + return ResultExtensions.FromError(botResult); } if (data.DataLoadFailed) @@ -68,27 +68,23 @@ public class GuildLoadedResponder : IResponder var ownerResult = await _userApi.GetUserAsync(guild.OwnerID, ct); if (!ownerResult.IsDefined(out var owner)) { - return Result.FromError(ownerResult); + return ResultExtensions.FromError(ownerResult); } _logger.LogInformation("Loaded guild \"{Name}\" ({ID}) owned by {Owner} ({OwnerID}) with {MemberCount} members", guild.Name, guild.ID, owner.GetTag(), owner.ID, guild.MemberCount); - if (!GuildSettings.ReceiveStartupMessages.Get(cfg)) + if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty() + || !GuildSettings.ReceiveStartupMessages.Get(cfg)) { - return Result.FromSuccess(); - } - - if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) - { - return Result.FromSuccess(); + return Result.Success; } Messages.Culture = GuildSettings.Language.Get(cfg); var i = Random.Shared.Next(1, 4); var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot) - .WithTitle($"Sound{i}".Localized()) + .WithTitle($"Generic{i}".Localized()) .WithDescription(Messages.Ready) .WithCurrentTimestamp() .WithColour(ColorsList.Blue) @@ -103,7 +99,7 @@ public class GuildLoadedResponder : IResponder var channelResult = await _utility.GetEmergencyFeedbackChannel(guild, data, ct); if (!channelResult.IsDefined(out var channel)) { - return Result.FromError(channelResult); + return ResultExtensions.FromError(channelResult); } var errorEmbed = new EmbedBuilder() @@ -115,9 +111,12 @@ public class GuildLoadedResponder : IResponder var issuesButton = new ButtonComponent( ButtonComponentStyle.Link, - Messages.ButtonReportIssue, + BuildInfo.IsDirty + ? Messages.ButtonDirty + : Messages.ButtonReportIssue, new PartialEmoji(Name: "⚠️"), - URL: Octobot.IssuesUrl + URL: BuildInfo.IssuesUrl, + IsDisabled: BuildInfo.IsDirty ); return await _channelApi.CreateMessageWithEmbedResultAsync(channel, embedResult: errorEmbed, diff --git a/src/Responders/GuildMemberJoinedResponder.cs b/src/Responders/GuildMemberJoinedResponder.cs index eee93b6..61ef5cc 100644 --- a/src/Responders/GuildMemberJoinedResponder.cs +++ b/src/Responders/GuildMemberJoinedResponder.cs @@ -48,13 +48,13 @@ public class GuildMemberJoinedResponder : IResponder var returnRolesResult = await TryReturnRolesAsync(cfg, memberData, gatewayEvent.GuildID, user.ID, ct); if (!returnRolesResult.IsSuccess) { - return Result.FromError(returnRolesResult.Error); + return ResultExtensions.FromError(returnRolesResult); } - if (GuildSettings.PublicFeedbackChannel.Get(cfg).Empty() + if (GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty() || GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled") { - return Result.FromSuccess(); + return Result.Success; } Messages.Culture = GuildSettings.Language.Get(cfg); @@ -65,7 +65,7 @@ public class GuildMemberJoinedResponder : IResponder var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct); if (!guildResult.IsDefined(out var guild)) { - return Result.FromError(guildResult); + return ResultExtensions.FromError(guildResult); } var embed = new EmbedBuilder() @@ -76,7 +76,7 @@ public class GuildMemberJoinedResponder : IResponder .Build(); return await _channelApi.CreateMessageWithEmbedResultAsync( - GuildSettings.PublicFeedbackChannel.Get(cfg), embedResult: embed, + GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed, allowedMentions: Octobot.NoMentions, ct: ct); } @@ -85,7 +85,7 @@ public class GuildMemberJoinedResponder : IResponder { if (!GuildSettings.ReturnRolesOnRejoin.Get(cfg)) { - return Result.FromSuccess(); + return Result.Success; } var assignRoles = new List(); diff --git a/src/Responders/GuildMemberLeftResponder.cs b/src/Responders/GuildMemberLeftResponder.cs new file mode 100644 index 0000000..90cc64c --- /dev/null +++ b/src/Responders/GuildMemberLeftResponder.cs @@ -0,0 +1,72 @@ +using JetBrains.Annotations; +using Octobot.Data; +using Octobot.Extensions; +using Octobot.Services; +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 Octobot.Responders; + +/// +/// Handles sending a guild's if one is set. +/// +/// +[UsedImplicitly] +public class GuildMemberLeftResponder : IResponder +{ + private readonly IDiscordRestChannelAPI _channelApi; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + + public GuildMemberLeftResponder( + IDiscordRestChannelAPI channelApi, GuildDataService guildData, IDiscordRestGuildAPI guildApi) + { + _channelApi = channelApi; + _guildData = guildData; + _guildApi = guildApi; + } + + public async Task RespondAsync(IGuildMemberRemove gatewayEvent, CancellationToken ct = default) + { + var user = gatewayEvent.User; + var data = await _guildData.GetData(gatewayEvent.GuildID, ct); + var cfg = data.Settings; + + var memberData = data.GetOrCreateMemberData(user.ID); + if (memberData.BannedUntil is not null || memberData.Kicked) + { + return Result.Success; + } + + if (GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty() + || GuildSettings.LeaveMessage.Get(cfg) is "off" or "disable" or "disabled") + { + return Result.Success; + } + + Messages.Culture = GuildSettings.Language.Get(cfg); + var leaveMessage = GuildSettings.LeaveMessage.Get(cfg) is "default" or "reset" + ? Messages.DefaultLeaveMessage + : GuildSettings.LeaveMessage.Get(cfg); + + var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct); + if (!guildResult.IsDefined(out var guild)) + { + return ResultExtensions.FromError(guildResult); + } + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(leaveMessage, user.GetTag(), guild.Name), user) + .WithGuildFooter(guild) + .WithTimestamp(DateTimeOffset.UtcNow) + .WithColour(ColorsList.Black) + .Build(); + + return await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed, + allowedMentions: Octobot.NoMentions, ct: ct); + } +} diff --git a/src/Responders/GuildUnloadedResponder.cs b/src/Responders/GuildUnloadedResponder.cs index 47bde75..b49d136 100644 --- a/src/Responders/GuildUnloadedResponder.cs +++ b/src/Responders/GuildUnloadedResponder.cs @@ -33,6 +33,6 @@ public class GuildUnloadedResponder : IResponder _logger.LogInformation("Unloaded guild {GuildId}", guildId); } - return Task.FromResult(Result.FromSuccess()); + return Task.FromResult(Result.Success); } } diff --git a/src/Responders/MessageDeletedResponder.cs b/src/Responders/MessageDeletedResponder.cs index bfedb22..5a69273 100644 --- a/src/Responders/MessageDeletedResponder.cs +++ b/src/Responders/MessageDeletedResponder.cs @@ -39,31 +39,31 @@ public class MessageDeletedResponder : IResponder { if (!gatewayEvent.GuildID.IsDefined(out var guildId)) { - return Result.FromSuccess(); + return Result.Success; } var cfg = await _guildData.GetSettings(guildId, ct); if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) { - return Result.FromSuccess(); + return Result.Success; } var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct); if (!messageResult.IsDefined(out var message)) { - return Result.FromError(messageResult); + return ResultExtensions.FromError(messageResult); } if (string.IsNullOrWhiteSpace(message.Content)) { - return Result.FromSuccess(); + return Result.Success; } var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync( guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct); if (!auditLogResult.IsDefined(out var auditLogPage)) { - return Result.FromError(auditLogResult); + return ResultExtensions.FromError(auditLogResult); } var auditLog = auditLogPage.AuditLogEntries.Single(); @@ -78,15 +78,16 @@ public class MessageDeletedResponder : IResponder if (!deleterResult.IsDefined(out var deleter)) { - return Result.FromError(deleterResult); + return ResultExtensions.FromError(deleterResult); } Messages.Culture = GuildSettings.Language.Get(cfg); - var builder = new StringBuilder().AppendLine( - string.Format(Messages.DescriptionActionJumpToChannel, - Mention.Channel(gatewayEvent.ChannelID))) - .AppendLine(message.Content.InBlockCode()); + var builder = new StringBuilder() + .AppendLine(message.Content.InBlockCode()) + .AppendLine( + string.Format(Messages.DescriptionActionJumpToChannel, Mention.Channel(gatewayEvent.ChannelID)) + ); var embed = new EmbedBuilder() .WithSmallTitle( diff --git a/src/Responders/MessageEditedResponder.cs b/src/Responders/MessageEditedResponder.cs index c7426d2..1143652 100644 --- a/src/Responders/MessageEditedResponder.cs +++ b/src/Responders/MessageEditedResponder.cs @@ -46,30 +46,18 @@ public class MessageEditedResponder : IResponder return new ArgumentNullError(nameof(gatewayEvent.ChannelID)); } - if (!gatewayEvent.GuildID.IsDefined(out var guildId)) + if (!gatewayEvent.GuildID.IsDefined(out var guildId) + || !gatewayEvent.Author.IsDefined(out var author) + || !gatewayEvent.EditedTimestamp.IsDefined(out var timestamp) + || !gatewayEvent.Content.IsDefined(out var newContent)) { - return Result.FromSuccess(); - } - - if (gatewayEvent.Author.IsDefined(out var author) && author.IsBot.OrDefault(false)) - { - return Result.FromSuccess(); - } - - if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp)) - { - return Result.FromSuccess(); // The message wasn't actually edited - } - - if (!gatewayEvent.Content.IsDefined(out var newContent)) - { - return Result.FromSuccess(); + return Result.Success; } var cfg = await _guildData.GetSettings(guildId, ct); - if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) + if (author.IsBot.OrDefault(false) || GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) { - return Result.FromSuccess(); + return Result.Success; } var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId); @@ -78,12 +66,12 @@ public class MessageEditedResponder : IResponder if (!messageResult.IsDefined(out var message)) { _ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct); - return Result.FromSuccess(); + return Result.Success; } if (message.Content == newContent) { - return Result.FromSuccess(); + return Result.Success; } // Custom event responders are called earlier than responders responsible for message caching @@ -101,10 +89,11 @@ public class MessageEditedResponder : IResponder Messages.Culture = GuildSettings.Language.Get(cfg); - var builder = new StringBuilder().AppendLine( - string.Format(Messages.DescriptionActionJumpToMessage, - $"https://discord.com/channels/{guildId}/{channelId}/{messageId}")) - .AppendLine(diff.AsMarkdown()); + var builder = new StringBuilder() + .AppendLine(diff.AsMarkdown()) + .AppendLine(string.Format(Messages.DescriptionActionJumpToMessage, + $"https://discord.com/channels/{guildId}/{channelId}/{messageId}") + ); var embed = new EmbedBuilder() .WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author) diff --git a/src/Responders/MessageReceivedResponder.cs b/src/Responders/MessageReceivedResponder.cs index 6ab7199..4c26d8d 100644 --- a/src/Responders/MessageReceivedResponder.cs +++ b/src/Responders/MessageReceivedResponder.cs @@ -34,6 +34,6 @@ public class MessageCreateResponder : IResponder "лан" => "https://i.ibb.co/VYH2QLc/lan.jpg", _ => default(Optional) }); - return Task.FromResult(Result.FromSuccess()); + return Task.FromResult(Result.Success); } } diff --git a/src/Services/AccessControlService.cs b/src/Services/AccessControlService.cs new file mode 100644 index 0000000..aeb16e4 --- /dev/null +++ b/src/Services/AccessControlService.cs @@ -0,0 +1,176 @@ +using Octobot.Data; +using Octobot.Extensions; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Results; +using Remora.Rest.Core; +using Remora.Results; + +namespace Octobot.Services; + +public sealed class AccessControlService +{ + private readonly GuildDataService _data; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly RequireDiscordPermissionCondition _permission; + private readonly IDiscordRestUserAPI _userApi; + + public AccessControlService(GuildDataService data, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, + RequireDiscordPermissionCondition permission) + { + _data = data; + _guildApi = guildApi; + _userApi = userApi; + _permission = permission; + } + + private async Task> CheckPermissionAsync(GuildData data, Snowflake memberId, IGuildMember member, + DiscordPermission permission, CancellationToken ct = default) + { + var moderatorRole = GuildSettings.ModeratorRole.Get(data.Settings); + var result = await _permission.CheckAsync(new RequireDiscordPermissionAttribute([permission]), member, ct); + + if (result.Error is not null and not PermissionDeniedError) + { + return Result.FromError(result); + } + + var hasPermission = result.IsSuccess; + return hasPermission || (!moderatorRole.Empty() && + data.GetOrCreateMemberData(memberId).Roles.Contains(moderatorRole.Value)); + } + + /// + /// Checks whether or not a member can interact with another member + /// + /// The ID of the guild in which an operation is being performed. + /// The executor of the operation. + /// The target of the operation. + /// The operation. + /// The cancellation token for this operation. + /// + /// + /// A result which has succeeded with a null string if the member can interact with the target. + /// + /// A result which has succeeded with a non-null string containing the error message if the member cannot + /// interact with the target. + /// + /// A result which has failed if an error occurred during the execution of this method. + /// + /// + public async Task> CheckInteractionsAsync( + Snowflake guildId, Snowflake? interacterId, Snowflake targetId, string action, CancellationToken ct = default) + { + if (interacterId == targetId) + { + return Result.FromSuccess($"UserCannot{action}Themselves".Localized()); + } + + var botResult = await _userApi.GetCurrentUserAsync(ct); + if (!botResult.IsDefined(out var bot)) + { + return Result.FromError(botResult); + } + + var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct); + if (!guildResult.IsDefined(out var guild)) + { + return Result.FromError(guildResult); + } + + var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct); + if (!targetMemberResult.IsDefined(out var targetMember)) + { + return Result.FromSuccess(null); + } + + var botMemberResult = await _guildApi.GetGuildMemberAsync(guildId, bot.ID, ct); + if (!botMemberResult.IsDefined(out var botMember)) + { + return Result.FromError(botMemberResult); + } + + var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct); + if (!rolesResult.IsDefined(out var roles)) + { + return Result.FromError(rolesResult); + } + + if (interacterId is null) + { + return CheckInteractions(action, guild, roles, targetMember, botMember, botMember); + } + + var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId.Value, ct); + if (!interacterResult.IsDefined(out var interacter)) + { + return Result.FromError(interacterResult); + } + + var data = await _data.GetData(guildId, ct); + + var permissionResult = await CheckPermissionAsync(data, interacterId.Value, interacter, + action switch + { + "Ban" => DiscordPermission.BanMembers, + "Kick" => DiscordPermission.KickMembers, + "Mute" or "Unmute" => DiscordPermission.ModerateMembers, + _ => throw new Exception() + }, ct); + if (!permissionResult.IsDefined(out var hasPermission)) + { + return Result.FromError(permissionResult); + } + + return hasPermission + ? CheckInteractions(action, guild, roles, targetMember, botMember, interacter) + : Result.FromSuccess($"UserCannot{action}Members".Localized()); + } + + private static Result CheckInteractions( + string action, IGuild guild, IReadOnlyList roles, IGuildMember targetMember, IGuildMember botMember, + IGuildMember interacter) + { + if (!targetMember.User.IsDefined(out var targetUser)) + { + return new ArgumentNullError(nameof(targetMember.User)); + } + + if (!interacter.User.IsDefined(out var interacterUser)) + { + return new ArgumentNullError(nameof(interacter.User)); + } + + if (botMember.User == targetMember.User) + { + return Result.FromSuccess($"UserCannot{action}Bot".Localized()); + } + + if (targetUser.ID == guild.OwnerID) + { + return Result.FromSuccess($"UserCannot{action}Owner".Localized()); + } + + var targetRoles = roles.Where(r => targetMember.Roles.Contains(r.ID)).ToList(); + var botRoles = roles.Where(r => botMember.Roles.Contains(r.ID)); + + var targetBotRoleDiff = targetRoles.MaxOrDefault(r => r.Position) - botRoles.MaxOrDefault(r => r.Position); + if (targetBotRoleDiff >= 0) + { + return Result.FromSuccess($"BotCannot{action}Target".Localized()); + } + + if (interacterUser.ID == guild.OwnerID) + { + return Result.FromSuccess(null); + } + + var interacterRoles = roles.Where(r => interacter.Roles.Contains(r.ID)); + var targetInteracterRoleDiff + = targetRoles.MaxOrDefault(r => r.Position) - interacterRoles.MaxOrDefault(r => r.Position); + return targetInteracterRoleDiff < 0 + ? Result.FromSuccess(null) + : Result.FromSuccess($"UserCannot{action}Target".Localized()); + } +} diff --git a/src/Services/Update/MemberUpdateService.cs b/src/Services/Update/MemberUpdateService.cs index 7674bbe..e177fca 100644 --- a/src/Services/Update/MemberUpdateService.cs +++ b/src/Services/Update/MemberUpdateService.cs @@ -26,20 +26,20 @@ public sealed partial class MemberUpdateService : BackgroundService "Torus", "Violet", "Vortex", "Vulture", "Wagon", "Whale", "Woodpecker", "Zebra", "Zigzag" ]; + private readonly AccessControlService _access; private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestGuildAPI _guildApi; private readonly GuildDataService _guildData; private readonly ILogger _logger; - private readonly Utility _utility; - public MemberUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildAPI guildApi, - GuildDataService guildData, ILogger logger, Utility utility) + public MemberUpdateService(AccessControlService access, IDiscordRestChannelAPI channelApi, + IDiscordRestGuildAPI guildApi, GuildDataService guildData, ILogger logger) { + _access = access; _channelApi = channelApi; _guildApi = guildApi; _guildData = guildData; _logger = logger; - _utility = utility; } protected override async Task ExecuteAsync(CancellationToken ct) @@ -94,10 +94,10 @@ public sealed partial class MemberUpdateService : BackgroundService } var interactionResult - = await _utility.CheckInteractionsAsync(guildId, null, id, "Update", ct); + = await _access.CheckInteractionsAsync(guildId, null, id, "Update", ct); if (!interactionResult.IsSuccess) { - return Result.FromError(interactionResult); + return ResultExtensions.FromError(interactionResult); } var canInteract = interactionResult.Entity is null; @@ -121,7 +121,7 @@ public sealed partial class MemberUpdateService : BackgroundService if (!canInteract) { - return Result.FromSuccess(); + return Result.Success; } var autoUnmuteResult = await TryAutoUnmuteAsync(guildId, id, data, ct); @@ -148,7 +148,14 @@ public sealed partial class MemberUpdateService : BackgroundService { if (data.BannedUntil is null || DateTimeOffset.UtcNow <= data.BannedUntil) { - return Result.FromSuccess(); + return Result.Success; + } + + var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, id, ct); + if (!existingBanResult.IsDefined()) + { + data.BannedUntil = null; + return Result.Success; } var unbanResult = await _guildApi.RemoveGuildBanAsync( @@ -166,7 +173,7 @@ public sealed partial class MemberUpdateService : BackgroundService { if (data.MutedUntil is null || DateTimeOffset.UtcNow <= data.MutedUntil) { - return Result.FromSuccess(); + return Result.Success; } var unmuteResult = await _guildApi.ModifyGuildMemberAsync( @@ -202,7 +209,7 @@ public sealed partial class MemberUpdateService : BackgroundService if (!usernameChanged) { - return Result.FromSuccess(); + return Result.Success; } var newNickname = string.Concat(characterList.ToArray()); @@ -223,12 +230,13 @@ public sealed partial class MemberUpdateService : BackgroundService { if (DateTimeOffset.UtcNow < reminder.At) { - return Result.FromSuccess(); + return Result.Success; } var builder = new StringBuilder() .AppendBulletPointLine(string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text))) - .AppendBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}")); + .AppendBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, + $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}")); var embed = new EmbedBuilder().WithSmallTitle( string.Format(Messages.Reminder, user.GetTag()), user) @@ -240,10 +248,10 @@ public sealed partial class MemberUpdateService : BackgroundService reminder.ChannelId.ToSnowflake(), Mention.User(user), embedResult: embed, ct: ct); if (!messageResult.IsSuccess) { - return messageResult; + return ResultExtensions.FromError(messageResult); } data.Reminders.Remove(reminder); - return Result.FromSuccess(); + return Result.Success; } } diff --git a/src/Services/Update/ScheduledEventUpdateService.cs b/src/Services/Update/ScheduledEventUpdateService.cs index ac5c109..8168fc1 100644 --- a/src/Services/Update/ScheduledEventUpdateService.cs +++ b/src/Services/Update/ScheduledEventUpdateService.cs @@ -53,7 +53,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); if (!eventsResult.IsDefined(out var events)) { - return Result.FromError(eventsResult); + return ResultExtensions.FromError(eventsResult); } SyncScheduledEvents(data, events); @@ -147,7 +147,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService || eventData.EarlyNotificationSent || DateTimeOffset.UtcNow < scheduledEvent.ScheduledStartTime - offset) { - return Result.FromSuccess(); + return Result.Success; } var sendResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct); @@ -182,7 +182,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService { if (GuildSettings.EventNotificationChannel.Get(settings).Empty()) { - return Result.FromSuccess(); + return Result.Success; } if (!scheduledEvent.Creator.IsDefined(out var creator)) @@ -204,7 +204,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService if (!embedDescriptionResult.IsDefined(out var embedDescription)) { - return Result.FromError(embedDescriptionResult); + return ResultExtensions.FromError(embedDescriptionResult); } var embed = new EmbedBuilder() @@ -283,7 +283,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) { - return Result.FromSuccess(); + return Result.Success; } var embedDescriptionResult = scheduledEvent.EntityType switch @@ -298,12 +298,12 @@ public sealed class ScheduledEventUpdateService : BackgroundService scheduledEvent, data, ct); if (!contentResult.IsDefined(out var content)) { - return Result.FromError(contentResult); + return ResultExtensions.FromError(contentResult); } if (!embedDescriptionResult.IsDefined(out var embedDescription)) { - return Result.FromError(embedDescriptionResult); + return ResultExtensions.FromError(embedDescriptionResult); } var startedEmbed = new EmbedBuilder() @@ -324,7 +324,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) { data.ScheduledEvents.Remove(eventData.Id); - return Result.FromSuccess(); + return Result.Success; } var completedEmbed = new EmbedBuilder() @@ -356,7 +356,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) { data.ScheduledEvents.Remove(eventData.Id); - return Result.FromSuccess(); + return Result.Success; } var embed = new EmbedBuilder() @@ -409,14 +409,14 @@ public sealed class ScheduledEventUpdateService : BackgroundService { if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) { - return Result.FromSuccess(); + return Result.Success; } var contentResult = await _utility.GetEventNotificationMentions( scheduledEvent, data, ct); if (!contentResult.IsDefined(out var content)) { - return Result.FromError(contentResult); + return ResultExtensions.FromError(contentResult); } var earlyResult = new EmbedBuilder() diff --git a/src/Services/Utility.cs b/src/Services/Utility.cs index ad06315..3b9ab19 100644 --- a/src/Services/Utility.cs +++ b/src/Services/Utility.cs @@ -21,129 +21,13 @@ public sealed class Utility private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestGuildScheduledEventAPI _eventApi; private readonly IDiscordRestGuildAPI _guildApi; - private readonly IDiscordRestUserAPI _userApi; public Utility( - IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, - IDiscordRestUserAPI userApi) + IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi) { _channelApi = channelApi; _eventApi = eventApi; _guildApi = guildApi; - _userApi = userApi; - } - - /// - /// Checks whether or not a member can interact with another member - /// - /// The ID of the guild in which an operation is being performed. - /// The executor of the operation. - /// The target of the operation. - /// The operation. - /// The cancellation token for this operation. - /// - /// - /// A result which has succeeded with a null string if the member can interact with the target. - /// - /// A result which has succeeded with a non-null string containing the error message if the member cannot - /// interact with the target. - /// - /// A result which has failed if an error occurred during the execution of this method. - /// - /// - public async Task> CheckInteractionsAsync( - Snowflake guildId, Snowflake? interacterId, Snowflake targetId, string action, CancellationToken ct = default) - { - if (interacterId == targetId) - { - return Result.FromSuccess($"UserCannot{action}Themselves".Localized()); - } - - var botResult = await _userApi.GetCurrentUserAsync(ct); - if (!botResult.IsDefined(out var bot)) - { - return Result.FromError(botResult); - } - - var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct); - if (!guildResult.IsDefined(out var guild)) - { - return Result.FromError(guildResult); - } - - var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct); - if (!targetMemberResult.IsDefined(out var targetMember)) - { - return Result.FromSuccess(null); - } - - var currentMemberResult = await _guildApi.GetGuildMemberAsync(guildId, bot.ID, ct); - if (!currentMemberResult.IsDefined(out var currentMember)) - { - return Result.FromError(currentMemberResult); - } - - var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct); - if (!rolesResult.IsDefined(out var roles)) - { - return Result.FromError(rolesResult); - } - - if (interacterId is null) - { - return CheckInteractions(action, guild, roles, targetMember, currentMember, currentMember); - } - - var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId.Value, ct); - return interacterResult.IsDefined(out var interacter) - ? CheckInteractions(action, guild, roles, targetMember, currentMember, interacter) - : Result.FromError(interacterResult); - } - - private static Result CheckInteractions( - string action, IGuild guild, IReadOnlyList roles, IGuildMember targetMember, IGuildMember currentMember, - IGuildMember interacter) - { - if (!targetMember.User.IsDefined(out var targetUser)) - { - return new ArgumentNullError(nameof(targetMember.User)); - } - - if (!interacter.User.IsDefined(out var interacterUser)) - { - return new ArgumentNullError(nameof(interacter.User)); - } - - if (currentMember.User == targetMember.User) - { - return Result.FromSuccess($"UserCannot{action}Bot".Localized()); - } - - if (targetUser.ID == guild.OwnerID) - { - return Result.FromSuccess($"UserCannot{action}Owner".Localized()); - } - - var targetRoles = roles.Where(r => targetMember.Roles.Contains(r.ID)).ToList(); - var botRoles = roles.Where(r => currentMember.Roles.Contains(r.ID)); - - var targetBotRoleDiff = targetRoles.MaxOrDefault(r => r.Position) - botRoles.MaxOrDefault(r => r.Position); - if (targetBotRoleDiff >= 0) - { - return Result.FromSuccess($"BotCannot{action}Target".Localized()); - } - - if (interacterUser.ID == guild.OwnerID) - { - return Result.FromSuccess(null); - } - - var interacterRoles = roles.Where(r => interacter.Roles.Contains(r.ID)); - var targetInteracterRoleDiff - = targetRoles.MaxOrDefault(r => r.Position) - interacterRoles.MaxOrDefault(r => r.Position); - return targetInteracterRoleDiff < 0 - ? Result.FromSuccess(null) - : Result.FromSuccess($"UserCannot{action}Target".Localized()); } ///