diff --git a/locale/Messages.resx b/locale/Messages.resx index c8ef510..49103c8 100644 --- a/locale/Messages.resx +++ b/locale/Messages.resx @@ -660,4 +660,34 @@ Welcome messages channel + + {0} received a warning + + + {0} no longer has warnings + + + You have been warned + + + Your warnings have been revoked + + + Warns: {0} + + + This user has no warnings! + + + Received too many warnings + + + Punishment type for warnings + + + Warnings threshold + + + Punishment duration for warnings + diff --git a/locale/Messages.ru.resx b/locale/Messages.ru.resx index eb8e57b..3933a1a 100644 --- a/locale/Messages.ru.resx +++ b/locale/Messages.ru.resx @@ -660,4 +660,34 @@ Канал для приветствий + + {0} получил предупреждение + + + {0} больше не имеет предупреждений + + + Вы получили предупреждение + + + Ваши предупреждения были отозваны + + + Предупреждений: {0} + + + Этот пользователь не имеет предупреждений! + + + Получил слишком много предупреждений + + + Тип наказания для предупреждений + + + Порог предупреждений + + + Длительность наказания для предупреждений + diff --git a/locale/Messages.tt-ru.resx b/locale/Messages.tt-ru.resx index df50d8d..b108287 100644 --- a/locale/Messages.tt-ru.resx +++ b/locale/Messages.tt-ru.resx @@ -660,4 +660,34 @@ канал куда говорить здравствуйте + + {0} схлопотал варн + + + {0} получил амнистию + + + вы схлопотали варн + + + вы получили амнистию + + + варнов: {0} + + + его еще никто ни в чем не обвинял + + + схлопотал много варнов + + + сколько времени держать варновый бан + + + тип варнового бана + + + сколько варнов чтобы потом бан + diff --git a/src/Commands/BanCommandGroup.cs b/src/Commands/BanCommandGroup.cs index c350729..6c8b004 100644 --- a/src/Commands/BanCommandGroup.cs +++ b/src/Commands/BanCommandGroup.cs @@ -127,7 +127,7 @@ public class BanCommandGroup : CommandGroup return await BanUserAsync(executor, target, reason, timeSpan, guild, data, channelId, bot, CancellationToken); } - private async Task BanUserAsync( + public async Task BanUserAsync( IUser executor, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data, Snowflake channelId, IUser bot, CancellationToken ct = default) { diff --git a/src/Commands/KickCommandGroup.cs b/src/Commands/KickCommandGroup.cs index 0faa1d3..4262487 100644 --- a/src/Commands/KickCommandGroup.cs +++ b/src/Commands/KickCommandGroup.cs @@ -110,7 +110,7 @@ public class KickCommandGroup : CommandGroup return await KickUserAsync(executor, target, reason, guild, channelId, data, bot, CancellationToken); } - private async Task KickUserAsync( + public async Task KickUserAsync( IUser executor, IUser target, string reason, IGuild guild, Snowflake channelId, GuildData data, IUser bot, CancellationToken ct = default) { diff --git a/src/Commands/MuteCommandGroup.cs b/src/Commands/MuteCommandGroup.cs index c2542e8..43eddf1 100644 --- a/src/Commands/MuteCommandGroup.cs +++ b/src/Commands/MuteCommandGroup.cs @@ -121,7 +121,7 @@ public class MuteCommandGroup : CommandGroup return await MuteUserAsync(executor, target, reason, duration, guildId, data, channelId, bot, CancellationToken); } - private async Task MuteUserAsync( + public async Task MuteUserAsync( IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data, Snowflake channelId, IUser bot, CancellationToken ct = default) { diff --git a/src/Commands/SettingsCommandGroup.cs b/src/Commands/SettingsCommandGroup.cs index 86f031f..9dd9e04 100644 --- a/src/Commands/SettingsCommandGroup.cs +++ b/src/Commands/SettingsCommandGroup.cs @@ -38,12 +38,14 @@ public class SettingsCommandGroup : CommandGroup private static readonly IOption[] AllOptions = [ GuildSettings.Language, + GuildSettings.WarnPunishment, GuildSettings.WelcomeMessage, GuildSettings.ReceiveStartupMessages, GuildSettings.RemoveRolesOnMute, GuildSettings.ReturnRolesOnRejoin, GuildSettings.AutoStartEvents, GuildSettings.RenameHoistedUsers, + GuildSettings.WarnsThreshold, GuildSettings.PublicFeedbackChannel, GuildSettings.PrivateFeedbackChannel, GuildSettings.WelcomeMessagesChannel, @@ -51,7 +53,8 @@ public class SettingsCommandGroup : CommandGroup GuildSettings.DefaultRole, GuildSettings.MuteRole, GuildSettings.EventNotificationRole, - GuildSettings.EventEarlyNotificationOffset + GuildSettings.EventEarlyNotificationOffset, + GuildSettings.WarnPunishmentDuration ]; private readonly ICommandContext _context; diff --git a/src/Commands/WarnCommandGroup.cs b/src/Commands/WarnCommandGroup.cs new file mode 100644 index 0000000..a712857 --- /dev/null +++ b/src/Commands/WarnCommandGroup.cs @@ -0,0 +1,253 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text; +using JetBrains.Annotations; +using Octobot.Data; +using Octobot.Extensions; +using Octobot.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Rest.Core; +using Remora.Results; + +namespace Octobot.Commands; + +[UsedImplicitly] +public class WarnCommandGroup : CommandGroup +{ + private readonly IDiscordRestChannelAPI _channelApi; + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + private readonly Utility _utility; + + public WarnCommandGroup( + ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService guildData, + IFeedbackService feedback, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, + Utility utility) + { + _context = context; + _channelApi = channelApi; + _guildData = guildData; + _feedback = feedback; + _guildApi = guildApi; + _userApi = userApi; + _utility = utility; + } + + [Command("warn")] + [DiscordDefaultMemberPermissions(DiscordPermission.KickMembers)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.KickMembers)] + [RequireBotDiscordPermissions(DiscordPermission.KickMembers)] + [Description("Warn user")] + [UsedImplicitly] + public async Task ExecuteWarnAsync( + [Description("User to warn")] IUser target, + [Description("Warn reason")] [MaxLength(256)] + string reason) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return Result.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return Result.FromError(executorResult); + } + + var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); + if (!guildResult.IsDefined(out var guild)) + { + return Result.FromError(guildResult); + } + + var data = await _guildData.GetData(guild.ID, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await WarnUserAsync(executor, target, reason, guild, data, channelId, bot, CancellationToken); + } + + private async Task WarnUserAsync(IUser executor, IUser target, string reason, IGuild guild, + GuildData data, Snowflake channelId, IUser bot, CancellationToken ct = default) + { + var memberData = data.GetOrCreateMemberData(target.ID); + memberData.Warns++; + + var warnsThreshold = GuildSettings.WarnsThreshold.Get(data.Settings); + + var builder = new StringBuilder() + .AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)) + .AppendBulletPointLine(string.Format(Messages.DescriptionActionWarns, + warnsThreshold is 0 ? memberData.Warns : $"{memberData.Warns}/{warnsThreshold}")); + + var title = string.Format(Messages.UserWarned, target.GetTag()); + var description = builder.ToString(); + + var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct); + if (dmChannelResult.IsDefined(out var dmChannel)) + { + var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) + .WithTitle(Messages.YouHaveBeenWarned) + .WithDescription(description) + .WithActionFooter(executor) + .WithCurrentTimestamp() + .WithColour(ColorsList.Yellow) + .Build(); + + await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct); + } + + _utility.LogAction( + data.Settings, channelId, executor, title, description, target, ColorsList.Yellow, false, ct); + + if (memberData.Warns >= warnsThreshold && + GuildSettings.WarnPunishment.Get(data.Settings) is not "off" and not "disable" and not "disabled") + { + memberData.Warns = 0; + return await PunishUserAsync(target, guild, data, channelId, bot, CancellationToken); + } + + var embed = new EmbedBuilder().WithSmallTitle( + title, target) + .WithColour(ColorsList.Green).Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + private async Task PunishUserAsync(IUser target, IGuild guild, + GuildData data, Snowflake channelId, IUser bot, CancellationToken ct) + { + var settings = data.Settings; + var duration = GuildSettings.WarnPunishmentDuration.Get(settings); + + if (GuildSettings.WarnPunishment.Get(settings) is "ban" + && duration != TimeSpan.Zero) + { + var banCommandGroup = new BanCommandGroup(_context, _channelApi, _guildData, _feedback, _guildApi, _userApi, _utility); + await banCommandGroup.BanUserAsync(bot, target, Messages.ReceivedTooManyWarnings, + duration, guild, data, channelId, bot, ct); + } + + if (GuildSettings.WarnPunishment.Get(settings) is "kick") + { + var kickCommandGroup = new KickCommandGroup(_context, _channelApi, _guildData, _feedback, _guildApi, _userApi, _utility); + await kickCommandGroup.KickUserAsync(bot, target, Messages.ReceivedTooManyWarnings, + guild, channelId, data, bot, ct); + } + + if (GuildSettings.WarnPunishment.Get(settings) is "mute" + && duration != TimeSpan.Zero) + { + var muteCommandGroup = new MuteCommandGroup(_context, _guildData, _feedback, _guildApi, _userApi, _utility); + await muteCommandGroup.MuteUserAsync(bot, target, Messages.ReceivedTooManyWarnings, + duration, guild.ID, data, channelId, bot, ct); + } + + return Result.FromSuccess(); + } + + [Command("unwarn")] + [DiscordDefaultMemberPermissions(DiscordPermission.KickMembers)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.KickMembers)] + [RequireBotDiscordPermissions(DiscordPermission.KickMembers)] + [Description("Remove warns from user")] + [UsedImplicitly] + public async Task ExecuteUnwarnAsync( + [Description("User to remove warns from")] + IUser target, + [Description("Warns remove reason")] [MaxLength(256)] + string reason) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return Result.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return Result.FromError(executorResult); + } + + var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); + if (!guildResult.IsDefined(out var guild)) + { + return Result.FromError(guildResult); + } + + var data = await _guildData.GetData(guild.ID, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await RemoveUserWarnsAsync(executor, target, reason, guild, data, channelId, bot, CancellationToken); + } + + private async Task RemoveUserWarnsAsync(IUser executor, IUser target, string reason, IGuild guild, + GuildData data, Snowflake channelId, IUser bot, CancellationToken ct = default) + { + var memberData = data.GetOrCreateMemberData(target.ID); + if (memberData.Warns is 0) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserHasNoWarnings, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + memberData.Warns = 0; + + var builder = new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)); + + var title = string.Format(Messages.UserWarnsRemoved, target.GetTag()); + var description = builder.ToString(); + + var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct); + if (dmChannelResult.IsDefined(out var dmChannel)) + { + var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) + .WithTitle(Messages.YourWarningsHaveBeenRevoked) + .WithDescription(description) + .WithActionFooter(executor) + .WithCurrentTimestamp() + .WithColour(ColorsList.Green) + .Build(); + + await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct); + } + + var embed = new EmbedBuilder().WithSmallTitle( + title, target) + .WithColour(ColorsList.Green).Build(); + + _utility.LogAction( + data.Settings, channelId, executor, title, description, target, ColorsList.Yellow, false, ct); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } +} diff --git a/src/Data/GuildSettings.cs b/src/Data/GuildSettings.cs index 5a99505..4e46be4 100644 --- a/src/Data/GuildSettings.cs +++ b/src/Data/GuildSettings.cs @@ -12,6 +12,8 @@ public static class GuildSettings { public static readonly LanguageOption Language = new("Language", "en"); + public static readonly Option WarnPunishment = new("WarnPunishment", "disabled"); + /// /// Controls what message should be sent in when a new member joins the server. /// @@ -46,6 +48,8 @@ public static class GuildSettings /// public static readonly BoolOption RenameHoistedUsers = new("RenameHoistedUsers", false); + public static readonly IntOption WarnsThreshold = new("WarnsThreshold", 0); + /// /// Controls what channel should all public messages be sent to. /// @@ -71,4 +75,7 @@ public static class GuildSettings /// public static readonly TimeSpanOption EventEarlyNotificationOffset = new( "EventEarlyNotificationOffset", TimeSpan.Zero); + + public static readonly TimeSpanOption WarnPunishmentDuration = new( + "WarnPunishmentDuration", TimeSpan.Zero); } diff --git a/src/Data/MemberData.cs b/src/Data/MemberData.cs index 8e23e54..2701be7 100644 --- a/src/Data/MemberData.cs +++ b/src/Data/MemberData.cs @@ -18,6 +18,7 @@ public sealed class MemberData public ulong Id { get; } public DateTimeOffset? BannedUntil { get; set; } public DateTimeOffset? MutedUntil { get; set; } + public int Warns { get; set; } public bool Kicked { get; set; } public List Roles { get; set; } = []; public List Reminders { get; } = []; diff --git a/src/Data/Options/AllOptionsEnum.cs b/src/Data/Options/AllOptionsEnum.cs index e9637d6..f3ef3eb 100644 --- a/src/Data/Options/AllOptionsEnum.cs +++ b/src/Data/Options/AllOptionsEnum.cs @@ -13,12 +13,14 @@ namespace Octobot.Data.Options; public enum AllOptionsEnum { [UsedImplicitly] Language, + [UsedImplicitly] WarnPunishment, [UsedImplicitly] WelcomeMessage, [UsedImplicitly] ReceiveStartupMessages, [UsedImplicitly] RemoveRolesOnMute, [UsedImplicitly] ReturnRolesOnRejoin, [UsedImplicitly] AutoStartEvents, [UsedImplicitly] RenameHoistedUsers, + [UsedImplicitly] WarnsThreshold, [UsedImplicitly] PublicFeedbackChannel, [UsedImplicitly] PrivateFeedbackChannel, [UsedImplicitly] WelcomeMessagesChannel, @@ -26,5 +28,6 @@ public enum AllOptionsEnum [UsedImplicitly] DefaultRole, [UsedImplicitly] MuteRole, [UsedImplicitly] EventNotificationRole, - [UsedImplicitly] EventEarlyNotificationOffset + [UsedImplicitly] EventEarlyNotificationOffset, + [UsedImplicitly] WarnPunishmentDuration } diff --git a/src/Data/Options/IntOption.cs b/src/Data/Options/IntOption.cs new file mode 100644 index 0000000..62b2883 --- /dev/null +++ b/src/Data/Options/IntOption.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Nodes; +using Remora.Results; + +namespace Octobot.Data.Options; + +public sealed class IntOption : Option +{ + public IntOption(string name, int defaultValue) : base(name, defaultValue) { } + + public override string Display(JsonNode settings) + { + return settings[Name]?.GetValue() ?? "0"; + } + + public override Result Set(JsonNode settings, string from) + { + if (!int.TryParse(from, out _)) + { + return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue); + } + + settings[Name] = from; + return Result.FromSuccess(); + } + + public override int Get(JsonNode settings) + { + var property = settings[Name]; + return property != null ? Convert.ToInt32(property.GetValue()) : DefaultValue; + } +} diff --git a/src/Messages.Designer.cs b/src/Messages.Designer.cs index 5ad741e..3d3c0d8 100644 --- a/src/Messages.Designer.cs +++ b/src/Messages.Designer.cs @@ -1191,5 +1191,54 @@ namespace Octobot { return ResourceManager.GetString("SettingsWelcomeMessagesChannel", resourceCulture); } } + + internal static string UserWarned + { + get { + return ResourceManager.GetString("UserWarned", resourceCulture); + } + } + + internal static string UserWarnsRemoved + { + get { + return ResourceManager.GetString("UserWarnsRemoved", resourceCulture); + } + } + + internal static string YouHaveBeenWarned + { + get { + return ResourceManager.GetString("YouHaveBeenWarned", resourceCulture); + } + } + + internal static string YourWarningsHaveBeenRevoked + { + get { + return ResourceManager.GetString("YourWarningsHaveBeenRevoked", resourceCulture); + } + } + + internal static string DescriptionActionWarns + { + get { + return ResourceManager.GetString("DescriptionActionWarns", resourceCulture); + } + } + + internal static string UserHasNoWarnings + { + get { + return ResourceManager.GetString("UserHasNoWarnings", resourceCulture); + } + } + + internal static string ReceivedTooManyWarnings + { + get { + return ResourceManager.GetString("ReceivedTooManyWarnings", resourceCulture); + } + } } }