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);
+ }
+ }
}
}