From 99f97f63a73198c95d5563942fce7c1b4fe78193 Mon Sep 17 00:00:00 2001 From: Toastie Date: Wed, 16 Aug 2023 01:25:10 +1200 Subject: [PATCH] Added Ellie.Bot.Modules.Administration Signed-off-by: Emotion --- .../Administration.cs | 405 +++++ .../AdministrationService.cs | 158 ++ .../AutoAssignRoleCommands.cs | 58 + .../AutoAssignRoleService.cs | 159 ++ .../AutoPublishService.cs | 87 ++ .../DangerousCommands/DangerousCommands.cs | 80 + .../DangerousCommandsService.cs | 104 ++ .../Ellie.Bot.Modules.Administration.csproj | 16 +- .../GameVoiceChannelCommands.cs | 36 + .../GameVoiceChannelService.cs | 127 ++ .../GreetBye/GreetCommands.cs | 229 +++ .../GreetBye/GreetGrouper.cs | 71 + .../GreetBye/GreetService.cs | 619 ++++++++ .../GreetBye/GreetSettings.cs | 45 + .../ImageOnlyChannelService.cs | 235 +++ .../LocalizationCommands.cs | 250 +++ .../Mute/MuteCommands.cs | 231 +++ .../Mute/MuteService.cs | 505 ++++++ .../DiscordPermOverrideCommands.cs | 80 + .../PlayingRotate/PlayingRotateCommands.cs | 60 + .../PlayingRotate/PlayingRotateService.cs | 109 ++ .../PrefixCommands.cs | 57 + .../Protection/ProtectionCommands.cs | 288 ++++ .../Protection/ProtectionService.cs | 499 ++++++ .../Protection/ProtectionStats.cs | 52 + .../Protection/PunishQueueItem.cs | 13 + .../Protection/UserSpamStats.cs | 64 + .../Prune/PruneCommands.cs | 114 ++ .../Prune/PruneService.cs | 89 ++ .../Role/IReactionRoleService.cs | 52 + .../Role/ReactionRoleCommands.cs | 171 +++ .../Role/ReactionRolesService.cs | 396 +++++ .../Role/RoleCommands.cs | 184 +++ .../Self/CheckForUpdatesService.cs | 153 ++ .../Self/SelfCommands.cs | 572 +++++++ .../Self/SelfService.cs | 399 +++++ .../SelfAssignedRolesCommands.cs | 234 +++ .../SelfAssignedRolesService.cs | 234 +++ .../ServerLog/DummyLogCommandService.cs | 25 + .../ServerLog/ServerLogCommandService.cs | 1358 +++++++++++++++++ .../ServerLog/ServerLogCommands.cs | 171 +++ .../Timezone/GuildTimezoneService.cs | 78 + .../Timezone/TimeZoneCommands.cs | 75 + .../UserPunish/UserPunishCommands.cs | 932 +++++++++++ .../UserPunish/UserPunishService.cs | 595 ++++++++ .../VcRole/VcRoleCommands.cs | 77 + .../VcRole/VcRoleService.cs | 209 +++ 47 files changed, 10754 insertions(+), 1 deletion(-) create mode 100644 src/Ellie.Bot.Modules.Administration/Administration.cs create mode 100644 src/Ellie.Bot.Modules.Administration/AdministrationService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/AutoAssignableRoles/AutoAssignRoleService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/AutoPublishService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/DangerousCommands/DangerousCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/DangerousCommands/DangerousCommandsService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/GameVoiceChannel/GameVoiceChannelCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/GameVoiceChannel/GameVoiceChannelService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/GreetBye/GreetCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/GreetBye/GreetGrouper.cs create mode 100644 src/Ellie.Bot.Modules.Administration/GreetBye/GreetService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/GreetBye/GreetSettings.cs create mode 100644 src/Ellie.Bot.Modules.Administration/ImageOnlyChannelService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/LocalizationCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Mute/MuteCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Mute/MuteService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/PermOverrides/DiscordPermOverrideCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/PlayingRotate/PlayingRotateCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/PlayingRotate/PlayingRotateService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/PrefixCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Protection/ProtectionCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Protection/ProtectionService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Protection/ProtectionStats.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Protection/PunishQueueItem.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Protection/UserSpamStats.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Prune/PruneCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Prune/PruneService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Role/IReactionRoleService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Role/ReactionRoleCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Role/ReactionRolesService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Role/RoleCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Self/CheckForUpdatesService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Self/SelfCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Self/SelfService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/SelfAssignableRoles/SelfAssignedRolesService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/ServerLog/DummyLogCommandService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/ServerLog/ServerLogCommandService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/ServerLog/ServerLogCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Timezone/GuildTimezoneService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/Timezone/TimeZoneCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/UserPunish/UserPunishCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/UserPunish/UserPunishService.cs create mode 100644 src/Ellie.Bot.Modules.Administration/VcRole/VcRoleCommands.cs create mode 100644 src/Ellie.Bot.Modules.Administration/VcRole/VcRoleService.cs diff --git a/src/Ellie.Bot.Modules.Administration/Administration.cs b/src/Ellie.Bot.Modules.Administration/Administration.cs new file mode 100644 index 0000000..5a4cc16 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Administration.cs @@ -0,0 +1,405 @@ +#nullable disable +using Ellie.Common.TypeReaders.Models; +using Ellie.Modules.Administration.Services; + +namespace Ellie.Modules.Administration; + +public partial class Administration : EllieModule +{ + public enum Channel + { + Channel, + Ch, + Chnl, + Chan + } + + public enum List + { + List = 0, + Ls = 0 + } + + public enum Server + { + Server + } + + public enum State + { + Enable, + Disable, + Inherit + } + + private readonly SomethingOnlyChannelService _somethingOnly; + private readonly AutoPublishService _autoPubService; + + public Administration(SomethingOnlyChannelService somethingOnly, AutoPublishService autoPubService) + { + _somethingOnly = somethingOnly; + _autoPubService = autoPubService; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageGuild)] + public async Task ImageOnlyChannel(StoopidTime time = null) + { + var newValue = await _somethingOnly.ToggleImageOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id); + if (newValue) + await ReplyConfirmLocalizedAsync(strs.imageonly_enable); + else + await ReplyPendingLocalizedAsync(strs.imageonly_disable); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageGuild)] + public async Task LinkOnlyChannel(StoopidTime time = null) + { + var newValue = await _somethingOnly.ToggleLinkOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id); + if (newValue) + await ReplyConfirmLocalizedAsync(strs.linkonly_enable); + else + await ReplyPendingLocalizedAsync(strs.linkonly_disable); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageChannels)] + [BotPerm(ChannelPerm.ManageChannels)] + public async Task Slowmode(StoopidTime time = null) + { + var seconds = (int?)time?.Time.TotalSeconds ?? 0; + if (time is not null && (time.Time < TimeSpan.FromSeconds(0) || time.Time > TimeSpan.FromHours(6))) + return; + + await ((ITextChannel)ctx.Channel).ModifyAsync(tcp => + { + tcp.SlowModeInterval = seconds; + }); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(2)] + public async Task Delmsgoncmd(List _) + { + var guild = (SocketGuild)ctx.Guild; + var (enabled, channels) = _service.GetDelMsgOnCmdData(ctx.Guild.Id); + + var embed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.server_delmsgoncmd)) + .WithDescription(enabled ? "✅" : "❌"); + + var str = string.Join("\n", + channels.Select(x => + { + var ch = guild.GetChannel(x.ChannelId)?.ToString() ?? x.ChannelId.ToString(); + var prefixSign = x.State ? "✅ " : "❌ "; + return prefixSign + ch; + })); + + if (string.IsNullOrWhiteSpace(str)) + str = "-"; + + embed.AddField(GetText(strs.channel_delmsgoncmd), str); + + await ctx.Channel.EmbedAsync(embed); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Delmsgoncmd(Server _ = Server.Server) + { + if (_service.ToggleDeleteMessageOnCommand(ctx.Guild.Id)) + { + _service.DeleteMessagesOnCommand.Add(ctx.Guild.Id); + await ReplyConfirmLocalizedAsync(strs.delmsg_on); + } + else + { + _service.DeleteMessagesOnCommand.TryRemove(ctx.Guild.Id); + await ReplyConfirmLocalizedAsync(strs.delmsg_off); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Delmsgoncmd(Channel _, State s, ITextChannel ch) + => Delmsgoncmd(_, s, ch.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Delmsgoncmd(Channel _, State s, ulong? chId = null) + { + var actualChId = chId ?? ctx.Channel.Id; + await _service.SetDelMsgOnCmdState(ctx.Guild.Id, actualChId, s); + + if (s == State.Disable) + await ReplyConfirmLocalizedAsync(strs.delmsg_channel_off); + else if (s == State.Enable) + await ReplyConfirmLocalizedAsync(strs.delmsg_channel_on); + else + await ReplyConfirmLocalizedAsync(strs.delmsg_channel_inherit); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.DeafenMembers)] + [BotPerm(GuildPerm.DeafenMembers)] + public async Task Deafen(params IGuildUser[] users) + { + await _service.DeafenUsers(true, users); + await ReplyConfirmLocalizedAsync(strs.deafen); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.DeafenMembers)] + [BotPerm(GuildPerm.DeafenMembers)] + public async Task UnDeafen(params IGuildUser[] users) + { + await _service.DeafenUsers(false, users); + await ReplyConfirmLocalizedAsync(strs.undeafen); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task DelVoiChanl([Leftover] IVoiceChannel voiceChannel) + { + await voiceChannel.DeleteAsync(); + await ReplyConfirmLocalizedAsync(strs.delvoich(Format.Bold(voiceChannel.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task CreatVoiChanl([Leftover] string channelName) + { + var ch = await ctx.Guild.CreateVoiceChannelAsync(channelName); + await ReplyConfirmLocalizedAsync(strs.createvoich(Format.Bold(ch.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task DelTxtChanl([Leftover] ITextChannel toDelete) + { + await toDelete.DeleteAsync(); + await ReplyConfirmLocalizedAsync(strs.deltextchan(Format.Bold(toDelete.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task CreaTxtChanl([Leftover] string channelName) + { + var txtCh = await ctx.Guild.CreateTextChannelAsync(channelName); + await ReplyConfirmLocalizedAsync(strs.createtextchan(Format.Bold(txtCh.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task SetTopic([Leftover] string topic = null) + { + var channel = (ITextChannel)ctx.Channel; + topic ??= ""; + await channel.ModifyAsync(c => c.Topic = topic); + await ReplyConfirmLocalizedAsync(strs.set_topic); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task SetChanlName([Leftover] string name) + { + var channel = (ITextChannel)ctx.Channel; + await channel.ModifyAsync(c => c.Name = name); + await ReplyConfirmLocalizedAsync(strs.set_channel_name); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task NsfwToggle() + { + var channel = (ITextChannel)ctx.Channel; + var isEnabled = channel.IsNsfw; + + await channel.ModifyAsync(c => c.IsNsfw = !isEnabled); + + if (isEnabled) + await ReplyConfirmLocalizedAsync(strs.nsfw_set_false); + else + await ReplyConfirmLocalizedAsync(strs.nsfw_set_true); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [Priority(0)] + public Task Edit(ulong messageId, [Leftover] string text) + => Edit((ITextChannel)ctx.Channel, messageId, text); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task Edit(ITextChannel channel, ulong messageId, [Leftover] string text) + { + var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel); + var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel); + if (!userPerms.Has(ChannelPermission.ManageMessages)) + { + await ReplyErrorLocalizedAsync(strs.insuf_perms_u); + return; + } + + if (!botPerms.Has(ChannelPermission.ViewChannel)) + { + await ReplyErrorLocalizedAsync(strs.insuf_perms_i); + return; + } + + await _service.EditMessage(ctx, channel, messageId, text); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + public Task Delete(ulong messageId, StoopidTime time = null) + => Delete((ITextChannel)ctx.Channel, messageId, time); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Delete(ITextChannel channel, ulong messageId, StoopidTime time = null) + => await InternalMessageAction(channel, messageId, time, msg => msg.DeleteAsync()); + + private async Task InternalMessageAction( + ITextChannel channel, + ulong messageId, + StoopidTime time, + Func func) + { + var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel); + var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel); + if (!userPerms.Has(ChannelPermission.ManageMessages)) + { + await ReplyErrorLocalizedAsync(strs.insuf_perms_u); + return; + } + + if (!botPerms.Has(ChannelPermission.ManageMessages)) + { + await ReplyErrorLocalizedAsync(strs.insuf_perms_i); + return; + } + + + var msg = await channel.GetMessageAsync(messageId); + if (msg is null) + { + await ReplyErrorLocalizedAsync(strs.msg_not_found); + return; + } + + if (time is null) + await msg.DeleteAsync(); + else if (time.Time <= TimeSpan.FromDays(7)) + { + _ = Task.Run(async () => + { + await Task.Delay(time.Time); + await msg.DeleteAsync(); + }); + } + else + { + await ReplyErrorLocalizedAsync(strs.time_too_long); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + [BotPerm(ChannelPermission.CreatePublicThreads)] + [UserPerm(ChannelPermission.CreatePublicThreads)] + public async Task ThreadCreate([Leftover] string name) + { + if (ctx.Channel is not SocketTextChannel stc) + return; + + await stc.CreateThreadAsync(name, message: ctx.Message.ReferencedMessage); + await ctx.OkAsync(); + } + + [Cmd] + [BotPerm(ChannelPermission.ManageThreads)] + [UserPerm(ChannelPermission.ManageThreads)] + public async Task ThreadDelete([Leftover] string name) + { + if (ctx.Channel is not SocketTextChannel stc) + return; + + var t = stc.Threads.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.InvariantCultureIgnoreCase)); + + if (t is null) + { + await ReplyErrorLocalizedAsync(strs.not_found); + return; + } + + await t.DeleteAsync(); + await ctx.OkAsync(); + } + + [Cmd] + [UserPerm(ChannelPerm.ManageMessages)] + public async Task AutoPublish() + { + if (ctx.Channel.GetChannelType() != ChannelType.News) + { + await ReplyErrorLocalizedAsync(strs.req_announcement_channel); + return; + } + + var newState = await _autoPubService.ToggleAutoPublish(ctx.Guild.Id, ctx.Channel.Id); + + if (newState) + { + await ReplyConfirmLocalizedAsync(strs.autopublish_enable); + } + else + { + await ReplyConfirmLocalizedAsync(strs.autopublish_disable); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/AdministrationService.cs b/src/Ellie.Bot.Modules.Administration/AdministrationService.cs new file mode 100644 index 0000000..128dad5 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/AdministrationService.cs @@ -0,0 +1,158 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration.Services; + +public class AdministrationService : IEService +{ + public ConcurrentHashSet DeleteMessagesOnCommand { get; } + public ConcurrentDictionary DeleteMessagesOnCommandChannels { get; } + + private readonly DbService _db; + private readonly ILogCommandService _logService; + + public AdministrationService( + IBot bot, + CommandHandler cmdHandler, + DbService db, + ILogCommandService logService) + { + _db = db; + _logService = logService; + + DeleteMessagesOnCommand = new(bot.AllGuildConfigs.Where(g => g.DeleteMessageOnCommand).Select(g => g.GuildId)); + + DeleteMessagesOnCommandChannels = new(bot.AllGuildConfigs.SelectMany(x => x.DelMsgOnCmdChannels) + .ToDictionary(x => x.ChannelId, x => x.State) + .ToConcurrent()); + + cmdHandler.CommandExecuted += DelMsgOnCmd_Handler; + } + + public (bool DelMsgOnCmd, IEnumerable channels) GetDelMsgOnCmdData(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels)); + + return (conf.DeleteMessageOnCommand, conf.DelMsgOnCmdChannels); + } + + private Task DelMsgOnCmd_Handler(IUserMessage msg, CommandInfo cmd) + { + if (msg.Channel is not ITextChannel channel) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + //wat ?! + if (DeleteMessagesOnCommandChannels.TryGetValue(channel.Id, out var state)) + { + if (state && cmd.Name != "prune" && cmd.Name != "pick") + { + _logService.AddDeleteIgnore(msg.Id); + try { await msg.DeleteAsync(); } + catch { } + } + //if state is false, that means do not do it + } + else if (DeleteMessagesOnCommand.Contains(channel.Guild.Id) && cmd.Name != "prune" && cmd.Name != "pick") + { + _logService.AddDeleteIgnore(msg.Id); + try { await msg.DeleteAsync(); } + catch { } + } + }); + return Task.CompletedTask; + } + + public bool ToggleDeleteMessageOnCommand(ulong guildId) + { + bool enabled; + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + enabled = conf.DeleteMessageOnCommand = !conf.DeleteMessageOnCommand; + + uow.SaveChanges(); + return enabled; + } + + public async Task SetDelMsgOnCmdState(ulong guildId, ulong chId, Administration.State newState) + { + await using (var uow = _db.GetDbContext()) + { + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels)); + + var old = conf.DelMsgOnCmdChannels.FirstOrDefault(x => x.ChannelId == chId); + if (newState == Administration.State.Inherit) + { + if (old is not null) + { + conf.DelMsgOnCmdChannels.Remove(old); + uow.Remove(old); + } + } + else + { + if (old is null) + { + old = new() + { + ChannelId = chId + }; + conf.DelMsgOnCmdChannels.Add(old); + } + + old.State = newState == Administration.State.Enable; + DeleteMessagesOnCommandChannels[chId] = newState == Administration.State.Enable; + } + + await uow.SaveChangesAsync(); + } + + if (newState == Administration.State.Disable) + { + } + else if (newState == Administration.State.Enable) + DeleteMessagesOnCommandChannels[chId] = true; + else + DeleteMessagesOnCommandChannels.TryRemove(chId, out _); + } + + public async Task DeafenUsers(bool value, params IGuildUser[] users) + { + if (!users.Any()) + return; + foreach (var u in users) + { + try + { + await u.ModifyAsync(usr => usr.Deaf = value); + } + catch + { + // ignored + } + } + } + + public async Task EditMessage( + ICommandContext context, + ITextChannel chanl, + ulong messageId, + string input) + { + var msg = await chanl.GetMessageAsync(messageId); + + if (msg is not IUserMessage umsg || msg.Author.Id != context.Client.CurrentUser.Id) + return; + + var rep = new ReplacementBuilder().WithDefault(context).Build(); + + var text = SmartText.CreateFrom(input); + text = rep.Replace(text); + + await umsg.EditAsync(text); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs b/src/Ellie.Bot.Modules.Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs new file mode 100644 index 0000000..57fda4d --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs @@ -0,0 +1,58 @@ +#nullable disable +using Ellie.Modules.Administration.Services; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class AutoAssignRoleCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task AutoAssignRole([Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (role.Id == ctx.Guild.EveryoneRole.Id) + return; + + // the user can't aar the role which is higher or equal to his highest role + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + { + await ReplyErrorLocalizedAsync(strs.hierarchy); + return; + } + + var roles = await _service.ToggleAarAsync(ctx.Guild.Id, role.Id); + if (roles.Count == 0) + await ReplyConfirmLocalizedAsync(strs.aar_disabled); + else if (roles.Contains(role.Id)) + await AutoAssignRole(); + else + await ReplyConfirmLocalizedAsync(strs.aar_role_removed(Format.Bold(role.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task AutoAssignRole() + { + if (!_service.TryGetRoles(ctx.Guild.Id, out var roles)) + { + await ReplyConfirmLocalizedAsync(strs.aar_none); + return; + } + + var existing = roles.Select(rid => ctx.Guild.GetRole(rid)).Where(r => r is not null).ToList(); + + if (existing.Count != roles.Count) + await _service.SetAarRolesAsync(ctx.Guild.Id, existing.Select(x => x.Id)); + + await ReplyConfirmLocalizedAsync(strs.aar_roles( + '\n' + existing.Select(x => Format.Bold(x.ToString())).Join(",\n"))); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/AutoAssignableRoles/AutoAssignRoleService.cs b/src/Ellie.Bot.Modules.Administration/AutoAssignableRoles/AutoAssignRoleService.cs new file mode 100644 index 0000000..3435815 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/AutoAssignableRoles/AutoAssignRoleService.cs @@ -0,0 +1,159 @@ +#nullable disable +using Ellie.Services.Database.Models; +using System.Net; +using System.Threading.Channels; +using LinqToDB; +using Microsoft.EntityFrameworkCore; +using Ellie.Db; + +namespace Ellie.Modules.Administration.Services; + +public sealed class AutoAssignRoleService : IEService +{ + private readonly DiscordSocketClient _client; + private readonly DbService _db; + + //guildid/roleid + private readonly ConcurrentDictionary> _autoAssignableRoles; + + private readonly Channel _assignQueue = Channel.CreateBounded( + new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + public AutoAssignRoleService(DiscordSocketClient client, IBot bot, DbService db) + { + _client = client; + _db = db; + + _autoAssignableRoles = bot.AllGuildConfigs.Where(x => !string.IsNullOrWhiteSpace(x.AutoAssignRoleIds)) + .ToDictionary>(k => k.GuildId, + v => v.GetAutoAssignableRoles()) + .ToConcurrent(); + + _ = Task.Run(async () => + { + while (true) + { + var user = await _assignQueue.Reader.ReadAsync(); + if (!_autoAssignableRoles.TryGetValue(user.Guild.Id, out var savedRoleIds)) + continue; + + try + { + var roleIds = savedRoleIds.Select(roleId => user.Guild.GetRole(roleId)) + .Where(x => x is not null) + .ToList(); + + if (roleIds.Any()) + { + await user.AddRolesAsync(roleIds); + await Task.Delay(250); + } + else + { + Log.Warning( + "Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server the roles dont exist", + user.Guild.Name, + user.Guild.Id); + + await DisableAarAsync(user.Guild.Id); + } + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + Log.Warning( + "Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server because I don't have role management permissions", + user.Guild.Name, + user.Guild.Id); + + await DisableAarAsync(user.Guild.Id); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in aar. Probably one of the roles doesn't exist"); + } + } + }); + + _client.UserJoined += OnClientOnUserJoined; + _client.RoleDeleted += OnClientRoleDeleted; + } + + private async Task OnClientRoleDeleted(SocketRole role) + { + if (_autoAssignableRoles.TryGetValue(role.Guild.Id, out var roles) && roles.Contains(role.Id)) + await ToggleAarAsync(role.Guild.Id, role.Id); + } + + private async Task OnClientOnUserJoined(SocketGuildUser user) + { + if (_autoAssignableRoles.TryGetValue(user.Guild.Id, out _)) + await _assignQueue.Writer.WriteAsync(user); + } + + public async Task> ToggleAarAsync(ulong guildId, ulong roleId) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + var roles = gc.GetAutoAssignableRoles(); + if (!roles.Remove(roleId) && roles.Count < 3) + roles.Add(roleId); + + gc.SetAutoAssignableRoles(roles); + await uow.SaveChangesAsync(); + + if (roles.Count > 0) + _autoAssignableRoles[guildId] = roles; + else + _autoAssignableRoles.TryRemove(guildId, out _); + + return roles; + } + + public async Task DisableAarAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + + await uow.Set().AsNoTracking() + .Where(x => x.GuildId == guildId) + .UpdateAsync(_ => new() + { + AutoAssignRoleIds = null + }); + + _autoAssignableRoles.TryRemove(guildId, out _); + + await uow.SaveChangesAsync(); + } + + public async Task SetAarRolesAsync(ulong guildId, IEnumerable newRoles) + { + await using var uow = _db.GetDbContext(); + + var gc = uow.GuildConfigsForId(guildId, set => set); + gc.SetAutoAssignableRoles(newRoles); + + await uow.SaveChangesAsync(); + } + + public bool TryGetRoles(ulong guildId, out IReadOnlyList roles) + => _autoAssignableRoles.TryGetValue(guildId, out roles); +} + +public static class GuildConfigExtensions +{ + public static List GetAutoAssignableRoles(this GuildConfig gc) + { + if (string.IsNullOrWhiteSpace(gc.AutoAssignRoleIds)) + return new(); + + return gc.AutoAssignRoleIds.Split(',').Select(ulong.Parse).ToList(); + } + + public static void SetAutoAssignableRoles(this GuildConfig gc, IEnumerable roles) + => gc.AutoAssignRoleIds = roles.Join(','); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/AutoPublishService.cs b/src/Ellie.Bot.Modules.Administration/AutoPublishService.cs new file mode 100644 index 0000000..8c628fd --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/AutoPublishService.cs @@ -0,0 +1,87 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db.Models; + +namespace Ellie.Modules.Administration.Services; + +public class AutoPublishService : IExecNoCommand, IReadyExecutor, IEService +{ + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IBotCredsProvider _creds; + private ConcurrentDictionary _enabled; + + public AutoPublishService(DbService db, DiscordSocketClient client, IBotCredsProvider creds) + { + _db = db; + _client = client; + _creds = creds; + } + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (guild is null) + return; + + if (msg.Channel.GetChannelType() != ChannelType.News) + return; + + if (!_enabled.TryGetValue(guild.Id, out var cid) || cid != msg.Channel.Id) + return; + + await msg.CrosspostAsync(new RequestOptions() + { + RetryMode = RetryMode.AlwaysFail + }); + } + + public async Task OnReadyAsync() + { + var creds = _creds.GetCreds(); + + await using var ctx = _db.GetDbContext(); + var items = await ctx.GetTable() + .Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, creds.TotalShards, _client.ShardId)) + .ToListAsyncLinqToDB(); + + _enabled = items + .ToDictionary(x => x.GuildId, x => x.ChannelId) + .ToConcurrent(); + } + + public async Task ToggleAutoPublish(ulong guildId, ulong channelId) + { + await using var ctx = _db.GetDbContext(); + var deleted = await ctx.GetTable() + .DeleteAsync(x => x.GuildId == guildId && x.ChannelId == channelId); + + if (deleted != 0) + { + _enabled.TryRemove(guildId, out _); + return false; + } + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guildId, + ChannelId = channelId, + DateAdded = DateTime.UtcNow, + }, + old => new() + { + ChannelId = channelId, + DateAdded = DateTime.UtcNow, + }, + () => new() + { + GuildId = guildId + }); + + _enabled[guildId] = channelId; + + return true; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/DangerousCommands/DangerousCommands.cs b/src/Ellie.Bot.Modules.Administration/DangerousCommands/DangerousCommands.cs new file mode 100644 index 0000000..4263be2 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/DangerousCommands/DangerousCommands.cs @@ -0,0 +1,80 @@ +#nullable disable +using Ellie.Modules.Administration.Services; + +#if !GLOBAL_ELLIE +namespace Ellie.Modules.Administration +{ + public partial class Administration + { + [Group] + [OwnerOnly] + public partial class DangerousCommands : EllieModule + { + [Cmd] + [OwnerOnly] + public Task SqlSelect([Leftover] string sql) + { + var result = _service.SelectSql(sql); + + return ctx.SendPaginatedConfirmAsync(0, + cur => + { + var items = result.Results.Skip(cur * 20).Take(20).ToList(); + + if (!items.Any()) + return _eb.Create().WithErrorColor().WithFooter(sql).WithDescription("-"); + + return _eb.Create() + .WithOkColor() + .WithFooter(sql) + .WithTitle(string.Join(" ║ ", result.ColumnNames)) + .WithDescription(string.Join('\n', items.Select(x => string.Join(" ║ ", x)))); + }, + result.Results.Count, + 20); + } + + [Cmd] + [OwnerOnly] + public async Task SqlExec([Leftover] string sql) + { + try + { + var embed = _eb.Create() + .WithTitle(GetText(strs.sql_confirm_exec)) + .WithDescription(Format.Code(sql)); + + if (!await PromptUserConfirmAsync(embed)) + return; + + var res = await _service.ExecuteSql(sql); + await SendConfirmAsync(res.ToString()); + } + catch (Exception ex) + { + await SendErrorAsync(ex.ToString()); + } + } + + [Cmd] + [OwnerOnly] + public async Task PurgeUser(ulong userId) + { + var embed = _eb.Create() + .WithDescription(GetText(strs.purge_user_confirm(Format.Bold(userId.ToString())))); + + if (!await PromptUserConfirmAsync(embed)) + return; + + await _service.PurgeUserAsync(userId); + await ctx.OkAsync(); + } + + [Cmd] + [OwnerOnly] + public Task PurgeUser([Leftover] IUser user) + => PurgeUser(user.Id); + } + } +} +#endif \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/DangerousCommands/DangerousCommandsService.cs b/src/Ellie.Bot.Modules.Administration/DangerousCommands/DangerousCommandsService.cs new file mode 100644 index 0000000..57b64b3 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/DangerousCommands/DangerousCommandsService.cs @@ -0,0 +1,104 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Ellie.Db.Models; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration.Services; + +public class DangerousCommandsService : IEService +{ + private readonly DbService _db; + + public DangerousCommandsService(DbService db) + => _db = db; + + public async Task ExecuteSql(string sql) + { + int res; + await using var uow = _db.GetDbContext(); + res = await uow.Database.ExecuteSqlRawAsync(sql); + return res; + } + + public SelectResult SelectSql(string sql) + { + var result = new SelectResult + { + ColumnNames = new(), + Results = new() + }; + + using var uow = _db.GetDbContext(); + var conn = uow.Database.GetDbConnection(); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + using var reader = cmd.ExecuteReader(); + if (reader.HasRows) + { + for (var i = 0; i < reader.FieldCount; i++) + result.ColumnNames.Add(reader.GetName(i)); + while (reader.Read()) + { + var obj = new object[reader.FieldCount]; + reader.GetValues(obj); + result.Results.Add(obj.Select(x => x.ToString()).ToArray()); + } + } + + return result; + } + + public async Task PurgeUserAsync(ulong userId) + { + await using var uow = _db.GetDbContext(); + + // get waifu info + var wi = await uow.Set().FirstOrDefaultAsyncEF(x => x.Waifu.UserId == userId); + + // if it exists, delete waifu related things + if (wi is not null) + { + // remove updates which have new or old as this waifu + await uow.Set().DeleteAsync(wu => wu.New.UserId == userId || wu.Old.UserId == userId); + + // delete all items this waifu owns + await uow.Set().DeleteAsync(x => x.WaifuInfoId == wi.Id); + + // all waifus this waifu claims are released + await uow.Set() + .AsQueryable() + .Where(x => x.Claimer.UserId == userId) + .UpdateAsync(x => new() + { + ClaimerId = null + }); + + // all affinities set to this waifu are reset + await uow.Set() + .AsQueryable() + .Where(x => x.Affinity.UserId == userId) + .UpdateAsync(x => new() + { + AffinityId = null + }); + } + + // delete guild xp + await uow.Set().DeleteAsync(x => x.UserId == userId); + + // delete currency transactions + await uow.Set().DeleteAsync(x => x.UserId == userId); + + // delete user, currency, and clubs go away with it + await uow.Set().DeleteAsync(u => u.UserId == userId); + } + + public class SelectResult + { + public List ColumnNames { get; set; } + public List Results { get; set; } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Ellie.Bot.Modules.Administration.csproj b/src/Ellie.Bot.Modules.Administration/Ellie.Bot.Modules.Administration.csproj index f02677b..3a33ab6 100644 --- a/src/Ellie.Bot.Modules.Administration/Ellie.Bot.Modules.Administration.csproj +++ b/src/Ellie.Bot.Modules.Administration/Ellie.Bot.Modules.Administration.csproj @@ -1,10 +1,24 @@ - Exe net7.0 enable enable + + + + + + + + + + + + + + + diff --git a/src/Ellie.Bot.Modules.Administration/GameVoiceChannel/GameVoiceChannelCommands.cs b/src/Ellie.Bot.Modules.Administration/GameVoiceChannel/GameVoiceChannelCommands.cs new file mode 100644 index 0000000..0171a3f --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/GameVoiceChannel/GameVoiceChannelCommands.cs @@ -0,0 +1,36 @@ +#nullable disable +using Ellie.Modules.Administration.Services; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class GameVoiceChannelCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.MoveMembers)] + public async Task GameVoiceChannel() + { + var vch = ((IGuildUser)ctx.User).VoiceChannel; + + if (vch is null) + { + await ReplyErrorLocalizedAsync(strs.not_in_voice); + return; + } + + var id = _service.ToggleGameVoiceChannel(ctx.Guild.Id, vch.Id); + + if (id is null) + await ReplyConfirmLocalizedAsync(strs.gvc_disabled); + else + { + _service.GameVoiceChannels.Add(vch.Id); + await ReplyConfirmLocalizedAsync(strs.gvc_enabled(Format.Bold(vch.Name))); + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/GameVoiceChannel/GameVoiceChannelService.cs b/src/Ellie.Bot.Modules.Administration/GameVoiceChannel/GameVoiceChannelService.cs new file mode 100644 index 0000000..5f4d415 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/GameVoiceChannel/GameVoiceChannelService.cs @@ -0,0 +1,127 @@ +#nullable disable +using Ellie.Db; + +namespace Ellie.Modules.Administration.Services; + +public class GameVoiceChannelService : IEService +{ + public ConcurrentHashSet GameVoiceChannels { get; } + + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public GameVoiceChannelService(DiscordSocketClient client, DbService db, IBot bot) + { + _db = db; + _client = client; + + GameVoiceChannels = new(bot.AllGuildConfigs + .Where(gc => gc.GameVoiceChannel is not null) + .Select(gc => gc.GameVoiceChannel!.Value)); + + _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated; + _client.PresenceUpdated += OnPresenceUpdate; + } + + private Task OnPresenceUpdate(SocketUser socketUser, SocketPresence before, SocketPresence after) + { + _ = Task.Run(async () => + { + try + { + if (socketUser is not SocketGuildUser newUser) + return; + // if the user is in the voice channel and that voice channel is gvc + + if (newUser.VoiceChannel is not { } vc + || !GameVoiceChannels.Contains(vc.Id)) + return; + + //if the activity has changed, and is a playi1ng activity + foreach (var activity in after.Activities) + { + if (activity is { Type: ActivityType.Playing }) + //trigger gvc + { + if (await TriggerGvc(newUser, activity.Name)) + return; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error running GuildMemberUpdated in gvc"); + } + }); + return Task.CompletedTask; + } + + public ulong? ToggleGameVoiceChannel(ulong guildId, ulong vchId) + { + ulong? id; + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + + if (gc.GameVoiceChannel == vchId) + { + GameVoiceChannels.TryRemove(vchId); + id = gc.GameVoiceChannel = null; + } + else + { + if (gc.GameVoiceChannel is not null) + GameVoiceChannels.TryRemove(gc.GameVoiceChannel.Value); + GameVoiceChannels.Add(vchId); + id = gc.GameVoiceChannel = vchId; + } + + uow.SaveChanges(); + return id; + } + + private Task OnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState) + { + _ = Task.Run(async () => + { + try + { + if (usr is not SocketGuildUser gUser) + return; + + if (newState.VoiceChannel is null) + return; + + if (!GameVoiceChannels.Contains(newState.VoiceChannel.Id)) + return; + + foreach (var game in gUser.Activities.Select(x => x.Name)) + { + if (await TriggerGvc(gUser, game)) + return; + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error running VoiceStateUpdate in gvc"); + } + }); + + return Task.CompletedTask; + } + + private async Task TriggerGvc(SocketGuildUser gUser, string game) + { + if (string.IsNullOrWhiteSpace(game)) + return false; + + game = game.TrimTo(50)!.ToLowerInvariant(); + var vch = gUser.Guild.VoiceChannels.FirstOrDefault(x => x.Name.ToLowerInvariant() == game); + + if (vch is null) + return false; + + await Task.Delay(1000); + await gUser.ModifyAsync(gu => gu.Channel = vch); + return true; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/GreetBye/GreetCommands.cs b/src/Ellie.Bot.Modules.Administration/GreetBye/GreetCommands.cs new file mode 100644 index 0000000..6eec1b2 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/GreetBye/GreetCommands.cs @@ -0,0 +1,229 @@ +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class GreetCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task Boost() + { + var enabled = await _service.ToggleBoost(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await ReplyConfirmLocalizedAsync(strs.boost_on); + else + await ReplyPendingLocalizedAsync(strs.boost_off); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task BoostDel(int timer = 30) + { + if (timer is < 0 or > 600) + return; + + await _service.SetBoostDel(ctx.Guild.Id, timer); + + if (timer > 0) + await ReplyConfirmLocalizedAsync(strs.boostdel_on(timer)); + else + await ReplyPendingLocalizedAsync(strs.boostdel_off); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task BoostMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var boostMessage = _service.GetBoostMessage(ctx.Guild.Id); + await ReplyConfirmLocalizedAsync(strs.boostmsg_cur(boostMessage?.SanitizeMentions())); + return; + } + + var sendBoostEnabled = _service.SetBoostMessage(ctx.Guild.Id, ref text); + + await ReplyConfirmLocalizedAsync(strs.boostmsg_new); + if (!sendBoostEnabled) + await ReplyPendingLocalizedAsync(strs.boostmsg_enable($"`{prefix}boost`")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetDel(int timer = 30) + { + if (timer is < 0 or > 600) + return; + + await _service.SetGreetDel(ctx.Guild.Id, timer); + + if (timer > 0) + await ReplyConfirmLocalizedAsync(strs.greetdel_on(timer)); + else + await ReplyPendingLocalizedAsync(strs.greetdel_off); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task Greet() + { + var enabled = await _service.SetGreet(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await ReplyConfirmLocalizedAsync(strs.greet_on); + else + await ReplyPendingLocalizedAsync(strs.greet_off); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var greetMsg = _service.GetGreetMsg(ctx.Guild.Id); + await ReplyConfirmLocalizedAsync(strs.greetmsg_cur(greetMsg?.SanitizeMentions())); + return; + } + + var sendGreetEnabled = _service.SetGreetMessage(ctx.Guild.Id, ref text); + + await ReplyConfirmLocalizedAsync(strs.greetmsg_new); + + if (!sendGreetEnabled) + await ReplyPendingLocalizedAsync(strs.greetmsg_enable($"`{prefix}greet`")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetDm() + { + var enabled = await _service.SetGreetDm(ctx.Guild.Id); + + if (enabled) + await ReplyConfirmLocalizedAsync(strs.greetdm_on); + else + await ReplyConfirmLocalizedAsync(strs.greetdm_off); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetDmMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var dmGreetMsg = _service.GetDmGreetMsg(ctx.Guild.Id); + await ReplyConfirmLocalizedAsync(strs.greetdmmsg_cur(dmGreetMsg?.SanitizeMentions())); + return; + } + + var sendGreetEnabled = _service.SetGreetDmMessage(ctx.Guild.Id, ref text); + + await ReplyConfirmLocalizedAsync(strs.greetdmmsg_new); + if (!sendGreetEnabled) + await ReplyPendingLocalizedAsync(strs.greetdmmsg_enable($"`{prefix}greetdm`")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task Bye() + { + var enabled = await _service.SetBye(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await ReplyConfirmLocalizedAsync(strs.bye_on); + else + await ReplyConfirmLocalizedAsync(strs.bye_off); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task ByeMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var byeMsg = _service.GetByeMessage(ctx.Guild.Id); + await ReplyConfirmLocalizedAsync(strs.byemsg_cur(byeMsg?.SanitizeMentions())); + return; + } + + var sendByeEnabled = _service.SetByeMessage(ctx.Guild.Id, ref text); + + await ReplyConfirmLocalizedAsync(strs.byemsg_new); + if (!sendByeEnabled) + await ReplyPendingLocalizedAsync(strs.byemsg_enable($"`{prefix}bye`")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task ByeDel(int timer = 30) + { + await _service.SetByeDel(ctx.Guild.Id, timer); + + if (timer > 0) + await ReplyConfirmLocalizedAsync(strs.byedel_on(timer)); + else + await ReplyPendingLocalizedAsync(strs.byedel_off); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public async Task ByeTest([Leftover] IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + await _service.ByeTest((ITextChannel)ctx.Channel, user); + var enabled = _service.GetByeEnabled(ctx.Guild.Id); + if (!enabled) + await ReplyPendingLocalizedAsync(strs.byemsg_enable($"`{prefix}bye`")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public async Task GreetTest([Leftover] IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + await _service.GreetTest((ITextChannel)ctx.Channel, user); + var enabled = _service.GetGreetEnabled(ctx.Guild.Id); + if (!enabled) + await ReplyPendingLocalizedAsync(strs.greetmsg_enable($"`{prefix}greet`")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public async Task GreetDmTest([Leftover] IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + var success = await _service.GreetDmTest(user); + if (success) + await ctx.OkAsync(); + else + await ctx.WarningAsync(); + var enabled = _service.GetGreetDmEnabled(ctx.Guild.Id); + if (!enabled) + await ReplyPendingLocalizedAsync(strs.greetdmmsg_enable($"`{prefix}greetdm`")); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/GreetBye/GreetGrouper.cs b/src/Ellie.Bot.Modules.Administration/GreetBye/GreetGrouper.cs new file mode 100644 index 0000000..0ed4d81 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/GreetBye/GreetGrouper.cs @@ -0,0 +1,71 @@ +namespace Ellie.Services; + +public class GreetGrouper +{ + private readonly Dictionary> _group; + private readonly object _locker = new(); + + public GreetGrouper() + => _group = new(); + + + /// + /// Creates a group, if group already exists, adds the specified user + /// + /// Id of the server for which to create group for + /// User to add if group already exists + /// + public bool CreateOrAdd(ulong guildId, T toAddIfExists) + { + lock (_locker) + { + if (_group.TryGetValue(guildId, out var list)) + { + list.Add(toAddIfExists); + return false; + } + + _group[guildId] = new(); + return true; + } + } + + /// + /// Remove the specified amount of items from the group. If all items are removed, group will be removed. + /// + /// Id of the group + /// Maximum number of items to retrieve + /// Items retrieved + /// Whether the group has no more items left and is deleted + public bool ClearGroup(ulong guildId, int count, out IReadOnlyCollection items) + { + lock (_locker) + { + if (_group.TryGetValue(guildId, out var set)) + { + // if we want more than there are, return everything + if (count >= set.Count) + { + items = set; + _group.Remove(guildId); + return true; + } + + // if there are more in the group than what's needed + // take the requested number, remove them from the set + // and return them + var toReturn = set.TakeWhile(_ => count-- != 0).ToList(); + foreach (var item in toReturn) + set.Remove(item); + + items = toReturn; + // returning falsemeans group is not yet deleted + // because there are items left + return false; + } + + items = Array.Empty(); + return true; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/GreetBye/GreetService.cs b/src/Ellie.Bot.Modules.Administration/GreetBye/GreetService.cs new file mode 100644 index 0000000..5e449c2 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/GreetBye/GreetService.cs @@ -0,0 +1,619 @@ +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Services.Database.Models; +using System.Threading.Channels; + +namespace Ellie.Services; + +public class GreetService : IEService, IReadyExecutor +{ + public bool GroupGreets + => _bss.Data.GroupGreets; + + private readonly DbService _db; + + private readonly ConcurrentDictionary _guildConfigsCache; + private readonly DiscordSocketClient _client; + + private readonly GreetGrouper _greets = new(); + private readonly GreetGrouper _byes = new(); + private readonly BotConfigService _bss; + + public GreetService( + DiscordSocketClient client, + IBot bot, + DbService db, + BotConfigService bss) + { + _db = db; + _client = client; + _bss = bss; + + _guildConfigsCache = new(bot.AllGuildConfigs.ToDictionary(g => g.GuildId, GreetSettings.Create)); + + _client.UserJoined += OnUserJoined; + _client.UserLeft += OnUserLeft; + + bot.JoinedGuild += OnBotJoinedGuild; + _client.LeftGuild += OnClientLeftGuild; + + _client.GuildMemberUpdated += ClientOnGuildMemberUpdated; + } + + public async Task OnReadyAsync() + { + while (true) + { + var (conf, user, compl) = await _greetDmQueue.Reader.ReadAsync(); + var res = await GreetDmUserInternal(conf, user); + compl.TrySetResult(res); + await Task.Delay(2000); + } + } + + private Task ClientOnGuildMemberUpdated(Cacheable optOldUser, SocketGuildUser newUser) + { + // if user is a new booster + // or boosted again the same server + if ((optOldUser.Value is { PremiumSince: null } && newUser is { PremiumSince: not null }) + || (optOldUser.Value?.PremiumSince is { } oldDate + && newUser.PremiumSince is { } newDate + && newDate > oldDate)) + { + var conf = GetOrAddSettingsForGuild(newUser.Guild.Id); + if (!conf.SendBoostMessage) + return Task.CompletedTask; + + _ = Task.Run(TriggerBoostMessage(conf, newUser)); + } + + return Task.CompletedTask; + } + + private Func TriggerBoostMessage(GreetSettings conf, SocketGuildUser user) + => async () => + { + var channel = user.Guild.GetTextChannel(conf.BoostMessageChannelId); + if (channel is null) + return; + + if (string.IsNullOrWhiteSpace(conf.BoostMessage)) + return; + + var toSend = SmartText.CreateFrom(conf.BoostMessage); + var rep = new ReplacementBuilder().WithDefault(user, channel, user.Guild, _client).Build(); + + try + { + var toDelete = await channel.SendAsync(rep.Replace(toSend)); + if (conf.BoostMessageDeleteAfter > 0) + toDelete.DeleteAfter(conf.BoostMessageDeleteAfter); + } + catch (Exception ex) + { + Log.Error(ex, "Error sending boost message"); + } + }; + + private Task OnClientLeftGuild(SocketGuild arg) + { + _guildConfigsCache.TryRemove(arg.Id, out _); + return Task.CompletedTask; + } + + private Task OnBotJoinedGuild(GuildConfig gc) + { + _guildConfigsCache[gc.GuildId] = GreetSettings.Create(gc); + return Task.CompletedTask; + } + + private Task OnUserLeft(SocketGuild guild, SocketUser user) + { + _ = Task.Run(async () => + { + try + { + var conf = GetOrAddSettingsForGuild(guild.Id); + + if (!conf.SendChannelByeMessage) + return; + var channel = guild.TextChannels.FirstOrDefault(c => c.Id == conf.ByeMessageChannelId); + + if (channel is null) //maybe warn the server owner that the channel is missing + return; + + if (GroupGreets) + { + // if group is newly created, greet that user right away, + // but any user which joins in the next 5 seconds will + // be greeted in a group greet + if (_byes.CreateOrAdd(guild.Id, user)) + { + // greet single user + await ByeUsers(conf, channel, new[] { user }); + var groupClear = false; + while (!groupClear) + { + await Task.Delay(5000); + groupClear = _byes.ClearGroup(guild.Id, 5, out var toBye); + await ByeUsers(conf, channel, toBye); + } + } + } + else + await ByeUsers(conf, channel, new[] { user }); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + public string? GetDmGreetMsg(ulong id) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(id, set => set).DmGreetMessageText; + } + + public string? GetGreetMsg(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(gid, set => set).ChannelGreetMessageText; + } + + public string? GetBoostMessage(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(gid, set => set).BoostMessage; + } + + private Task ByeUsers(GreetSettings conf, ITextChannel channel, IUser user) + => ByeUsers(conf, channel, new[] { user }); + + private async Task ByeUsers(GreetSettings conf, ITextChannel channel, IReadOnlyCollection users) + { + if (!users.Any()) + return; + + var rep = new ReplacementBuilder().WithChannel(channel) + .WithClient(_client) + .WithServer(_client, (SocketGuild)channel.Guild) + .WithManyUsers(users) + .Build(); + + var text = SmartText.CreateFrom(conf.ChannelByeMessageText); + text = rep.Replace(text); + try + { + var toDelete = await channel.SendAsync(text); + if (conf.AutoDeleteByeMessagesTimer > 0) + toDelete.DeleteAfter(conf.AutoDeleteByeMessagesTimer); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.InsufficientPermissions || ex.DiscordCode == DiscordErrorCode.UnknownChannel) + { + Log.Warning(ex, "Missing permissions to send a bye message, the bye message will be disabled on server: {GuildId}", channel.GuildId); + await SetBye(channel.GuildId, channel.Id, false); + } + catch (Exception ex) + { + Log.Warning(ex, "Error embeding bye message"); + } + } + + private Task GreetUsers(GreetSettings conf, ITextChannel channel, IGuildUser user) + => GreetUsers(conf, channel, new[] { user }); + + private async Task GreetUsers(GreetSettings conf, ITextChannel channel, IReadOnlyCollection users) + { + if (users.Count == 0) + return; + + var rep = new ReplacementBuilder().WithChannel(channel) + .WithClient(_client) + .WithServer(_client, (SocketGuild)channel.Guild) + .WithManyUsers(users) + .Build(); + + var text = SmartText.CreateFrom(conf.ChannelGreetMessageText); + text = rep.Replace(text); + try + { + var toDelete = await channel.SendAsync(text); + if (conf.AutoDeleteGreetMessagesTimer > 0) + toDelete.DeleteAfter(conf.AutoDeleteGreetMessagesTimer); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.InsufficientPermissions || ex.DiscordCode == DiscordErrorCode.UnknownChannel) + { + Log.Warning(ex, "Missing permissions to send a bye message, the greet message will be disabled on server: {GuildId}", channel.GuildId); + await SetGreet(channel.GuildId, channel.Id, false); + } + catch (Exception ex) + { + Log.Warning(ex, "Error embeding greet message"); + } + } + + private readonly Channel<(GreetSettings, IGuildUser, TaskCompletionSource)> _greetDmQueue = + Channel.CreateBounded<(GreetSettings, IGuildUser, TaskCompletionSource)>(new BoundedChannelOptions(60) + { + // The limit of 60 users should be only hit when there's a raid. In that case + // probably the best thing to do is to drop newest (raiding) users + FullMode = BoundedChannelFullMode.DropNewest + }); + + private async Task GreetDmUser(GreetSettings conf, IGuildUser user) + { + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await _greetDmQueue.Writer.WriteAsync((conf, user, completionSource)); + return await completionSource.Task; + } + + private async Task GreetDmUserInternal(GreetSettings conf, IGuildUser user) + { + try + { + var rep = new ReplacementBuilder() + .WithUser(user) + .WithServer(_client, (SocketGuild)user.Guild) + .Build(); + + var text = SmartText.CreateFrom(conf.DmGreetMessageText); + text = rep.Replace(text); + + if (text is SmartPlainText pt) + { + text = new SmartEmbedText() + { + Description = pt.Text + }; + } + + if (text is SmartEmbedText set) + { + text = set with + { + Footer = CreateFooterSource(user) + }; + } + else if (text is SmartEmbedTextArray seta) + { + // if the greet dm message is a text array + var ebElem = seta.Embeds.LastOrDefault(); + if (ebElem is null) + { + // if there are no embeds, add an embed with the footer + text = seta with + { + Embeds = new[] + { + new SmartEmbedArrayElementText() + { + Footer = CreateFooterSource(user) + } + } + }; + } + else + { + // if the maximum amount of embeds is reached, edit the last embed + if (seta.Embeds.Length >= 10) + { + seta.Embeds[^1] = seta.Embeds[^1] with + { + Footer = CreateFooterSource(user) + }; + } + else + { + // if there is less than 10 embeds, add an embed with footer only + seta.Embeds = seta.Embeds.Append(new SmartEmbedArrayElementText() + { + Footer = CreateFooterSource(user) + }).ToArray(); + } + } + } + + await user.SendAsync(text); + } + catch + { + return false; + } + + return true; + } + + private static SmartTextEmbedFooter CreateFooterSource(IGuildUser user) + => new() + { + Text = $"This message was sent from {user.Guild} server.", + IconUrl = user.Guild.IconUrl + }; + + private Task OnUserJoined(IGuildUser user) + { + _ = Task.Run(async () => + { + try + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + + if (conf.SendChannelGreetMessage) + { + var channel = await user.Guild.GetTextChannelAsync(conf.GreetMessageChannelId); + if (channel is not null) + { + if (GroupGreets) + { + // if group is newly created, greet that user right away, + // but any user which joins in the next 5 seconds will + // be greeted in a group greet + if (_greets.CreateOrAdd(user.GuildId, user)) + { + // greet single user + await GreetUsers(conf, channel, new[] { user }); + var groupClear = false; + while (!groupClear) + { + await Task.Delay(5000); + groupClear = _greets.ClearGroup(user.GuildId, 5, out var toGreet); + await GreetUsers(conf, channel, toGreet); + } + } + } + else + await GreetUsers(conf, channel, new[] { user }); + } + } + + if (conf.SendDmGreetMessage) + await GreetDmUser(conf, user); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + public string? GetByeMessage(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(gid, set => set).ChannelByeMessageText; + } + + public GreetSettings GetOrAddSettingsForGuild(ulong guildId) + { + if (_guildConfigsCache.TryGetValue(guildId, out var settings)) + return settings; + + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guildId, set => set); + settings = GreetSettings.Create(gc); + } + + _guildConfigsCache.TryAdd(guildId, settings); + return settings; + } + + public async Task SetGreet(ulong guildId, ulong channelId, bool? value = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + var enabled = conf.SendChannelGreetMessage = value ?? !conf.SendChannelGreetMessage; + conf.GreetMessageChannelId = channelId; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + return enabled; + } + + public bool SetGreetMessage(ulong guildId, ref string message) + { + message = message.SanitizeMentions(); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.ChannelGreetMessageText = message; + var greetMsgEnabled = conf.SendChannelGreetMessage; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache.AddOrUpdate(guildId, toAdd, (_, _) => toAdd); + + uow.SaveChanges(); + return greetMsgEnabled; + } + + public async Task SetGreetDm(ulong guildId, bool? value = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + var enabled = conf.SendDmGreetMessage = value ?? !conf.SendDmGreetMessage; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + return enabled; + } + + public bool SetGreetDmMessage(ulong guildId, ref string? message) + { + message = message?.SanitizeMentions(); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.DmGreetMessageText = message; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + uow.SaveChanges(); + return conf.SendDmGreetMessage; + } + + public async Task SetBye(ulong guildId, ulong channelId, bool? value = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + var enabled = conf.SendChannelByeMessage = value ?? !conf.SendChannelByeMessage; + conf.ByeMessageChannelId = channelId; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + return enabled; + } + + public bool SetByeMessage(ulong guildId, ref string? message) + { + message = message?.SanitizeMentions(); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.ChannelByeMessageText = message; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + uow.SaveChanges(); + return conf.SendChannelByeMessage; + } + + public async Task SetByeDel(ulong guildId, int timer) + { + if (timer is < 0 or > 600) + return; + + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.AutoDeleteByeMessagesTimer = timer; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + } + + public async Task SetGreetDel(ulong guildId, int timer) + { + if (timer is < 0 or > 600) + return; + + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.AutoDeleteGreetMessagesTimer = timer; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + } + + public bool SetBoostMessage(ulong guildId, ref string message) + { + message = message.SanitizeMentions(); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.BoostMessage = message; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + uow.SaveChanges(); + return conf.SendBoostMessage; + } + + public async Task SetBoostDel(ulong guildId, int timer) + { + if (timer is < 0 or > 600) + throw new ArgumentOutOfRangeException(nameof(timer)); + + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.BoostMessageDeleteAfter = timer; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + } + + public async Task ToggleBoost(ulong guildId, ulong channelId) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.SendBoostMessage = !conf.SendBoostMessage; + conf.BoostMessageChannelId = channelId; + await uow.SaveChangesAsync(); + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + return conf.SendBoostMessage; + } + + #region Get Enabled Status + + public bool GetGreetDmEnabled(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + return conf.SendDmGreetMessage; + } + + public bool GetGreetEnabled(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + return conf.SendChannelGreetMessage; + } + + public bool GetByeEnabled(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + return conf.SendChannelByeMessage; + } + + #endregion + + #region Test Messages + + public Task ByeTest(ITextChannel channel, IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return ByeUsers(conf, channel, user); + } + + public Task GreetTest(ITextChannel channel, IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return GreetUsers(conf, channel, user); + } + + public Task GreetDmTest(IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return GreetDmUser(conf, user); + } + + #endregion +} diff --git a/src/Ellie.Bot.Modules.Administration/GreetBye/GreetSettings.cs b/src/Ellie.Bot.Modules.Administration/GreetBye/GreetSettings.cs new file mode 100644 index 0000000..6361841 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/GreetBye/GreetSettings.cs @@ -0,0 +1,45 @@ +using Ellie.Services.Database.Models; + +namespace Ellie.Services; + +public class GreetSettings +{ + public int AutoDeleteGreetMessagesTimer { get; set; } + public int AutoDeleteByeMessagesTimer { get; set; } + + public ulong GreetMessageChannelId { get; set; } + public ulong ByeMessageChannelId { get; set; } + + public bool SendDmGreetMessage { get; set; } + public string? DmGreetMessageText { get; set; } + + public bool SendChannelGreetMessage { get; set; } + public string? ChannelGreetMessageText { get; set; } + + public bool SendChannelByeMessage { get; set; } + public string? ChannelByeMessageText { get; set; } + + public bool SendBoostMessage { get; set; } + public string? BoostMessage { get; set; } + public int BoostMessageDeleteAfter { get; set; } + public ulong BoostMessageChannelId { get; set; } + + public static GreetSettings Create(GuildConfig g) + => new() + { + AutoDeleteByeMessagesTimer = g.AutoDeleteByeMessagesTimer, + AutoDeleteGreetMessagesTimer = g.AutoDeleteGreetMessagesTimer, + GreetMessageChannelId = g.GreetMessageChannelId, + ByeMessageChannelId = g.ByeMessageChannelId, + SendDmGreetMessage = g.SendDmGreetMessage, + DmGreetMessageText = g.DmGreetMessageText, + SendChannelGreetMessage = g.SendChannelGreetMessage, + ChannelGreetMessageText = g.ChannelGreetMessageText, + SendChannelByeMessage = g.SendChannelByeMessage, + ChannelByeMessageText = g.ChannelByeMessageText, + SendBoostMessage = g.SendBoostMessage, + BoostMessage = g.BoostMessage, + BoostMessageDeleteAfter = g.BoostMessageDeleteAfter, + BoostMessageChannelId = g.BoostMessageChannelId + }; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/ImageOnlyChannelService.cs b/src/Ellie.Bot.Modules.Administration/ImageOnlyChannelService.cs new file mode 100644 index 0000000..3f4775e --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/ImageOnlyChannelService.cs @@ -0,0 +1,235 @@ +#nullable disable +using LinqToDB; +using Microsoft.Extensions.Caching.Memory; +using Ellie.Common.ModuleBehaviors; +using System.Net; +using System.Threading.Channels; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration.Services; + +public sealed class SomethingOnlyChannelService : IExecOnMessage +{ + public int Priority { get; } = 0; + private readonly IMemoryCache _ticketCache; + private readonly DiscordSocketClient _client; + private readonly DbService _db; + private readonly ConcurrentDictionary> _imageOnly; + private readonly ConcurrentDictionary> _linkOnly; + + private readonly Channel _deleteQueue = Channel.CreateBounded( + new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + + public SomethingOnlyChannelService(IMemoryCache ticketCache, DiscordSocketClient client, DbService db) + { + _ticketCache = ticketCache; + _client = client; + _db = db; + + using var uow = _db.GetDbContext(); + _imageOnly = uow.Set() + .Where(x => x.Type == OnlyChannelType.Image) + .ToList() + .GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, x => new ConcurrentHashSet(x.Select(y => y.ChannelId))) + .ToConcurrent(); + + _linkOnly = uow.Set() + .Where(x => x.Type == OnlyChannelType.Link) + .ToList() + .GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, x => new ConcurrentHashSet(x.Select(y => y.ChannelId))) + .ToConcurrent(); + + _ = Task.Run(DeleteQueueRunner); + + _client.ChannelDestroyed += ClientOnChannelDestroyed; + } + + private async Task ClientOnChannelDestroyed(SocketChannel ch) + { + if (ch is not IGuildChannel gch) + return; + + if (_imageOnly.TryGetValue(gch.GuildId, out var channels) && channels.TryRemove(ch.Id)) + await ToggleImageOnlyChannelAsync(gch.GuildId, ch.Id, true); + } + + private async Task DeleteQueueRunner() + { + while (true) + { + var toDelete = await _deleteQueue.Reader.ReadAsync(); + try + { + await toDelete.DeleteAsync(); + await Task.Delay(1000); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + // disable if bot can't delete messages in the channel + await ToggleImageOnlyChannelAsync(((ITextChannel)toDelete.Channel).GuildId, toDelete.Channel.Id, true); + } + } + } + + public async Task ToggleImageOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false) + { + var newState = false; + await using var uow = _db.GetDbContext(); + if (forceDisable || (_imageOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId))) + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Image); + } + else + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId); + uow.Set().Add(new() + { + GuildId = guildId, + ChannelId = channelId, + Type = OnlyChannelType.Image + }); + + if (_linkOnly.TryGetValue(guildId, out var chs)) + chs.TryRemove(channelId); + + channels = _imageOnly.GetOrAdd(guildId, new ConcurrentHashSet()); + channels.Add(channelId); + newState = true; + } + + await uow.SaveChangesAsync(); + return newState; + } + + public async Task ToggleLinkOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false) + { + var newState = false; + await using var uow = _db.GetDbContext(); + if (forceDisable || (_linkOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId))) + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Link); + } + else + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId); + uow.Set().Add(new() + { + GuildId = guildId, + ChannelId = channelId, + Type = OnlyChannelType.Link + }); + + if (_imageOnly.TryGetValue(guildId, out var chs)) + chs.TryRemove(channelId); + + channels = _linkOnly.GetOrAdd(guildId, new ConcurrentHashSet()); + channels.Add(channelId); + newState = true; + } + + await uow.SaveChangesAsync(); + return newState; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + if (msg.Channel is not ITextChannel tch) + return false; + + if (_imageOnly.TryGetValue(tch.GuildId, out var chs) && chs.Contains(msg.Channel.Id)) + return await HandleOnlyChannel(tch, msg, OnlyChannelType.Image); + + if (_linkOnly.TryGetValue(tch.GuildId, out chs) && chs.Contains(msg.Channel.Id)) + return await HandleOnlyChannel(tch, msg, OnlyChannelType.Link); + + return false; + } + + private async Task HandleOnlyChannel(ITextChannel tch, IUserMessage msg, OnlyChannelType type) + { + if (type == OnlyChannelType.Image) + { + if (msg.Attachments.Any(x => x is { Height: > 0, Width: > 0 })) + return false; + } + else + { + if (msg.Content.TryGetUrlPath(out _)) + return false; + } + + var user = await tch.Guild.GetUserAsync(msg.Author.Id) + ?? await _client.Rest.GetGuildUserAsync(tch.GuildId, msg.Author.Id); + + if (user is null) + return false; + + // ignore owner and admin + if (user.Id == tch.Guild.OwnerId || user.GuildPermissions.Administrator) + { + Log.Information("{Type}-Only Channel: Ignoring owner od admin ({ChannelId})", type, msg.Channel.Id); + return false; + } + + // ignore users higher in hierarchy + var botUser = await tch.Guild.GetCurrentUserAsync(); + if (user.GetRoles().Max(x => x.Position) >= botUser.GetRoles().Max(x => x.Position)) + return false; + + if (!botUser.GetPermissions(tch).ManageChannel) + { + if(type == OnlyChannelType.Image) + await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true); + else + await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true); + + return false; + } + + var shouldLock = AddUserTicket(tch.GuildId, msg.Author.Id); + if (shouldLock) + { + await tch.AddPermissionOverwriteAsync(msg.Author, new(sendMessages: PermValue.Deny)); + Log.Warning("{Type}-Only Channel: User {User} [{UserId}] has been banned from typing in the channel [{ChannelId}]", + type, + msg.Author, + msg.Author.Id, + msg.Channel.Id); + } + + try + { + await _deleteQueue.Writer.WriteAsync(msg); + } + catch (Exception ex) + { + Log.Error(ex, "Error deleting message {MessageId} in image-only channel {ChannelId}", msg.Id, tch.Id); + } + + return true; + } + + private bool AddUserTicket(ulong guildId, ulong userId) + { + var old = _ticketCache.GetOrCreate($"{guildId}_{userId}", + entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); + return 0; + }); + + _ticketCache.Set($"{guildId}_{userId}", ++old); + + // if this is the third time that the user posts a + // non image in an image-only channel on this server + return old > 2; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/LocalizationCommands.cs b/src/Ellie.Bot.Modules.Administration/LocalizationCommands.cs new file mode 100644 index 0000000..9acb3bb --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/LocalizationCommands.cs @@ -0,0 +1,250 @@ +#nullable disable +using System.Globalization; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class LocalizationCommands : EllieModule + { + private static readonly IReadOnlyDictionary _supportedLocales = new Dictionary + { + { "ar", "العربية" }, + { "zh-TW", "繁體中文, 台灣" }, + { "zh-CN", "简体中文, 中华人民共和国" }, + { "nl-NL", "Nederlands, Nederland" }, + { "en-US", "English, United States" }, + { "fr-FR", "Français, France" }, + { "cs-CZ", "Čeština, Česká republika" }, + { "da-DK", "Dansk, Danmark" }, + { "de-DE", "Deutsch, Deutschland" }, + { "he-IL", "עברית, ישראל" }, + { "hu-HU", "Magyar, Magyarország" }, + { "id-ID", "Bahasa Indonesia, Indonesia" }, + { "it-IT", "Italiano, Italia" }, + { "ja-JP", "日本語, 日本" }, + { "ko-KR", "한국어, 대한민국" }, + { "nb-NO", "Norsk, Norge" }, + { "pl-PL", "Polski, Polska" }, + { "pt-BR", "Português Brasileiro, Brasil" }, + { "ro-RO", "Română, România" }, + { "ru-RU", "Русский, Россия" }, + { "sr-Cyrl-RS", "Српски, Србија" }, + { "es-ES", "Español, España" }, + { "sv-SE", "Svenska, Sverige" }, + { "tr-TR", "Türkçe, Türkiye" }, + { "ts-TS", "Tsundere, You Baka" }, + { "uk-UA", "Українська, Україна" } + }; + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task LanguageSet() + => await ReplyConfirmLocalizedAsync(strs.lang_set_show(Format.Bold(Culture.ToString()), + Format.Bold(Culture.NativeName))); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public async Task LanguageSet(string name) + { + try + { + CultureInfo ci; + if (name.Trim().ToLowerInvariant() == "default") + { + _localization.RemoveGuildCulture(ctx.Guild); + ci = _localization.DefaultCultureInfo; + } + else + { + ci = new(name); + _localization.SetGuildCulture(ctx.Guild, ci); + } + + await ReplyConfirmLocalizedAsync(strs.lang_set(Format.Bold(ci.ToString()), Format.Bold(ci.NativeName))); + } + catch (Exception) + { + await ReplyErrorLocalizedAsync(strs.lang_set_fail); + } + } + + [Cmd] + public async Task LanguageSetDefault() + { + var cul = _localization.DefaultCultureInfo; + await ReplyErrorLocalizedAsync(strs.lang_set_bot_show(cul, cul.NativeName)); + } + + [Cmd] + [OwnerOnly] + public async Task LanguageSetDefault(string name) + { + try + { + CultureInfo ci; + if (name.Trim().ToLowerInvariant() == "default") + { + _localization.ResetDefaultCulture(); + ci = _localization.DefaultCultureInfo; + } + else + { + ci = new(name); + _localization.SetDefaultCulture(ci); + } + + await ReplyConfirmLocalizedAsync(strs.lang_set_bot(Format.Bold(ci.ToString()), + Format.Bold(ci.NativeName))); + } + catch (Exception) + { + await ReplyErrorLocalizedAsync(strs.lang_set_fail); + } + } + + [Cmd] + public async Task LanguagesList() + => await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.lang_list)) + .WithDescription(string.Join("\n", + _supportedLocales.Select( + x => $"{Format.Code(x.Key),-10} => {x.Value}")))); + } +} +/* list of language codes for reference. + * taken from https://github.com/dotnet/coreclr/blob/ee5862c6a257e60e263537d975ab6c513179d47f/src/mscorlib/src/System/Globalization/CultureData.cs#L192 + { "029", "en-029" }, + { "AE", "ar-AE" }, + { "AF", "prs-AF" }, + { "AL", "sq-AL" }, + { "AM", "hy-AM" }, + { "AR", "es-AR" }, + { "AT", "de-AT" }, + { "AU", "en-AU" }, + { "AZ", "az-Cyrl-AZ" }, + { "BA", "bs-Latn-BA" }, + { "BD", "bn-BD" }, + { "BE", "nl-BE" }, + { "BG", "bg-BG" }, + { "BH", "ar-BH" }, + { "BN", "ms-BN" }, + { "BO", "es-BO" }, + { "BR", "pt-BR" }, + { "BY", "be-BY" }, + { "BZ", "en-BZ" }, + { "CA", "en-CA" }, + { "CH", "it-CH" }, + { "CL", "es-CL" }, + { "CN", "zh-CN" }, + { "CO", "es-CO" }, + { "CR", "es-CR" }, + { "CS", "sr-Cyrl-CS" }, + { "CZ", "cs-CZ" }, + { "DE", "de-DE" }, + { "DK", "da-DK" }, + { "DO", "es-DO" }, + { "DZ", "ar-DZ" }, + { "EC", "es-EC" }, + { "EE", "et-EE" }, + { "EG", "ar-EG" }, + { "ES", "es-ES" }, + { "ET", "am-ET" }, + { "FI", "fi-FI" }, + { "FO", "fo-FO" }, + { "FR", "fr-FR" }, + { "GB", "en-GB" }, + { "GE", "ka-GE" }, + { "GL", "kl-GL" }, + { "GR", "el-GR" }, + { "GT", "es-GT" }, + { "HK", "zh-HK" }, + { "HN", "es-HN" }, + { "HR", "hr-HR" }, + { "HU", "hu-HU" }, + { "ID", "id-ID" }, + { "IE", "en-IE" }, + { "IL", "he-IL" }, + { "IN", "hi-IN" }, + { "IQ", "ar-IQ" }, + { "IR", "fa-IR" }, + { "IS", "is-IS" }, + { "IT", "it-IT" }, + { "IV", "" }, + { "JM", "en-JM" }, + { "JO", "ar-JO" }, + { "JP", "ja-JP" }, + { "KE", "sw-KE" }, + { "KG", "ky-KG" }, + { "KH", "km-KH" }, + { "KR", "ko-KR" }, + { "KW", "ar-KW" }, + { "KZ", "kk-KZ" }, + { "LA", "lo-LA" }, + { "LB", "ar-LB" }, + { "LI", "de-LI" }, + { "LK", "si-LK" }, + { "LT", "lt-LT" }, + { "LU", "lb-LU" }, + { "LV", "lv-LV" }, + { "LY", "ar-LY" }, + { "MA", "ar-MA" }, + { "MC", "fr-MC" }, + { "ME", "sr-Latn-ME" }, + { "MK", "mk-MK" }, + { "MN", "mn-MN" }, + { "MO", "zh-MO" }, + { "MT", "mt-MT" }, + { "MV", "dv-MV" }, + { "MX", "es-MX" }, + { "MY", "ms-MY" }, + { "NG", "ig-NG" }, + { "NI", "es-NI" }, + { "NL", "nl-NL" }, + { "NO", "nn-NO" }, + { "NP", "ne-NP" }, + { "NZ", "en-NZ" }, + { "OM", "ar-OM" }, + { "PA", "es-PA" }, + { "PE", "es-PE" }, + { "PH", "en-PH" }, + { "PK", "ur-PK" }, + { "PL", "pl-PL" }, + { "PR", "es-PR" }, + { "PT", "pt-PT" }, + { "PY", "es-PY" }, + { "QA", "ar-QA" }, + { "RO", "ro-RO" }, + { "RS", "sr-Latn-RS" }, + { "RU", "ru-RU" }, + { "RW", "rw-RW" }, + { "SA", "ar-SA" }, + { "SE", "sv-SE" }, + { "SG", "zh-SG" }, + { "SI", "sl-SI" }, + { "SK", "sk-SK" }, + { "SN", "wo-SN" }, + { "SV", "es-SV" }, + { "SY", "ar-SY" }, + { "TH", "th-TH" }, + { "TJ", "tg-Cyrl-TJ" }, + { "TM", "tk-TM" }, + { "TN", "ar-TN" }, + { "TR", "tr-TR" }, + { "TT", "en-TT" }, + { "TW", "zh-TW" }, + { "UA", "uk-UA" }, + { "US", "en-US" }, + { "UY", "es-UY" }, + { "UZ", "uz-Cyrl-UZ" }, + { "VE", "es-VE" }, + { "VN", "vi-VN" }, + { "YE", "ar-YE" }, + { "ZA", "af-ZA" }, + { "ZW", "en-ZW" } + */ \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Mute/MuteCommands.cs b/src/Ellie.Bot.Modules.Administration/Mute/MuteCommands.cs new file mode 100644 index 0000000..17c4d38 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Mute/MuteCommands.cs @@ -0,0 +1,231 @@ +#nullable disable +using Ellie.Common.TypeReaders.Models; +using Ellie.Modules.Administration.Services; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class MuteCommands : EllieModule + { + private async Task VerifyMutePermissions(IGuildUser runnerUser, IGuildUser targetUser) + { + var runnerUserRoles = runnerUser.GetRoles(); + var targetUserRoles = targetUser.GetRoles(); + if (runnerUser.Id != ctx.Guild.OwnerId + && runnerUserRoles.Max(x => x.Position) <= targetUserRoles.Max(x => x.Position)) + { + await ReplyErrorLocalizedAsync(strs.mute_perms); + return false; + } + + return true; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + public async Task MuteRole([Leftover] IRole role = null) + { + if (role is null) + { + var muteRole = await _service.GetMuteRole(ctx.Guild); + await ReplyConfirmLocalizedAsync(strs.mute_role(Format.Code(muteRole.Name))); + return; + } + + if (ctx.User.Id != ctx.Guild.OwnerId + && role.Position >= ((SocketGuildUser)ctx.User).Roles.Max(x => x.Position)) + { + await ReplyErrorLocalizedAsync(strs.insuf_perms_u); + return; + } + + await _service.SetMuteRoleAsync(ctx.Guild.Id, role.Name); + + await ReplyConfirmLocalizedAsync(strs.mute_role_set); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)] + [Priority(0)] + public async Task Mute(IGuildUser target, [Leftover] string reason = "") + { + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, target)) + return; + + await _service.MuteUser(target, ctx.User, reason: reason); + await ReplyConfirmLocalizedAsync(strs.user_muted(Format.Bold(target.ToString()))); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception in the mute command"); + await ReplyErrorLocalizedAsync(strs.mute_error); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)] + [Priority(1)] + public async Task Mute(StoopidTime time, IGuildUser user, [Leftover] string reason = "") + { + if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49)) + return; + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.TimedMute(user, ctx.User, time.Time, reason: reason); + await ReplyConfirmLocalizedAsync(strs.user_muted_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in mute command"); + await ReplyErrorLocalizedAsync(strs.mute_error); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)] + public async Task Unmute(IGuildUser user, [Leftover] string reason = "") + { + try + { + await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, reason: reason); + await ReplyConfirmLocalizedAsync(strs.user_unmuted(Format.Bold(user.ToString()))); + } + catch + { + await ReplyErrorLocalizedAsync(strs.mute_error); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task ChatMute(IGuildUser user, [Leftover] string reason = "") + { + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.MuteUser(user, ctx.User, MuteType.Chat, reason); + await ReplyConfirmLocalizedAsync(strs.user_chat_mute(Format.Bold(user.ToString()))); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception in the chatmute command"); + await ReplyErrorLocalizedAsync(strs.mute_error); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [Priority(1)] + public async Task ChatMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "") + { + if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49)) + return; + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.TimedMute(user, ctx.User, time.Time, MuteType.Chat, reason); + await ReplyConfirmLocalizedAsync(strs.user_chat_mute_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in chatmute command"); + await ReplyErrorLocalizedAsync(strs.mute_error); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + public async Task ChatUnmute(IGuildUser user, [Leftover] string reason = "") + { + try + { + await _service.UnmuteUser(user.Guild.Id, user.Id, ctx.User, MuteType.Chat, reason); + await ReplyConfirmLocalizedAsync(strs.user_chat_unmute(Format.Bold(user.ToString()))); + } + catch + { + await ReplyErrorLocalizedAsync(strs.mute_error); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.MuteMembers)] + [Priority(0)] + public async Task VoiceMute(IGuildUser user, [Leftover] string reason = "") + { + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.MuteUser(user, ctx.User, MuteType.Voice, reason); + await ReplyConfirmLocalizedAsync(strs.user_voice_mute(Format.Bold(user.ToString()))); + } + catch + { + await ReplyErrorLocalizedAsync(strs.mute_error); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.MuteMembers)] + [Priority(1)] + public async Task VoiceMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "") + { + if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49)) + return; + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.TimedMute(user, ctx.User, time.Time, MuteType.Voice, reason); + await ReplyConfirmLocalizedAsync(strs.user_voice_mute_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)); + } + catch + { + await ReplyErrorLocalizedAsync(strs.mute_error); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.MuteMembers)] + public async Task VoiceUnmute(IGuildUser user, [Leftover] string reason = "") + { + try + { + await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, MuteType.Voice, reason); + await ReplyConfirmLocalizedAsync(strs.user_voice_unmute(Format.Bold(user.ToString()))); + } + catch + { + await ReplyErrorLocalizedAsync(strs.mute_error); + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Mute/MuteService.cs b/src/Ellie.Bot.Modules.Administration/Mute/MuteService.cs new file mode 100644 index 0000000..50bf52e --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Mute/MuteService.cs @@ -0,0 +1,505 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration.Services; + +public enum MuteType +{ + Voice, + Chat, + All +} + +public class MuteService : IEService +{ + public enum TimerType { Mute, Ban, AddRole } + + private static readonly OverwritePermissions _denyOverwrite = new(addReactions: PermValue.Deny, + sendMessages: PermValue.Deny, + sendMessagesInThreads: PermValue.Deny, + attachFiles: PermValue.Deny); + + public event Action UserMuted = delegate { }; + public event Action UserUnmuted = delegate { }; + + public ConcurrentDictionary GuildMuteRoles { get; } + public ConcurrentDictionary> MutedUsers { get; } + + public ConcurrentDictionary> UnTimers { get; } = new(); + + private readonly DiscordSocketClient _client; + private readonly DbService _db; + private readonly IEmbedBuilderService _eb; + + public MuteService(DiscordSocketClient client, DbService db, IEmbedBuilderService eb) + { + _client = client; + _db = db; + _eb = eb; + + using (var uow = db.GetDbContext()) + { + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set() + .AsNoTracking() + .AsSplitQuery() + .Include(x => x.MutedUsers) + .Include(x => x.UnbanTimer) + .Include(x => x.UnmuteTimers) + .Include(x => x.UnroleTimer) + .Where(x => guildIds.Contains(x.GuildId)) + .ToList(); + + GuildMuteRoles = configs.Where(c => !string.IsNullOrWhiteSpace(c.MuteRoleName)) + .ToDictionary(c => c.GuildId, c => c.MuteRoleName) + .ToConcurrent(); + + MutedUsers = new(configs.ToDictionary(k => k.GuildId, + v => new ConcurrentHashSet(v.MutedUsers.Select(m => m.UserId)))); + + var max = TimeSpan.FromDays(49); + + foreach (var conf in configs) + { + foreach (var x in conf.UnmuteTimers) + { + TimeSpan after; + if (x.UnmuteAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow) + after = TimeSpan.FromMinutes(2); + else + { + var unmute = x.UnmuteAt - DateTime.UtcNow; + after = unmute > max ? max : unmute; + } + + StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Mute); + } + + foreach (var x in conf.UnbanTimer) + { + TimeSpan after; + if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow) + after = TimeSpan.FromMinutes(2); + else + { + var unban = x.UnbanAt - DateTime.UtcNow; + after = unban > max ? max : unban; + } + + StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Ban); + } + + foreach (var x in conf.UnroleTimer) + { + TimeSpan after; + if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow) + after = TimeSpan.FromMinutes(2); + else + { + var unban = x.UnbanAt - DateTime.UtcNow; + after = unban > max ? max : unban; + } + + StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.AddRole, x.RoleId); + } + } + + _client.UserJoined += Client_UserJoined; + } + + UserMuted += OnUserMuted; + UserUnmuted += OnUserUnmuted; + } + + private void OnUserMuted( + IGuildUser user, + IUser mod, + MuteType type, + string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + return; + + _ = Task.Run(() => user.SendMessageAsync(embed: _eb.Create() + .WithDescription( + $"You've been muted in {user.Guild} server") + .AddField("Mute Type", type.ToString()) + .AddField("Moderator", mod.ToString()) + .AddField("Reason", reason) + .Build())); + } + + private void OnUserUnmuted( + IGuildUser user, + IUser mod, + MuteType type, + string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + return; + + _ = Task.Run(() => user.SendMessageAsync(embed: _eb.Create() + .WithDescription( + $"You've been unmuted in {user.Guild} server") + .AddField("Unmute Type", type.ToString()) + .AddField("Moderator", mod.ToString()) + .AddField("Reason", reason) + .Build())); + } + + private Task Client_UserJoined(IGuildUser usr) + { + try + { + MutedUsers.TryGetValue(usr.Guild.Id, out var muted); + + if (muted is null || !muted.Contains(usr.Id)) + return Task.CompletedTask; + _ = Task.Run(() => MuteUser(usr, _client.CurrentUser, reason: "Sticky mute")); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in MuteService UserJoined event"); + } + + return Task.CompletedTask; + } + + public async Task SetMuteRoleAsync(ulong guildId, string name) + { + await using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + config.MuteRoleName = name; + GuildMuteRoles.AddOrUpdate(guildId, name, (_, _) => name); + await uow.SaveChangesAsync(); + } + + public async Task MuteUser( + IGuildUser usr, + IUser mod, + MuteType type = MuteType.All, + string reason = "") + { + if (type == MuteType.All) + { + try { await usr.ModifyAsync(x => x.Mute = true); } + catch { } + + var muteRole = await GetMuteRole(usr.Guild); + if (!usr.RoleIds.Contains(muteRole.Id)) + await usr.AddRoleAsync(muteRole); + StopTimer(usr.GuildId, usr.Id, TimerType.Mute); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(usr.Guild.Id, + set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers)); + config.MutedUsers.Add(new() + { + UserId = usr.Id + }); + if (MutedUsers.TryGetValue(usr.Guild.Id, out var muted)) + muted.Add(usr.Id); + + config.UnmuteTimers.RemoveWhere(x => x.UserId == usr.Id); + + await uow.SaveChangesAsync(); + } + + UserMuted(usr, mod, MuteType.All, reason); + } + else if (type == MuteType.Voice) + { + try + { + await usr.ModifyAsync(x => x.Mute = true); + UserMuted(usr, mod, MuteType.Voice, reason); + } + catch { } + } + else if (type == MuteType.Chat) + { + await usr.AddRoleAsync(await GetMuteRole(usr.Guild)); + UserMuted(usr, mod, MuteType.Chat, reason); + } + } + + public async Task UnmuteUser( + ulong guildId, + ulong usrId, + IUser mod, + MuteType type = MuteType.All, + string reason = "") + { + var usr = _client.GetGuild(guildId)?.GetUser(usrId); + if (type == MuteType.All) + { + StopTimer(guildId, usrId, TimerType.Mute); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guildId, + set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers)); + var match = new MutedUserId + { + UserId = usrId + }; + var toRemove = config.MutedUsers.FirstOrDefault(x => x.Equals(match)); + if (toRemove is not null) + uow.Remove(toRemove); + if (MutedUsers.TryGetValue(guildId, out var muted)) + muted.TryRemove(usrId); + + config.UnmuteTimers.RemoveWhere(x => x.UserId == usrId); + + await uow.SaveChangesAsync(); + } + + if (usr is not null) + { + try { await usr.ModifyAsync(x => x.Mute = false); } + catch { } + + try { await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild)); } + catch + { + /*ignore*/ + } + + UserUnmuted(usr, mod, MuteType.All, reason); + } + } + else if (type == MuteType.Voice) + { + if (usr is null) + return; + try + { + await usr.ModifyAsync(x => x.Mute = false); + UserUnmuted(usr, mod, MuteType.Voice, reason); + } + catch { } + } + else if (type == MuteType.Chat) + { + if (usr is null) + return; + await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild)); + UserUnmuted(usr, mod, MuteType.Chat, reason); + } + } + + public async Task GetMuteRole(IGuild guild) + { + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + const string defaultMuteRoleName = "nadeko-mute"; + + var muteRoleName = GuildMuteRoles.GetOrAdd(guild.Id, defaultMuteRoleName); + + var muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName); + if (muteRole is null) + //if it doesn't exist, create it + { + try { muteRole = await guild.CreateRoleAsync(muteRoleName, isMentionable: false); } + catch + { + //if creations fails, maybe the name is not correct, find default one, if doesn't work, create default one + muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName) + ?? await guild.CreateRoleAsync(defaultMuteRoleName, isMentionable: false); + } + } + + foreach (var toOverwrite in await guild.GetTextChannelsAsync()) + { + try + { + if (!toOverwrite.PermissionOverwrites.Any(x => x.TargetId == muteRole.Id + && x.TargetType == PermissionTarget.Role)) + { + await toOverwrite.AddPermissionOverwriteAsync(muteRole, _denyOverwrite); + + await Task.Delay(200); + } + } + catch + { + // ignored + } + } + + return muteRole; + } + + public async Task TimedMute( + IGuildUser user, + IUser mod, + TimeSpan after, + MuteType muteType = MuteType.All, + string reason = "") + { + await MuteUser(user, mod, muteType, reason); // mute the user. This will also remove any previous unmute timers + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnmuteTimers)); + config.UnmuteTimers.Add(new() + { + UserId = user.Id, + UnmuteAt = DateTime.UtcNow + after + }); // add teh unmute timer to the database + uow.SaveChanges(); + } + + StartUn_Timer(user.GuildId, user.Id, after, TimerType.Mute); // start the timer + } + + public async Task TimedBan( + IGuild guild, + ulong userId, + TimeSpan after, + string reason, + int pruneDays) + { + await guild.AddBanAsync(userId, pruneDays, reason); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.UnbanTimer)); + config.UnbanTimer.Add(new() + { + UserId = userId, + UnbanAt = DateTime.UtcNow + after + }); // add teh unmute timer to the database + await uow.SaveChangesAsync(); + } + + StartUn_Timer(guild.Id, userId, after, TimerType.Ban); // start the timer + } + + public async Task TimedRole( + IGuildUser user, + TimeSpan after, + string reason, + IRole role) + { + await user.AddRoleAsync(role); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnroleTimer)); + config.UnroleTimer.Add(new() + { + UserId = user.Id, + UnbanAt = DateTime.UtcNow + after, + RoleId = role.Id + }); // add teh unmute timer to the database + uow.SaveChanges(); + } + + StartUn_Timer(user.GuildId, user.Id, after, TimerType.AddRole, role.Id); // start the timer + } + + public void StartUn_Timer( + ulong guildId, + ulong userId, + TimeSpan after, + TimerType type, + ulong? roleId = null) + { + //load the unmute timers for this guild + var userUnTimers = UnTimers.GetOrAdd(guildId, new ConcurrentDictionary<(ulong, TimerType), Timer>()); + + //unmute timer to be added + var toAdd = new Timer(async _ => + { + if (type == TimerType.Ban) + { + try + { + RemoveTimerFromDb(guildId, userId, type); + StopTimer(guildId, userId, type); + var guild = _client.GetGuild(guildId); // load the guild + if (guild is not null) + await guild.RemoveBanAsync(userId); + } + catch (Exception ex) + { + Log.Warning(ex, "Couldn't unban user {UserId} in guild {GuildId}", userId, guildId); + } + } + else if (type == TimerType.AddRole) + { + try + { + if (roleId is null) + return; + + RemoveTimerFromDb(guildId, userId, type); + StopTimer(guildId, userId, type); + var guild = _client.GetGuild(guildId); + var user = guild?.GetUser(userId); + var role = guild?.GetRole(roleId.Value); + if (guild is not null && user is not null && user.Roles.Contains(role)) + await user.RemoveRoleAsync(role); + } + catch (Exception ex) + { + Log.Warning(ex, "Couldn't remove role from user {UserId} in guild {GuildId}", userId, guildId); + } + } + else + { + try + { + // unmute the user, this will also remove the timer from the db + await UnmuteUser(guildId, userId, _client.CurrentUser, reason: "Timed mute expired"); + } + catch (Exception ex) + { + RemoveTimerFromDb(guildId, userId, type); // if unmute errored, just remove unmute from db + Log.Warning(ex, "Couldn't unmute user {UserId} in guild {GuildId}", userId, guildId); + } + } + }, + null, + after, + Timeout.InfiniteTimeSpan); + + //add it, or stop the old one and add this one + userUnTimers.AddOrUpdate((userId, type), + _ => toAdd, + (_, old) => + { + old.Change(Timeout.Infinite, Timeout.Infinite); + return toAdd; + }); + } + + public void StopTimer(ulong guildId, ulong userId, TimerType type) + { + if (!UnTimers.TryGetValue(guildId, out var userTimer)) + return; + + if (userTimer.TryRemove((userId, type), out var removed)) + removed.Change(Timeout.Infinite, Timeout.Infinite); + } + + private void RemoveTimerFromDb(ulong guildId, ulong userId, TimerType type) + { + using var uow = _db.GetDbContext(); + object toDelete; + if (type == TimerType.Mute) + { + var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnmuteTimers)); + toDelete = config.UnmuteTimers.FirstOrDefault(x => x.UserId == userId); + } + else + { + var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnbanTimer)); + toDelete = config.UnbanTimer.FirstOrDefault(x => x.UserId == userId); + } + + if (toDelete is not null) + uow.Remove(toDelete); + uow.SaveChanges(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/PermOverrides/DiscordPermOverrideCommands.cs b/src/Ellie.Bot.Modules.Administration/PermOverrides/DiscordPermOverrideCommands.cs new file mode 100644 index 0000000..896ce3b --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/PermOverrides/DiscordPermOverrideCommands.cs @@ -0,0 +1,80 @@ +#nullable disable +using Ellie.Common.TypeReaders; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class DiscordPermOverrideCommands : EllieModule + { + // override stats, it should require that the user has managessages guild permission + // .po 'stats' add user guild managemessages + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverride(CommandOrExprInfo cmd, params GuildPerm[] perms) + { + if (perms is null || perms.Length == 0) + { + await _service.RemoveOverride(ctx.Guild.Id, cmd.Name); + await ReplyConfirmLocalizedAsync(strs.perm_override_reset); + return; + } + + var aggregatePerms = perms.Aggregate((acc, seed) => seed | acc); + await _service.AddOverride(ctx.Guild.Id, cmd.Name, aggregatePerms); + + await ReplyConfirmLocalizedAsync(strs.perm_override(Format.Bold(aggregatePerms.ToString()), + Format.Code(cmd.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverrideReset() + { + var result = await PromptUserConfirmAsync(_eb.Create() + .WithOkColor() + .WithDescription(GetText(strs.perm_override_all_confirm))); + + if (!result) + return; + + await _service.ClearAllOverrides(ctx.Guild.Id); + + await ReplyConfirmLocalizedAsync(strs.perm_override_all); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverrideList(int page = 1) + { + if (--page < 0) + return; + + var overrides = await _service.GetAllOverrides(ctx.Guild.Id); + + await ctx.SendPaginatedConfirmAsync(page, + curPage => + { + var eb = _eb.Create().WithTitle(GetText(strs.perm_overrides)).WithOkColor(); + + var thisPageOverrides = overrides.Skip(9 * curPage).Take(9).ToList(); + + if (thisPageOverrides.Count == 0) + eb.WithDescription(GetText(strs.perm_override_page_none)); + else + { + eb.WithDescription(thisPageOverrides.Select(ov => $"{ov.Command} => {ov.Perm.ToString()}") + .Join("\n")); + } + + return eb; + }, + overrides.Count, + 9); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/PlayingRotate/PlayingRotateCommands.cs b/src/Ellie.Bot.Modules.Administration/PlayingRotate/PlayingRotateCommands.cs new file mode 100644 index 0000000..4b82dbf --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/PlayingRotate/PlayingRotateCommands.cs @@ -0,0 +1,60 @@ +#nullable disable +using Ellie.Modules.Administration.Services; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PlayingRotateCommands : EllieModule + { + [Cmd] + [OwnerOnly] + public async Task RotatePlaying() + { + if (_service.ToggleRotatePlaying()) + await ReplyConfirmLocalizedAsync(strs.ropl_enabled); + else + await ReplyConfirmLocalizedAsync(strs.ropl_disabled); + } + + [Cmd] + [OwnerOnly] + public async Task AddPlaying(ActivityType t, [Leftover] string status) + { + await _service.AddPlaying(t, status); + + await ReplyConfirmLocalizedAsync(strs.ropl_added); + } + + [Cmd] + [OwnerOnly] + public async Task ListPlaying() + { + var statuses = _service.GetRotatingStatuses(); + + if (!statuses.Any()) + await ReplyErrorLocalizedAsync(strs.ropl_not_set); + else + { + var i = 1; + await ReplyConfirmLocalizedAsync(strs.ropl_list(string.Join("\n\t", + statuses.Select(rs => $"`{i++}.` *{rs.Type}* {rs.Status}")))); + } + } + + [Cmd] + [OwnerOnly] + public async Task RemovePlaying(int index) + { + index -= 1; + + var msg = await _service.RemovePlayingAsync(index); + + if (msg is null) + return; + + await ReplyConfirmLocalizedAsync(strs.reprm(msg)); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/PlayingRotate/PlayingRotateService.cs b/src/Ellie.Bot.Modules.Administration/PlayingRotate/PlayingRotateService.cs new file mode 100644 index 0000000..8bce6e5 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/PlayingRotate/PlayingRotateService.cs @@ -0,0 +1,109 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration.Services; + +public sealed class PlayingRotateService : IEService, IReadyExecutor +{ + private readonly BotConfigService _bss; + private readonly SelfService _selfService; + private readonly Replacer _rep; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public PlayingRotateService( + DiscordSocketClient client, + DbService db, + BotConfigService bss, + IEnumerable phProviders, + SelfService selfService) + { + _db = db; + _bss = bss; + _selfService = selfService; + _client = client; + + if (client.ShardId == 0) + _rep = new ReplacementBuilder().WithClient(client).WithProviders(phProviders).Build(); + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + var index = 0; + while (await timer.WaitForNextTickAsync()) + { + try + { + if (!_bss.Data.RotateStatuses) + continue; + + IReadOnlyList rotatingStatuses; + await using (var uow = _db.GetDbContext()) + { + rotatingStatuses = uow.Set().AsNoTracking().OrderBy(x => x.Id).ToList(); + } + + if (rotatingStatuses.Count == 0) + continue; + + var playingStatus = index >= rotatingStatuses.Count + ? rotatingStatuses[index = 0] + : rotatingStatuses[index++]; + + var statusText = _rep.Replace(playingStatus.Status); + await _selfService.SetGameAsync(statusText, (ActivityType)playingStatus.Type); + } + catch (Exception ex) + { + Log.Warning(ex, "Rotating playing status errored: {ErrorMessage}", ex.Message); + } + } + } + + public async Task RemovePlayingAsync(int index) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(index)); + + await using var uow = _db.GetDbContext(); + var toRemove = await uow.Set().AsQueryable().AsNoTracking().Skip(index).FirstOrDefaultAsync(); + + if (toRemove is null) + return null; + + uow.Remove(toRemove); + await uow.SaveChangesAsync(); + return toRemove.Status; + } + + public async Task AddPlaying(ActivityType activityType, string status) + { + await using var uow = _db.GetDbContext(); + var toAdd = new RotatingPlayingStatus + { + Status = status, + Type = (Ellie.Bot.Db.ActivityType)activityType + }; + uow.Add(toAdd); + await uow.SaveChangesAsync(); + } + + public bool ToggleRotatePlaying() + { + var enabled = false; + _bss.ModifyConfig(bs => { enabled = bs.RotateStatuses = !bs.RotateStatuses; }); + return enabled; + } + + public IReadOnlyList GetRotatingStatuses() + { + using var uow = _db.GetDbContext(); + return uow.Set().AsNoTracking().ToList(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/PrefixCommands.cs b/src/Ellie.Bot.Modules.Administration/PrefixCommands.cs new file mode 100644 index 0000000..e78c208 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/PrefixCommands.cs @@ -0,0 +1,57 @@ +#nullable disable +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PrefixCommands : EllieModule + { + public enum Set + { + Set + } + + [Cmd] + [Priority(1)] + public async Task Prefix() + => await ReplyConfirmLocalizedAsync(strs.prefix_current(Format.Code(_cmdHandler.GetPrefix(ctx.Guild)))); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task Prefix(Set _, [Leftover] string newPrefix) + => Prefix(newPrefix); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public async Task Prefix([Leftover] string toSet) + { + if (string.IsNullOrWhiteSpace(prefix)) + return; + + var oldPrefix = prefix; + var newPrefix = _cmdHandler.SetPrefix(ctx.Guild, toSet); + + await ReplyConfirmLocalizedAsync(strs.prefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))); + } + + [Cmd] + [OwnerOnly] + public async Task DefPrefix([Leftover] string toSet = null) + { + if (string.IsNullOrWhiteSpace(toSet)) + { + await ReplyConfirmLocalizedAsync(strs.defprefix_current(_cmdHandler.GetPrefix())); + return; + } + + var oldPrefix = _cmdHandler.GetPrefix(); + var newPrefix = _cmdHandler.SetDefaultPrefix(toSet); + + await ReplyConfirmLocalizedAsync(strs.defprefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Protection/ProtectionCommands.cs b/src/Ellie.Bot.Modules.Administration/Protection/ProtectionCommands.cs new file mode 100644 index 0000000..7820e68 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Protection/ProtectionCommands.cs @@ -0,0 +1,288 @@ +#nullable disable +using Ellie.Common.TypeReaders.Models; +using Ellie.Modules.Administration.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class ProtectionCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntiAlt() + { + if (await _service.TryStopAntiAlt(ctx.Guild.Id)) + { + await ReplyConfirmLocalizedAsync(strs.prot_disable("Anti-Alt")); + return; + } + + await ReplyConfirmLocalizedAsync(strs.protection_not_running("Anti-Alt")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntiAlt( + StoopidTime minAge, + PunishmentAction action, + [Leftover] StoopidTime punishTime = null) + { + var minAgeMinutes = (int)minAge.Time.TotalMinutes; + var punishTimeMinutes = (int?)punishTime?.Time.TotalMinutes ?? 0; + + if (minAgeMinutes < 1 || punishTimeMinutes < 0) + return; + + var minutes = (int?)punishTime?.Time.TotalMinutes ?? 0; + if (action is PunishmentAction.TimeOut && minutes < 1) + minutes = 1; + + await _service.StartAntiAltAsync(ctx.Guild.Id, + minAgeMinutes, + action, + minutes); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntiAlt(StoopidTime minAge, PunishmentAction action, [Leftover] IRole role) + { + var minAgeMinutes = (int)minAge.Time.TotalMinutes; + + if (minAgeMinutes < 1) + return; + + if (action == PunishmentAction.TimeOut) + return; + + await _service.StartAntiAltAsync(ctx.Guild.Id, minAgeMinutes, action, roleId: role.Id); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public Task AntiRaid() + { + if (_service.TryStopAntiRaid(ctx.Guild.Id)) + return ReplyConfirmLocalizedAsync(strs.prot_disable("Anti-Raid")); + return ReplyPendingLocalizedAsync(strs.protection_not_running("Anti-Raid")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public Task AntiRaid( + int userThreshold, + int seconds, + PunishmentAction action, + [Leftover] StoopidTime punishTime) + => InternalAntiRaid(userThreshold, seconds, action, punishTime); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(2)] + public Task AntiRaid(int userThreshold, int seconds, PunishmentAction action) + => InternalAntiRaid(userThreshold, seconds, action); + + private async Task InternalAntiRaid( + int userThreshold, + int seconds = 10, + PunishmentAction action = PunishmentAction.Mute, + StoopidTime punishTime = null) + { + if (action == PunishmentAction.AddRole) + { + await ReplyErrorLocalizedAsync(strs.punishment_unsupported(action)); + return; + } + + if (userThreshold is < 2 or > 30) + { + await ReplyErrorLocalizedAsync(strs.raid_cnt(2, 30)); + return; + } + + if (seconds is < 2 or > 300) + { + await ReplyErrorLocalizedAsync(strs.raid_time(2, 300)); + return; + } + + if (punishTime is not null) + { + if (!_service.IsDurationAllowed(action)) + await ReplyErrorLocalizedAsync(strs.prot_cant_use_time); + } + + var time = (int?)punishTime?.Time.TotalMinutes ?? 0; + if (time is < 0 or > 60 * 24) + return; + + if(action is PunishmentAction.TimeOut && time < 1) + return; + + var stats = await _service.StartAntiRaidAsync(ctx.Guild.Id, userThreshold, seconds, action, time); + + if (stats is null) + return; + + await SendConfirmAsync(GetText(strs.prot_enable("Anti-Raid")), + $"{ctx.User.Mention} {GetAntiRaidString(stats)}"); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public Task AntiSpam() + { + if (_service.TryStopAntiSpam(ctx.Guild.Id)) + return ReplyConfirmLocalizedAsync(strs.prot_disable("Anti-Spam")); + return ReplyPendingLocalizedAsync(strs.protection_not_running("Anti-Spam")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] IRole role) + { + if (action != PunishmentAction.AddRole) + return Task.CompletedTask; + + return InternalAntiSpam(messageCount, action, null, role); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] StoopidTime punishTime) + => InternalAntiSpam(messageCount, action, punishTime); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(2)] + public Task AntiSpam(int messageCount, PunishmentAction action) + => InternalAntiSpam(messageCount, action); + + private async Task InternalAntiSpam( + int messageCount, + PunishmentAction action, + StoopidTime timeData = null, + IRole role = null) + { + if (messageCount is < 2 or > 10) + return; + + if (timeData is not null) + { + if (!_service.IsDurationAllowed(action)) + await ReplyErrorLocalizedAsync(strs.prot_cant_use_time); + } + + var time = (int?)timeData?.Time.TotalMinutes ?? 0; + if (time is < 0 or > 60 * 24) + return; + + if (action is PunishmentAction.TimeOut && time < 1) + return; + + var stats = await _service.StartAntiSpamAsync(ctx.Guild.Id, messageCount, action, time, role?.Id); + + await SendConfirmAsync(GetText(strs.prot_enable("Anti-Spam")), + $"{ctx.User.Mention} {GetAntiSpamString(stats)}"); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntispamIgnore() + { + var added = await _service.AntiSpamIgnoreAsync(ctx.Guild.Id, ctx.Channel.Id); + + if (added is null) + { + await ReplyErrorLocalizedAsync(strs.protection_not_running("Anti-Spam")); + return; + } + + if (added.Value) + await ReplyConfirmLocalizedAsync(strs.spam_ignore("Anti-Spam")); + else + await ReplyConfirmLocalizedAsync(strs.spam_not_ignore("Anti-Spam")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AntiList() + { + var (spam, raid, alt) = _service.GetAntiStats(ctx.Guild.Id); + + if (spam is null && raid is null && alt is null) + { + await ReplyConfirmLocalizedAsync(strs.prot_none); + return; + } + + var embed = _eb.Create().WithOkColor().WithTitle(GetText(strs.prot_active)); + + if (spam is not null) + embed.AddField("Anti-Spam", GetAntiSpamString(spam).TrimTo(1024), true); + + if (raid is not null) + embed.AddField("Anti-Raid", GetAntiRaidString(raid).TrimTo(1024), true); + + if (alt is not null) + embed.AddField("Anti-Alt", GetAntiAltString(alt), true); + + await ctx.Channel.EmbedAsync(embed); + } + + private string GetAntiAltString(AntiAltStats alt) + => GetText(strs.anti_alt_status(Format.Bold(alt.MinAge.ToString(@"dd\d\ hh\h\ mm\m\ ")), + Format.Bold(alt.Action.ToString()), + Format.Bold(alt.Counter.ToString()))); + + private string GetAntiSpamString(AntiSpamStats stats) + { + var settings = stats.AntiSpamSettings; + var ignoredString = string.Join(", ", settings.IgnoredChannels.Select(c => $"<#{c.ChannelId}>")); + + if (string.IsNullOrWhiteSpace(ignoredString)) + ignoredString = "none"; + + var add = string.Empty; + if (settings.MuteTime > 0) + add = $" ({TimeSpan.FromMinutes(settings.MuteTime):hh\\hmm\\m})"; + + return GetText(strs.spam_stats(Format.Bold(settings.MessageThreshold.ToString()), + Format.Bold(settings.Action + add), + ignoredString)); + } + + private string GetAntiRaidString(AntiRaidStats stats) + { + var actionString = Format.Bold(stats.AntiRaidSettings.Action.ToString()); + + if (stats.AntiRaidSettings.PunishDuration > 0) + actionString += $" **({TimeSpan.FromMinutes(stats.AntiRaidSettings.PunishDuration):hh\\hmm\\m})**"; + + return GetText(strs.raid_stats(Format.Bold(stats.AntiRaidSettings.UserThreshold.ToString()), + Format.Bold(stats.AntiRaidSettings.Seconds.ToString()), + actionString)); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Protection/ProtectionService.cs b/src/Ellie.Bot.Modules.Administration/Protection/ProtectionService.cs new file mode 100644 index 0000000..bee9110 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Protection/ProtectionService.cs @@ -0,0 +1,499 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Services.Database.Models; +using System.Threading.Channels; + +namespace Ellie.Modules.Administration.Services; + +public class ProtectionService : IEService +{ + public event Func OnAntiProtectionTriggered = delegate + { + return Task.CompletedTask; + }; + + private readonly ConcurrentDictionary _antiRaidGuilds = new(); + + private readonly ConcurrentDictionary _antiSpamGuilds = new(); + + private readonly ConcurrentDictionary _antiAltGuilds = new(); + + private readonly DiscordSocketClient _client; + private readonly MuteService _mute; + private readonly DbService _db; + private readonly UserPunishService _punishService; + + private readonly Channel _punishUserQueue = + Channel.CreateUnbounded(new() + { + SingleReader = true, + SingleWriter = false + }); + + public ProtectionService( + DiscordSocketClient client, + IBot bot, + MuteService mute, + DbService db, + UserPunishService punishService) + { + _client = client; + _mute = mute; + _db = db; + _punishService = punishService; + + var ids = client.GetGuildIds(); + using (var uow = db.GetDbContext()) + { + var configs = uow.Set() + .AsQueryable() + .Include(x => x.AntiRaidSetting) + .Include(x => x.AntiSpamSetting) + .ThenInclude(x => x.IgnoredChannels) + .Include(x => x.AntiAltSetting) + .Where(x => ids.Contains(x.GuildId)) + .ToList(); + + foreach (var gc in configs) + Initialize(gc); + } + + _client.MessageReceived += HandleAntiSpam; + _client.UserJoined += HandleUserJoined; + + bot.JoinedGuild += _bot_JoinedGuild; + _client.LeftGuild += _client_LeftGuild; + + _ = Task.Run(RunQueue); + } + + private async Task RunQueue() + { + while (true) + { + var item = await _punishUserQueue.Reader.ReadAsync(); + + var muteTime = item.MuteTime; + var gu = item.User; + try + { + await _punishService.ApplyPunishment(gu.Guild, + gu, + _client.CurrentUser, + item.Action, + muteTime, + item.RoleId, + $"{item.Type} Protection"); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in punish queue: {Message}", ex.Message); + } + finally + { + await Task.Delay(1000); + } + } + } + + private Task _client_LeftGuild(SocketGuild guild) + { + _ = Task.Run(async () => + { + TryStopAntiRaid(guild.Id); + TryStopAntiSpam(guild.Id); + await TryStopAntiAlt(guild.Id); + }); + return Task.CompletedTask; + } + + private Task _bot_JoinedGuild(GuildConfig gc) + { + using var uow = _db.GetDbContext(); + var gcWithData = uow.GuildConfigsForId(gc.GuildId, + set => set.Include(x => x.AntiRaidSetting) + .Include(x => x.AntiAltSetting) + .Include(x => x.AntiSpamSetting) + .ThenInclude(x => x.IgnoredChannels)); + + Initialize(gcWithData); + return Task.CompletedTask; + } + + private void Initialize(GuildConfig gc) + { + var raid = gc.AntiRaidSetting; + var spam = gc.AntiSpamSetting; + + if (raid is not null) + { + var raidStats = new AntiRaidStats + { + AntiRaidSettings = raid + }; + _antiRaidGuilds[gc.GuildId] = raidStats; + } + + if (spam is not null) + { + _antiSpamGuilds[gc.GuildId] = new() + { + AntiSpamSettings = spam + }; + } + + var alt = gc.AntiAltSetting; + if (alt is not null) + _antiAltGuilds[gc.GuildId] = new(alt); + } + + private Task HandleUserJoined(SocketGuildUser user) + { + if (user.IsBot) + return Task.CompletedTask; + + _antiRaidGuilds.TryGetValue(user.Guild.Id, out var maybeStats); + _antiAltGuilds.TryGetValue(user.Guild.Id, out var maybeAlts); + + if (maybeStats is null && maybeAlts is null) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + if (maybeAlts is { } alts) + { + if (user.CreatedAt != default) + { + var diff = DateTime.UtcNow - user.CreatedAt.UtcDateTime; + if (diff < alts.MinAge) + { + alts.Increment(); + + await PunishUsers(alts.Action, + ProtectionType.Alting, + alts.ActionDurationMinutes, + alts.RoleId, + user); + + return; + } + } + } + + try + { + if (maybeStats is not { } stats || !stats.RaidUsers.Add(user)) + return; + + ++stats.UsersCount; + + if (stats.UsersCount >= stats.AntiRaidSettings.UserThreshold) + { + var users = stats.RaidUsers.ToArray(); + stats.RaidUsers.Clear(); + var settings = stats.AntiRaidSettings; + + await PunishUsers(settings.Action, ProtectionType.Raiding, settings.PunishDuration, null, users); + } + + await Task.Delay(1000 * stats.AntiRaidSettings.Seconds); + + stats.RaidUsers.TryRemove(user); + --stats.UsersCount; + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task HandleAntiSpam(SocketMessage arg) + { + if (arg is not SocketUserMessage msg || msg.Author.IsBot) + return Task.CompletedTask; + + if (msg.Channel is not ITextChannel channel) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + if (!_antiSpamGuilds.TryGetValue(channel.Guild.Id, out var spamSettings) + || spamSettings.AntiSpamSettings.IgnoredChannels.Contains(new() + { + ChannelId = channel.Id + })) + return; + + var stats = spamSettings.UserStats.AddOrUpdate(msg.Author.Id, + _ => new(msg), + (_, old) => + { + old.ApplyNextMessage(msg); + return old; + }); + + if (stats.Count >= spamSettings.AntiSpamSettings.MessageThreshold) + { + if (spamSettings.UserStats.TryRemove(msg.Author.Id, out stats)) + { + var settings = spamSettings.AntiSpamSettings; + await PunishUsers(settings.Action, + ProtectionType.Spamming, + settings.MuteTime, + settings.RoleId, + (IGuildUser)msg.Author); + } + } + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private async Task PunishUsers( + PunishmentAction action, + ProtectionType pt, + int muteTime, + ulong? roleId, + params IGuildUser[] gus) + { + Log.Information("[{PunishType}] - Punishing [{Count}] users with [{PunishAction}] in {GuildName} guild", + pt, + gus.Length, + action, + gus[0].Guild.Name); + + foreach (var gu in gus) + { + await _punishUserQueue.Writer.WriteAsync(new() + { + Action = action, + Type = pt, + User = gu, + MuteTime = muteTime, + RoleId = roleId + }); + } + + _ = OnAntiProtectionTriggered(action, pt, gus); + } + + public async Task StartAntiRaidAsync( + ulong guildId, + int userThreshold, + int seconds, + PunishmentAction action, + int minutesDuration) + { + var g = _client.GetGuild(guildId); + await _mute.GetMuteRole(g); + + if (action == PunishmentAction.AddRole) + return null; + + if (!IsDurationAllowed(action)) + minutesDuration = 0; + + var stats = new AntiRaidStats + { + AntiRaidSettings = new() + { + Action = action, + Seconds = seconds, + UserThreshold = userThreshold, + PunishDuration = minutesDuration + } + }; + + _antiRaidGuilds.AddOrUpdate(guildId, stats, (_, _) => stats); + + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting)); + + gc.AntiRaidSetting = stats.AntiRaidSettings; + await uow.SaveChangesAsync(); + + return stats; + } + + public bool TryStopAntiRaid(ulong guildId) + { + if (_antiRaidGuilds.TryRemove(guildId, out _)) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting)); + + gc.AntiRaidSetting = null; + uow.SaveChanges(); + return true; + } + + return false; + } + + public bool TryStopAntiSpam(ulong guildId) + { + if (_antiSpamGuilds.TryRemove(guildId, out _)) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels)); + + gc.AntiSpamSetting = null; + uow.SaveChanges(); + return true; + } + + return false; + } + + public async Task StartAntiSpamAsync( + ulong guildId, + int messageCount, + PunishmentAction action, + int punishDurationMinutes, + ulong? roleId) + { + var g = _client.GetGuild(guildId); + await _mute.GetMuteRole(g); + + if (!IsDurationAllowed(action)) + punishDurationMinutes = 0; + + var stats = new AntiSpamStats + { + AntiSpamSettings = new() + { + Action = action, + MessageThreshold = messageCount, + MuteTime = punishDurationMinutes, + RoleId = roleId + } + }; + + stats = _antiSpamGuilds.AddOrUpdate(guildId, + stats, + (_, old) => + { + stats.AntiSpamSettings.IgnoredChannels = old.AntiSpamSettings.IgnoredChannels; + return stats; + }); + + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiSpamSetting)); + + if (gc.AntiSpamSetting is not null) + { + gc.AntiSpamSetting.Action = stats.AntiSpamSettings.Action; + gc.AntiSpamSetting.MessageThreshold = stats.AntiSpamSettings.MessageThreshold; + gc.AntiSpamSetting.MuteTime = stats.AntiSpamSettings.MuteTime; + gc.AntiSpamSetting.RoleId = stats.AntiSpamSettings.RoleId; + } + else + gc.AntiSpamSetting = stats.AntiSpamSettings; + + await uow.SaveChangesAsync(); + return stats; + } + + public async Task AntiSpamIgnoreAsync(ulong guildId, ulong channelId) + { + var obj = new AntiSpamIgnore + { + ChannelId = channelId + }; + bool added; + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels)); + var spam = gc.AntiSpamSetting; + if (spam is null) + return null; + + if (spam.IgnoredChannels.Add(obj)) // if adding to db is successful + { + if (_antiSpamGuilds.TryGetValue(guildId, out var temp)) + temp.AntiSpamSettings.IgnoredChannels.Add(obj); // add to local cache + + added = true; + } + else + { + var toRemove = spam.IgnoredChannels.First(x => x.ChannelId == channelId); + uow.Set().Remove(toRemove); // remove from db + if (_antiSpamGuilds.TryGetValue(guildId, out var temp)) + temp.AntiSpamSettings.IgnoredChannels.Remove(toRemove); // remove from local cache + + added = false; + } + + await uow.SaveChangesAsync(); + return added; + } + + public (AntiSpamStats, AntiRaidStats, AntiAltStats) GetAntiStats(ulong guildId) + { + _antiRaidGuilds.TryGetValue(guildId, out var antiRaidStats); + _antiSpamGuilds.TryGetValue(guildId, out var antiSpamStats); + _antiAltGuilds.TryGetValue(guildId, out var antiAltStats); + + return (antiSpamStats, antiRaidStats, antiAltStats); + } + + public bool IsDurationAllowed(PunishmentAction action) + { + switch (action) + { + case PunishmentAction.Ban: + case PunishmentAction.Mute: + case PunishmentAction.ChatMute: + case PunishmentAction.VoiceMute: + case PunishmentAction.AddRole: + case PunishmentAction.TimeOut: + return true; + default: + return false; + } + } + + public async Task StartAntiAltAsync( + ulong guildId, + int minAgeMinutes, + PunishmentAction action, + int actionDurationMinutes = 0, + ulong? roleId = null) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting)); + gc.AntiAltSetting = new() + { + Action = action, + ActionDurationMinutes = actionDurationMinutes, + MinAge = TimeSpan.FromMinutes(minAgeMinutes), + RoleId = roleId + }; + + await uow.SaveChangesAsync(); + _antiAltGuilds[guildId] = new(gc.AntiAltSetting); + } + + public async Task TryStopAntiAlt(ulong guildId) + { + if (!_antiAltGuilds.TryRemove(guildId, out _)) + return false; + + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting)); + gc.AntiAltSetting = null; + await uow.SaveChangesAsync(); + return true; + } +} diff --git a/src/Ellie.Bot.Modules.Administration/Protection/ProtectionStats.cs b/src/Ellie.Bot.Modules.Administration/Protection/ProtectionStats.cs new file mode 100644 index 0000000..7f4684d --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Protection/ProtectionStats.cs @@ -0,0 +1,52 @@ +#nullable disable +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration; + +public enum ProtectionType +{ + Raiding, + Spamming, + Alting +} + +public class AntiRaidStats +{ + public AntiRaidSetting AntiRaidSettings { get; set; } + public int UsersCount { get; set; } + public ConcurrentHashSet RaidUsers { get; set; } = new(); +} + +public class AntiSpamStats +{ + public AntiSpamSetting AntiSpamSettings { get; set; } + public ConcurrentDictionary UserStats { get; set; } = new(); +} + +public class AntiAltStats +{ + public PunishmentAction Action + => _setting.Action; + + public int ActionDurationMinutes + => _setting.ActionDurationMinutes; + + public ulong? RoleId + => _setting.RoleId; + + public TimeSpan MinAge + => _setting.MinAge; + + public int Counter + => counter; + + private readonly AntiAltSetting _setting; + + private int counter; + + public AntiAltStats(AntiAltSetting setting) + => _setting = setting; + + public void Increment() + => Interlocked.Increment(ref counter); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Protection/PunishQueueItem.cs b/src/Ellie.Bot.Modules.Administration/Protection/PunishQueueItem.cs new file mode 100644 index 0000000..59e5e69 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Protection/PunishQueueItem.cs @@ -0,0 +1,13 @@ +#nullable disable +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration; + +public class PunishQueueItem +{ + public PunishmentAction Action { get; set; } + public ProtectionType Type { get; set; } + public int MuteTime { get; set; } + public ulong? RoleId { get; set; } + public IGuildUser User { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Protection/UserSpamStats.cs b/src/Ellie.Bot.Modules.Administration/Protection/UserSpamStats.cs new file mode 100644 index 0000000..09a9be0 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Protection/UserSpamStats.cs @@ -0,0 +1,64 @@ +#nullable disable +namespace Ellie.Modules.Administration; + +public sealed class UserSpamStats +{ + public int Count + { + get + { + lock (_applyLock) + { + Cleanup(); + return _messageTracker.Count; + } + } + } + + private string lastMessage; + + private readonly Queue _messageTracker; + + private readonly object _applyLock = new(); + + private readonly TimeSpan _maxTime = TimeSpan.FromMinutes(30); + + public UserSpamStats(IUserMessage msg) + { + lastMessage = msg.Content.ToUpperInvariant(); + _messageTracker = new(); + + ApplyNextMessage(msg); + } + + public void ApplyNextMessage(IUserMessage message) + { + var upperMsg = message.Content.ToUpperInvariant(); + + lock (_applyLock) + { + if (upperMsg != lastMessage || (string.IsNullOrWhiteSpace(upperMsg) && message.Attachments.Any())) + { + // if it's a new message, reset spam counter + lastMessage = upperMsg; + _messageTracker.Clear(); + } + + _messageTracker.Enqueue(DateTime.UtcNow); + } + } + + private void Cleanup() + { + lock (_applyLock) + { + while (_messageTracker.TryPeek(out var dateTime)) + { + if (DateTime.UtcNow - dateTime < _maxTime) + break; + + _messageTracker.Dequeue(); + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Prune/PruneCommands.cs b/src/Ellie.Bot.Modules.Administration/Prune/PruneCommands.cs new file mode 100644 index 0000000..17e9791 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Prune/PruneCommands.cs @@ -0,0 +1,114 @@ +#nullable disable +using CommandLine; +using Ellie.Modules.Administration.Services; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PruneCommands : EllieModule + { + private static readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14); + + public sealed class PruneOptions : IEllieCommandOptions + { + [Option(shortName: 's', longName: "safe", Default = false, HelpText = "Whether pinned messages should be deleted.", Required = false)] + public bool Safe { get; set; } + + [Option(shortName: 'a', longName: "after", Default = null, HelpText = "Prune only messages after the specified message ID.", Required = false)] + public ulong? After { get; set; } + + public void NormalizeOptions() + { + } + } + + //deletes her own messages, no perm required + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task Prune(params string[] args) + { + var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args); + + var user = await ctx.Guild.GetCurrentUserAsync(); + + if (opts.Safe) + await _service.PruneWhere((ITextChannel)ctx.Channel, 100, x => x.Author.Id == user.Id && !x.IsPinned, opts.After); + else + await _service.PruneWhere((ITextChannel)ctx.Channel, 100, x => x.Author.Id == user.Id, opts.After); + + ctx.Message.DeleteAfter(3); + } + + // prune x + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + [EllieOptions] + [Priority(1)] + public async Task Prune(int count, params string[] args) + { + count++; + if (count < 1) + return; + if (count > 1000) + count = 1000; + + var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args); + + if (opts.Safe) + await _service.PruneWhere((ITextChannel)ctx.Channel, count, x => !x.IsPinned, opts.After); + else + await _service.PruneWhere((ITextChannel)ctx.Channel, count, _ => true, opts.After); + } + + //prune @user [x] + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + [EllieOptions] + [Priority(0)] + public Task Prune(IGuildUser user, int count = 100, params string[] args) + => Prune(user.Id, count, args); + + //prune userid [x] + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + [EllieOptions] + [Priority(0)] + public async Task Prune(ulong userId, int count = 100, params string[] args) + { + if (userId == ctx.User.Id) + count++; + + if (count < 1) + return; + + if (count > 1000) + count = 1000; + + var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args); + + if (opts.Safe) + { + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks && !m.IsPinned, + opts.After); + } + else + { + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks, + opts.After); + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Prune/PruneService.cs b/src/Ellie.Bot.Modules.Administration/Prune/PruneService.cs new file mode 100644 index 0000000..bc5e765 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Prune/PruneService.cs @@ -0,0 +1,89 @@ +#nullable disable +namespace Ellie.Modules.Administration.Services; + +public class PruneService : IEService +{ + //channelids where prunes are currently occuring + private readonly ConcurrentHashSet _pruningGuilds = new(); + private readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14); + private readonly ILogCommandService _logService; + + public PruneService(ILogCommandService logService) + => _logService = logService; + + public async Task PruneWhere(ITextChannel channel, int amount, Func predicate, ulong? after = null) + { + ArgumentNullException.ThrowIfNull(channel, nameof(channel)); + + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount)); + + if (!_pruningGuilds.Add(channel.GuildId)) + return; + + try + { + var now = DateTime.UtcNow; + IMessage[] msgs; + IMessage lastMessage = null; + var dled = await channel.GetMessagesAsync(50).FlattenAsync(); + + msgs = dled + .Where(predicate) + .Where(x => after is ulong a ? x.Id > a : true) + .Take(amount) + .ToArray(); + + while (amount > 0 && msgs.Any()) + { + lastMessage = msgs[^1]; + + var bulkDeletable = new List(); + var singleDeletable = new List(); + foreach (var x in msgs) + { + _logService.AddDeleteIgnore(x.Id); + + if (now - x.CreatedAt < _twoWeeks) + bulkDeletable.Add(x); + else + singleDeletable.Add(x); + } + + if (bulkDeletable.Count > 0) + { + await channel.DeleteMessagesAsync(bulkDeletable); + await Task.Delay(2000); + } + + foreach (var group in singleDeletable.Chunk(5)) + { + await group.Select(x => x.DeleteAsync()).WhenAll(); + await Task.Delay(5000); + } + + //this isn't good, because this still work as if i want to remove only specific user's messages from the last + //100 messages, Maybe this needs to be reduced by msgs.Length instead of 100 + amount -= 50; + if (amount > 0) + { + dled = await channel.GetMessagesAsync(lastMessage, Direction.Before, 50).FlattenAsync(); + + msgs = dled + .Where(predicate) + .Where(x => after is ulong a ? x.Id > a : true) + .Take(amount) + .ToArray(); + } + } + } + catch + { + //ignore + } + finally + { + _pruningGuilds.TryRemove(channel.GuildId); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Role/IReactionRoleService.cs b/src/Ellie.Bot.Modules.Administration/Role/IReactionRoleService.cs new file mode 100644 index 0000000..c4d2b97 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Role/IReactionRoleService.cs @@ -0,0 +1,52 @@ +#nullable disable +using Ellie.Modules.Patronage; +using Ellie.Services.Database.Models; +using OneOf; +using OneOf.Types; + +namespace Ellie.Modules.Administration.Services; + +public interface IReactionRoleService +{ + /// + /// Adds a single reaction role + /// + /// Guild where to add a reaction role + /// Message to which to add a reaction role + /// + /// + /// + /// + /// The result of the operation + Task> AddReactionRole( + IGuild guild, + IMessage msg, + string emote, + IRole role, + int group = 0, + int levelReq = 0); + + /// + /// Get all reaction roles on the specified server + /// + /// + /// + Task> GetReactionRolesAsync(ulong guildId); + + /// + /// Remove reaction roles on the specified message + /// + /// + /// + /// + Task RemoveReactionRoles(ulong guildId, ulong messageId); + + /// + /// Remove all reaction roles in the specified server + /// + /// + /// + Task RemoveAllReactionRoles(ulong guildId); + + Task> TransferReactionRolesAsync(ulong guildId, ulong fromMessageId, ulong toMessageId); +} diff --git a/src/Ellie.Bot.Modules.Administration/Role/ReactionRoleCommands.cs b/src/Ellie.Bot.Modules.Administration/Role/ReactionRoleCommands.cs new file mode 100644 index 0000000..a679aeb --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Role/ReactionRoleCommands.cs @@ -0,0 +1,171 @@ +using Ellie.Modules.Administration.Services; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + public partial class ReactionRoleCommands : EllieModule + { + private readonly IReactionRoleService _rero; + + public ReactionRoleCommands(IReactionRoleService rero) + { + _rero = rero; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReactionRoleAdd( + ulong messageId, + string emoteStr, + IRole role, + int group = 0, + int levelReq = 0) + { + if (group < 0) + return; + + if (levelReq < 0) + return; + + var msg = await ctx.Channel.GetMessageAsync(messageId); + if (msg is null) + { + await ReplyErrorLocalizedAsync(strs.not_found); + return; + } + + if (ctx.User.Id != ctx.Guild.OwnerId && ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position) <= role.Position) + { + await ReplyErrorLocalizedAsync(strs.hierarchy); + return; + } + + var emote = emoteStr.ToIEmote(); + await msg.AddReactionAsync(emote); + var res = await _rero.AddReactionRole(ctx.Guild, + msg, + emoteStr, + role, + group, + levelReq); + + await res.Match( + _ => ctx.OkAsync(), + fl => + { + _ = msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser); + return !fl.IsPatronLimit + ? ReplyErrorLocalizedAsync(strs.limit_reached(fl.Quota)) + : ReplyPendingLocalizedAsync(strs.feature_limit_reached_owner(fl.Quota, fl.Name)); + }); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReactionRolesList(int page = 1) + { + if (--page < 0) + return; + + var reros = await _rero.GetReactionRolesAsync(ctx.Guild.Id); + + await ctx.SendPaginatedConfirmAsync(page, curPage => + { + var embed = _eb.Create(ctx) + .WithOkColor(); + + var content = string.Empty; + foreach (var g in reros.OrderBy(x => x.Group) + .Skip(curPage * 10) + .GroupBy(x => x.MessageId) + .OrderBy(x => x.Key)) + { + var messageId = g.Key; + content += + $"[{messageId}](https://discord.com/channels/{ctx.Guild.Id}/{g.First().ChannelId}/{g.Key})\n"; + + var groupGroups = g.GroupBy(x => x.Group); + + foreach (var ggs in groupGroups) + { + content += $"`< {(g.Key == 0 ? ("Not Exclusive (Group 0)") : ($"Group {ggs.Key}"))} >`\n"; + + foreach (var rero in ggs) + { + content += + $"\t{rero.Emote} -> {(ctx.Guild.GetRole(rero.RoleId)?.Mention ?? "")}"; + if (rero.LevelReq > 0) + content += $" (lvl {rero.LevelReq}+)"; + content += '\n'; + } + } + + } + + embed.WithDescription(string.IsNullOrWhiteSpace(content) + ? "There are no reaction roles on this server" + : content); + + return embed; + }, reros.Count, 10); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReactionRolesRemove(ulong messageId) + { + var succ = await _rero.RemoveReactionRoles(ctx.Guild.Id, messageId); + if (succ) + await ctx.OkAsync(); + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReactionRolesDeleteAll() + { + await _rero.RemoveAllReactionRoles(ctx.Guild.Id); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Ratelimit(60)] + public async Task ReactionRolesTransfer(ulong fromMessageId, ulong toMessageId) + { + var msg = await ctx.Channel.GetMessageAsync(toMessageId); + + if (msg is null) + { + await ctx.ErrorAsync(); + return; + } + + var reactions = await _rero.TransferReactionRolesAsync(ctx.Guild.Id, fromMessageId, toMessageId); + + if (reactions.Count == 0) + { + await ctx.ErrorAsync(); + } + else + { + foreach (var r in reactions) + { + await msg.AddReactionAsync(r); + } + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Role/ReactionRolesService.cs b/src/Ellie.Bot.Modules.Administration/Role/ReactionRolesService.cs new file mode 100644 index 0000000..cdb0d25 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Role/ReactionRolesService.cs @@ -0,0 +1,396 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Modules.Patronage; +using Ellie.Services.Database.Models; +using OneOf.Types; +using OneOf; + +namespace Ellie.Modules.Administration.Services; + +public sealed class ReactionRolesService : IReadyExecutor, IEService, IReactionRoleService +{ + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + + private ConcurrentDictionary> _cache; + private readonly object _cacheLock = new(); + private readonly SemaphoreSlim _assignementLock = new(1, 1); + private readonly IPatronageService _ps; + + private static readonly FeatureLimitKey _reroFLKey = new() + { + Key = "rero:max_count", + PrettyName = "Reaction Role" + }; + + public ReactionRolesService( + DiscordSocketClient client, + DbService db, + IBotCredentials creds, + IPatronageService ps) + { + _db = db; + _ps = ps; + _client = client; + _creds = creds; + _cache = new(); + } + + public async Task OnReadyAsync() + { + await using var uow = _db.GetDbContext(); + var reros = await uow.GetTable() + .Where( + x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId)) + .ToListAsyncLinqToDB(); + + foreach (var group in reros.GroupBy(x => x.MessageId)) + { + _cache[group.Key] = group.ToList(); + } + + _client.ReactionAdded += ClientOnReactionAdded; + _client.ReactionRemoved += ClientOnReactionRemoved; + } + + private async Task<(IGuildUser, IRole)> GetUserAndRoleAsync( + ulong userId, + ReactionRoleV2 rero) + { + var guild = _client.GetGuild(rero.GuildId); + var role = guild?.GetRole(rero.RoleId); + + if (role is null) + return default; + + var user = guild.GetUser(userId) as IGuildUser + ?? await _client.Rest.GetGuildUserAsync(guild.Id, userId); + + if (user is null) + return default; + + return (user, role); + } + + private Task ClientOnReactionRemoved( + Cacheable cmsg, + Cacheable ch, + SocketReaction r) + { + if (!_cache.TryGetValue(cmsg.Id, out var reros)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var emote = await GetFixedEmoteAsync(cmsg, r.Emote); + + var rero = reros.FirstOrDefault(x => x.Emote == emote.Name + || x.Emote == emote.ToString()); + if (rero is null) + return; + + var (user, role) = await GetUserAndRoleAsync(r.UserId, rero); + + if (user.IsBot) + return; + + await _assignementLock.WaitAsync(); + try + { + if (user.RoleIds.Contains(role.Id)) + { + await user.RemoveRoleAsync(role.Id); + } + } + finally + { + _assignementLock.Release(); + } + }); + + return Task.CompletedTask; + } + + + // had to add this because for some reason, reactionremoved event's reaction doesn't have IsAnimated set, + // causing the .ToString() to be wrong on animated custom emotes + private async Task GetFixedEmoteAsync( + Cacheable cmsg, + IEmote inputEmote) + { + // this should only run for emote + if (inputEmote is not Emote e) + return inputEmote; + + // try to get the message and pull + var msg = await cmsg.GetOrDownloadAsync(); + + var emote = msg.Reactions.Keys.FirstOrDefault(x => e.Equals(x)); + return emote ?? inputEmote; + } + + private Task ClientOnReactionAdded( + Cacheable msg, + Cacheable ch, + SocketReaction r) + { + if (!_cache.TryGetValue(msg.Id, out var reros)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var rero = reros.FirstOrDefault(x => x.Emote == r.Emote.Name || x.Emote == r.Emote.ToString()); + if (rero is null) + return; + + var (user, role) = await GetUserAndRoleAsync(r.UserId, rero); + + if (user.IsBot) + return; + + await _assignementLock.WaitAsync(); + try + { + if (!user.RoleIds.Contains(role.Id)) + { + // first check if there is a level requirement + // and if there is, make sure user satisfies it + if (rero.LevelReq > 0) + { + await using var ctx = _db.GetDbContext(); + var levelData = await ctx.GetTable() + .GetLevelDataFor(user.GuildId, user.Id); + + if (levelData.Level < rero.LevelReq) + return; + } + + // remove all other roles from the same group from the user + // execept in group 0, which is a special, non-exclusive group + if (rero.Group != 0) + { + var exclusive = reros + .Where(x => x.Group == rero.Group && x.RoleId != role.Id) + .Select(x => x.RoleId) + .Distinct(); + + + try { await user.RemoveRolesAsync(exclusive); } + catch { } + + // remove user's previous reaction + try + { + var m = await msg.GetOrDownloadAsync(); + if (m is not null) + { + var reactToRemove = m.Reactions + .FirstOrDefault(x => x.Key.ToString() != r.Emote.ToString()) + .Key; + + if (reactToRemove is not null) + { + await m.RemoveReactionAsync(reactToRemove, user); + } + } + } + catch + { + } + } + + await user.AddRoleAsync(role.Id); + } + } + finally + { + _assignementLock.Release(); + } + }); + + return Task.CompletedTask; + } + + /// + /// Adds a single reaction role + /// + /// Guild where to add a reaction role + /// Message to which to add a reaction role + /// + /// + /// + /// + /// The result of the operation + public async Task> AddReactionRole( + IGuild guild, + IMessage msg, + string emote, + IRole role, + int group = 0, + int levelReq = 0) + { + if (group < 0) + throw new ArgumentOutOfRangeException(nameof(group)); + + if (levelReq < 0) + throw new ArgumentOutOfRangeException(nameof(group)); + + await using var ctx = _db.GetDbContext(); + + await using var tran = await ctx.Database.BeginTransactionAsync(); + var activeReactionRoles = await ctx.GetTable() + .Where(x => x.GuildId == guild.Id) + .CountAsync(); + + var result = await _ps.TryGetFeatureLimitAsync(_reroFLKey, guild.OwnerId, 50); + if (result.Quota != -1 && activeReactionRoles >= result.Quota) + return result; + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guild.Id, + ChannelId = msg.Channel.Id, + + MessageId = msg.Id, + Emote = emote, + + RoleId = role.Id, + Group = group, + LevelReq = levelReq + }, + (old) => new() + { + RoleId = role.Id, + Group = group, + LevelReq = levelReq + }, + () => new() + { + MessageId = msg.Id, + Emote = emote, + }); + + await tran.CommitAsync(); + + var obj = new ReactionRoleV2() + { + GuildId = guild.Id, + MessageId = msg.Id, + Emote = emote, + RoleId = role.Id, + Group = group, + LevelReq = levelReq + }; + + lock (_cacheLock) + { + _cache.AddOrUpdate(msg.Id, + _ => new() + { + obj + }, + (_, list) => + { + list.RemoveAll(x => x.Emote == emote); + list.Add(obj); + return list; + }); + } + + return new Success(); + } + + /// + /// Get all reaction roles on the specified server + /// + /// + /// + public async Task> GetReactionRolesAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + } + + /// + /// Remove reaction roles on the specified message + /// + /// + /// + /// + public async Task RemoveReactionRoles(ulong guildId, ulong messageId) + { + // guildid is used for quick index lookup + await using var ctx = _db.GetDbContext(); + var changed = await ctx.GetTable() + .Where(x => x.GuildId == guildId && x.MessageId == messageId) + .DeleteAsync(); + + _cache.TryRemove(messageId, out _); + + if (changed == 0) + return false; + + return true; + } + + /// + /// Remove all reaction roles in the specified server + /// + /// + /// + public async Task RemoveAllReactionRoles(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + var output = await ctx.GetTable() + .Where(x => x.GuildId == guildId) + .DeleteWithOutputAsync(x => x.MessageId); + + lock (_cacheLock) + { + foreach (var o in output) + { + _cache.TryRemove(o, out _); + } + } + + return output.Length; + } + + public async Task> TransferReactionRolesAsync( + ulong guildId, + ulong fromMessageId, + ulong toMessageId) + { + await using var ctx = _db.GetDbContext(); + var updated = ctx.GetTable() + .Where(x => x.GuildId == guildId && x.MessageId == fromMessageId) + .UpdateWithOutput(old => new() + { + MessageId = toMessageId + }, + (old, neu) => neu); + lock (_cacheLock) + { + if (_cache.TryRemove(fromMessageId, out var data)) + { + if (_cache.TryGetValue(toMessageId, out var newData)) + { + newData.AddRange(data); + } + else + { + _cache[toMessageId] = data; + } + } + } + + return updated.Select(x => x.Emote.ToIEmote()).ToList(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Role/RoleCommands.cs b/src/Ellie.Bot.Modules.Administration/Role/RoleCommands.cs new file mode 100644 index 0000000..67de9bc --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Role/RoleCommands.cs @@ -0,0 +1,184 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using Color = SixLabors.ImageSharp.Color; + +namespace Ellie.Modules.Administration; + + +public partial class Administration +{ + public partial class RoleCommands : EllieModule + { + public enum Exclude { Excl } + + private readonly IServiceProvider _services; + + public RoleCommands(IServiceProvider services) + { + _services = services; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task SetRole(IGuildUser targetUser, [Leftover] IRole roleToAdd) + { + var runnerUser = (IGuildUser)ctx.User; + var runnerMaxRolePosition = runnerUser.GetRoles().Max(x => x.Position); + if (ctx.User.Id != ctx.Guild.OwnerId && runnerMaxRolePosition <= roleToAdd.Position) + return; + try + { + await targetUser.AddRoleAsync(roleToAdd); + + await ReplyConfirmLocalizedAsync(strs.setrole(Format.Bold(roleToAdd.Name), + Format.Bold(targetUser.ToString()))); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in setrole command"); + await ReplyErrorLocalizedAsync(strs.setrole_err); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RemoveRole(IGuildUser targetUser, [Leftover] IRole roleToRemove) + { + var runnerUser = (IGuildUser)ctx.User; + if (ctx.User.Id != runnerUser.Guild.OwnerId + && runnerUser.GetRoles().Max(x => x.Position) <= roleToRemove.Position) + return; + try + { + await targetUser.RemoveRoleAsync(roleToRemove); + await ReplyConfirmLocalizedAsync(strs.remrole(Format.Bold(roleToRemove.Name), + Format.Bold(targetUser.ToString()))); + } + catch + { + await ReplyErrorLocalizedAsync(strs.remrole_err); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RenameRole(IRole roleToEdit, [Leftover] string newname) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= roleToEdit.Position) + return; + try + { + if (roleToEdit.Position > (await ctx.Guild.GetCurrentUserAsync()).GetRoles().Max(r => r.Position)) + { + await ReplyErrorLocalizedAsync(strs.renrole_perms); + return; + } + + await roleToEdit.ModifyAsync(g => g.Name = newname); + await ReplyConfirmLocalizedAsync(strs.renrole); + } + catch (Exception) + { + await ReplyErrorLocalizedAsync(strs.renrole_err); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RemoveAllRoles([Leftover] IGuildUser user) + { + var guser = (IGuildUser)ctx.User; + + var userRoles = user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole).ToList(); + + if (user.Id == ctx.Guild.OwnerId + || (ctx.User.Id != ctx.Guild.OwnerId + && guser.GetRoles().Max(x => x.Position) <= userRoles.Max(x => x.Position))) + return; + try + { + await user.RemoveRolesAsync(userRoles); + await ReplyConfirmLocalizedAsync(strs.rar(Format.Bold(user.ToString()))); + } + catch (Exception) + { + await ReplyErrorLocalizedAsync(strs.rar_err); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task CreateRole([Leftover] string roleName = null) + { + if (string.IsNullOrWhiteSpace(roleName)) + return; + + var r = await ctx.Guild.CreateRoleAsync(roleName, isMentionable: false); + await ReplyConfirmLocalizedAsync(strs.cr(Format.Bold(r.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task DeleteRole([Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + return; + + await role.DeleteAsync(); + await ReplyConfirmLocalizedAsync(strs.dr(Format.Bold(role.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RoleHoist([Leftover] IRole role) + { + var newHoisted = !role.IsHoisted; + await role.ModifyAsync(r => r.Hoist = newHoisted); + if (newHoisted) + await ReplyConfirmLocalizedAsync(strs.rolehoist_enabled(Format.Bold(role.Name))); + else + await ReplyConfirmLocalizedAsync(strs.rolehoist_disabled(Format.Bold(role.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task RoleColor([Leftover] IRole role) + => await SendConfirmAsync("Role Color", role.Color.RawValue.ToString("x6")); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task RoleColor(Color color, [Leftover] IRole role) + { + try + { + var rgba32 = color.ToPixel(); + await role.ModifyAsync(r => r.Color = new Discord.Color(rgba32.R, rgba32.G, rgba32.B)); + await ReplyConfirmLocalizedAsync(strs.rc(Format.Bold(role.Name))); + } + catch (Exception) + { + await ReplyErrorLocalizedAsync(strs.rc_perms); + } + } + } +} diff --git a/src/Ellie.Bot.Modules.Administration/Self/CheckForUpdatesService.cs b/src/Ellie.Bot.Modules.Administration/Self/CheckForUpdatesService.cs new file mode 100644 index 0000000..2b5c9e3 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Self/CheckForUpdatesService.cs @@ -0,0 +1,153 @@ +using System.Net.Http.Json; +using System.Text; +using Ellie.Common.ModuleBehaviors; + +namespace NadekoBot.Modules.Administration.Self; + +public sealed class CheckForUpdatesService : IEService, IReadyExecutor +{ + private readonly BotConfigService _bcs; + private readonly IBotCredsProvider _bcp; + private readonly IHttpClientFactory _httpFactory; + private readonly DiscordSocketClient _client; + private readonly IEmbedBuilderService _ebs; + + public CheckForUpdatesService(BotConfigService bcs, IBotCredsProvider bcp, IHttpClientFactory httpFactory, + DiscordSocketClient client, IEmbedBuilderService ebs) + { + _bcs = bcs; + _bcp = bcp; + _httpFactory = httpFactory; + _client = client; + _ebs = ebs; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + var conf = _bcs.Data; + + if (!conf.CheckForUpdates) + continue; + + try + { + const string URL = "https://cdn.elliebot.net/cmds/versions.json"; + using var http = _httpFactory.CreateClient(); + var versions = await http.GetFromJsonAsync(URL); + + if (versions is null) + continue; + + var latest = versions[0]; + var latestVersion = Version.Parse(latest); + var lastKnownVersion = GetLastKnownVersion(); + + if (lastKnownVersion is null) + { + UpdateLastKnownVersion(latestVersion); + continue; + } + + if (latestVersion > lastKnownVersion) + { + UpdateLastKnownVersion(latestVersion); + + // pull changelog + var changelog = await http.GetStringAsync("https://toastielab.dev/Emotions-stuff/Ellie/src/branch/main/CHANGELOG.md"); + + var thisVersionChangelog = GetVersionChangelog(latestVersion, changelog); + + if (string.IsNullOrWhiteSpace(thisVersionChangelog)) + { + Log.Warning("New version {BotVersion} was found but changelog is unavailable", + thisVersionChangelog); + continue; + } + + var creds = _bcp.GetCreds(); + await creds.OwnerIds + .Select(async x => + { + var user = await _client.GetUserAsync(x); + if (user is null) + return; + + var eb = _ebs.Create() + .WithOkColor() + .WithAuthor($"Ellie v{latestVersion} Released!") + .WithTitle("Changelog") + .WithUrl("https://toastielab.dev/Emotions-stuff/Ellie/src/branch/main/CHANGELOG.md") + .WithDescription(thisVersionChangelog.TrimTo(4096)) + .WithFooter("You may disable these messages by typing '.conf bot checkforupdates false'"); + + await user.EmbedAsync(eb); + }).WhenAll(); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error while checking for new bot release: {ErrorMessage}", ex.Message); + } + } + } + + private string? GetVersionChangelog(Version latestVersion, string changelog) + { + var clSpan = changelog.AsSpan(); + + var sb = new StringBuilder(); + var started = false; + foreach (var line in clSpan.EnumerateLines()) + { + // if we're at the current version, keep reading lines and adding to the output + if (started) + { + // if we got to previous version, end + if (line.StartsWith("## [")) + break; + + // if we're reading a new segment, reformat it to print it better to discord + if (line.StartsWith("### ")) + { + sb.AppendLine(Format.Bold(line.ToString())); + } + else + { + sb.AppendLine(line.ToString()); + } + + continue; + } + + if (line.StartsWith($"## [{latestVersion.ToString()}]")) + { + started = true; + continue; + } + } + + return sb.ToString(); + } + + private const string LAST_KNOWN_VERSION_PATH = "data/last_known_version.txt"; + private Version? GetLastKnownVersion() + { + if (!File.Exists(LAST_KNOWN_VERSION_PATH)) + return null; + + return Version.TryParse(File.ReadAllText(LAST_KNOWN_VERSION_PATH), out var ver) + ? ver + : null; + } + + private void UpdateLastKnownVersion(Version version) + { + File.WriteAllText("data/last_known_version.txt", version.ToString()); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Self/SelfCommands.cs b/src/Ellie.Bot.Modules.Administration/Self/SelfCommands.cs new file mode 100644 index 0000000..a03c8ce --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Self/SelfCommands.cs @@ -0,0 +1,572 @@ +#nullable disable +using Ellie.Marmalade; +using Ellie.Modules.Administration.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class SelfCommands : EllieModule + { + public enum SettableUserStatus + { + Online, + Invisible, + Idle, + Dnd + } + + private readonly DiscordSocketClient _client; + private readonly IBotStrings _strings; + private readonly IMarmaladeLoaderSevice _marmaladeLoader; + private readonly ICoordinator _coord; + + public SelfCommands( + DiscordSocketClient client, + IBotStrings strings, + ICoordinator coord, + IMarmaladeLoaderSevice marmaladeLoader) + { + _client = client; + _strings = strings; + _coord = coord; + _marmaladeLoader = marmaladeLoader; + } + + [Cmd] + [OwnerOnly] + public async Task DoAs(IUser user, [Leftover] string message) + { + if (ctx.User is not IGuildUser { GuildPermissions.Administrator: true }) + return; + + if (ctx.Guild is SocketGuild sg && ctx.Channel is ISocketMessageChannel ch + && ctx.Message is SocketUserMessage msg) + { + var fakeMessage = new DoAsUserMessage(msg, user, message); + + await _cmdHandler.TryRunCommand(sg, ch, fakeMessage); + } + else + { + await ReplyErrorLocalizedAsync(strs.error_occured); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task StartupCommandAdd([Leftover] string cmdText) + { + if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture)) + return; + + var guser = (IGuildUser)ctx.User; + var cmd = new AutoCommand + { + CommandText = cmdText, + ChannelId = ctx.Channel.Id, + ChannelName = ctx.Channel.Name, + GuildId = ctx.Guild?.Id, + GuildName = ctx.Guild?.Name, + VoiceChannelId = guser.VoiceChannel?.Id, + VoiceChannelName = guser.VoiceChannel?.Name, + Interval = 0 + }; + _service.AddNewAutoCommand(cmd); + + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.scadd)) + .AddField(GetText(strs.server), + cmd.GuildId is null ? "-" : $"{cmd.GuildName}/{cmd.GuildId}", + true) + .AddField(GetText(strs.channel), $"{cmd.ChannelName}/{cmd.ChannelId}", true) + .AddField(GetText(strs.command_text), cmdText)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task AutoCommandAdd(int interval, [Leftover] string cmdText) + { + if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture)) + return; + + if (interval < 5) + return; + + var guser = (IGuildUser)ctx.User; + var cmd = new AutoCommand + { + CommandText = cmdText, + ChannelId = ctx.Channel.Id, + ChannelName = ctx.Channel.Name, + GuildId = ctx.Guild?.Id, + GuildName = ctx.Guild?.Name, + VoiceChannelId = guser.VoiceChannel?.Id, + VoiceChannelName = guser.VoiceChannel?.Name, + Interval = interval + }; + _service.AddNewAutoCommand(cmd); + + await ReplyConfirmLocalizedAsync(strs.autocmd_add(Format.Code(Format.Sanitize(cmdText)), cmd.Interval)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task StartupCommandsList(int page = 1) + { + if (page-- < 1) + return; + + var scmds = _service.GetStartupCommands().Skip(page * 5).Take(5).ToList(); + + if (scmds.Count == 0) + await ReplyErrorLocalizedAsync(strs.startcmdlist_none); + else + { + var i = 0; + await SendConfirmAsync(text: string.Join("\n", + scmds.Select(x => $@"```css +#{++i + (page * 5)} +[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")} +[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId} +[{GetText(strs.command_text)}]: {x.CommandText}```")), + title: string.Empty, + footer: GetText(strs.page(page + 1))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task AutoCommandsList(int page = 1) + { + if (page-- < 1) + return; + + var scmds = _service.GetAutoCommands().Skip(page * 5).Take(5).ToList(); + if (!scmds.Any()) + await ReplyErrorLocalizedAsync(strs.autocmdlist_none); + else + { + var i = 0; + await SendConfirmAsync(text: string.Join("\n", + scmds.Select(x => $@"```css +#{++i + (page * 5)} +[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")} +[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId} +{GetIntervalText(x.Interval)} +[{GetText(strs.command_text)}]: {x.CommandText}```")), + title: string.Empty, + footer: GetText(strs.page(page + 1))); + } + } + + private string GetIntervalText(int interval) + => $"[{GetText(strs.interval)}]: {interval}"; + + [Cmd] + [OwnerOnly] + public async Task Wait(int miliseconds) + { + if (miliseconds <= 0) + return; + ctx.Message.DeleteAfter(0); + try + { + var msg = await SendConfirmAsync($"⏲ {miliseconds}ms"); + msg.DeleteAfter(miliseconds / 1000); + } + catch { } + + await Task.Delay(miliseconds); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task AutoCommandRemove([Leftover] int index) + { + if (!_service.RemoveAutoCommand(--index, out _)) + { + await ReplyErrorLocalizedAsync(strs.acrm_fail); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task StartupCommandRemove([Leftover] int index) + { + if (!_service.RemoveStartupCommand(--index, out _)) + await ReplyErrorLocalizedAsync(strs.scrm_fail); + else + await ReplyConfirmLocalizedAsync(strs.scrm); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task StartupCommandsClear() + { + _service.ClearStartupCommands(); + + await ReplyConfirmLocalizedAsync(strs.startcmds_cleared); + } + + [Cmd] + [OwnerOnly] + public async Task ForwardMessages() + { + var enabled = _service.ForwardMessages(); + + if (enabled) + await ReplyConfirmLocalizedAsync(strs.fwdm_start); + else + await ReplyPendingLocalizedAsync(strs.fwdm_stop); + } + + [Cmd] + [OwnerOnly] + public async Task ForwardToAll() + { + var enabled = _service.ForwardToAll(); + + if (enabled) + await ReplyConfirmLocalizedAsync(strs.fwall_start); + else + await ReplyPendingLocalizedAsync(strs.fwall_stop); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task ForwardToChannel() + { + var enabled = _service.ForwardToChannel(ctx.Channel.Id); + + if (enabled) + await ReplyConfirmLocalizedAsync(strs.fwch_start); + else + await ReplyPendingLocalizedAsync(strs.fwch_stop); + } + + [Cmd] + public async Task ShardStats(int page = 1) + { + if (--page < 0) + return; + + var statuses = _coord.GetAllShardStatuses(); + + var status = string.Join(" : ", + statuses.Select(x => (ConnectionStateToEmoji(x), x)) + .GroupBy(x => x.Item1) + .Select(x => $"`{x.Count()} {x.Key}`") + .ToArray()); + + var allShardStrings = statuses.Select(st => + { + var timeDiff = DateTime.UtcNow - st.LastUpdate; + var stateStr = ConnectionStateToEmoji(st); + var maxGuildCountLength = + statuses.Max(x => x.GuildCount).ToString().Length; + return $"`{stateStr} " + + $"| #{st.ShardId.ToString().PadBoth(3)} " + + $"| {timeDiff:mm\\:ss} " + + $"| {st.GuildCount.ToString().PadBoth(maxGuildCountLength)} `"; + }) + .ToArray(); + await ctx.SendPaginatedConfirmAsync(page, + curPage => + { + var str = string.Join("\n", allShardStrings.Skip(25 * curPage).Take(25)); + + if (string.IsNullOrWhiteSpace(str)) + str = GetText(strs.no_shards_on_page); + + return _eb.Create().WithOkColor().WithDescription($"{status}\n\n{str}"); + }, + allShardStrings.Length, + 25); + } + + private static string ConnectionStateToEmoji(ShardStatus status) + { + var timeDiff = DateTime.UtcNow - status.LastUpdate; + return status.ConnectionState switch + { + ConnectionState.Disconnected => "🔻", + _ when timeDiff > TimeSpan.FromSeconds(30) => " ❗ ", + ConnectionState.Connected => "✅", + _ => " ⏳" + }; + } + + [Cmd] + [OwnerOnly] + public async Task RestartShard(int shardId) + { + var success = _coord.RestartShard(shardId); + if (success) + await ReplyConfirmLocalizedAsync(strs.shard_reconnecting(Format.Bold("#" + shardId))); + else + await ReplyErrorLocalizedAsync(strs.no_shard_id); + } + + [Cmd] + [OwnerOnly] + public Task Leave([Leftover] string guildStr) + => _service.LeaveGuild(guildStr); + + [Cmd] + [OwnerOnly] + public async Task DeleteEmptyServers() + { + await ctx.Channel.TriggerTypingAsync(); + + var toLeave = _client.Guilds + .Where(s => s.MemberCount == 1 && s.Users.Count == 1) + .ToList(); + + foreach (var server in toLeave) + { + try + { + await server.DeleteAsync(); + Log.Information("Deleted server {ServerName} [{ServerId}]", + server.Name, + server.Id); + } + catch (Exception ex) + { + Log.Warning(ex, + "Error leaving server {ServerName} [{ServerId}]", + server.Name, + server.Id); + } + } + + await ReplyConfirmLocalizedAsync(strs.deleted_x_servers(toLeave.Count)); + } + + [Cmd] + [OwnerOnly] + public async Task Die(bool graceful = false) + { + try + { + await _client.SetStatusAsync(UserStatus.Invisible); + _ = _client.StopAsync(); + await ReplyConfirmLocalizedAsync(strs.shutting_down); + } + catch + { + // ignored + } + + await Task.Delay(2000); + _coord.Die(graceful); + } + + [Cmd] + [OwnerOnly] + public async Task Restart() + { + var success = _coord.RestartBot(); + if (!success) + { + await ReplyErrorLocalizedAsync(strs.restart_fail); + return; + } + + try { await ReplyConfirmLocalizedAsync(strs.restarting); } + catch { } + } + + [Cmd] + [OwnerOnly] + public async Task SetName([Leftover] string newName) + { + if (string.IsNullOrWhiteSpace(newName)) + return; + + try + { + await _client.CurrentUser.ModifyAsync(u => u.Username = newName); + } + catch (RateLimitedException) + { + Log.Warning("You've been ratelimited. Wait 2 hours to change your name"); + } + + await ReplyConfirmLocalizedAsync(strs.bot_name(Format.Bold(newName))); + } + + [Cmd] + [UserPerm(GuildPerm.ManageNicknames)] + [BotPerm(GuildPerm.ChangeNickname)] + [Priority(0)] + public async Task SetNick([Leftover] string newNick = null) + { + if (string.IsNullOrWhiteSpace(newNick)) + return; + var curUser = await ctx.Guild.GetCurrentUserAsync(); + await curUser.ModifyAsync(u => u.Nickname = newNick); + + await ReplyConfirmLocalizedAsync(strs.bot_nick(Format.Bold(newNick) ?? "-")); + } + + [Cmd] + [BotPerm(GuildPerm.ManageNicknames)] + [UserPerm(GuildPerm.ManageNicknames)] + [Priority(1)] + public async Task SetNick(IGuildUser gu, [Leftover] string newNick = null) + { + var sg = (SocketGuild)ctx.Guild; + if (sg.OwnerId == gu.Id + || gu.GetRoles().Max(r => r.Position) >= sg.CurrentUser.GetRoles().Max(r => r.Position)) + { + await ReplyErrorLocalizedAsync(strs.insuf_perms_i); + return; + } + + await gu.ModifyAsync(u => u.Nickname = newNick); + + await ReplyConfirmLocalizedAsync(strs.user_nick(Format.Bold(gu.ToString()), Format.Bold(newNick) ?? "-")); + } + + [Cmd] + [OwnerOnly] + public async Task SetStatus([Leftover] SettableUserStatus status) + { + await _client.SetStatusAsync(SettableUserStatusToUserStatus(status)); + + await ReplyConfirmLocalizedAsync(strs.bot_status(Format.Bold(status.ToString()))); + } + + [Cmd] + [OwnerOnly] + public async Task SetAvatar([Leftover] string img = null) + { + var success = await _service.SetAvatar(img); + + if (success) + await ReplyConfirmLocalizedAsync(strs.set_avatar); + } + + [Cmd] + [OwnerOnly] + public async Task SetGame(ActivityType type, [Leftover] string game = null) + { + var rep = new ReplacementBuilder().WithDefault(Context).Build(); + + await _service.SetGameAsync(game is null ? game : rep.Replace(game), type); + + await ReplyConfirmLocalizedAsync(strs.set_game); + } + + [Cmd] + [OwnerOnly] + public async Task SetStream(string url, [Leftover] string name = null) + { + name ??= ""; + + await _service.SetStreamAsync(name, url); + + await ReplyConfirmLocalizedAsync(strs.set_stream); + } + + [Cmd] + [OwnerOnly] + public async Task Send(string where, [Leftover] SmartText text = null) + { + var ids = where.Split('|'); + if (ids.Length != 2) + return; + + var sid = ulong.Parse(ids[0]); + var server = _client.Guilds.FirstOrDefault(s => s.Id == sid); + + if (server is null) + return; + + var rep = new ReplacementBuilder().WithDefault(Context).Build(); + + if (ids[1].ToUpperInvariant().StartsWith("C:", StringComparison.InvariantCulture)) + { + var cid = ulong.Parse(ids[1][2..]); + var ch = server.TextChannels.FirstOrDefault(c => c.Id == cid); + if (ch is null) + return; + + text = rep.Replace(text); + await ch.SendAsync(text); + } + else if (ids[1].ToUpperInvariant().StartsWith("U:", StringComparison.InvariantCulture)) + { + var uid = ulong.Parse(ids[1][2..]); + var user = server.Users.FirstOrDefault(u => u.Id == uid); + if (user is null) + return; + + var ch = await user.CreateDMChannelAsync(); + text = rep.Replace(text); + await ch.SendAsync(text); + } + else + { + await ReplyErrorLocalizedAsync(strs.invalid_format); + return; + } + + await ReplyConfirmLocalizedAsync(strs.message_sent); + } + + [Cmd] + [OwnerOnly] + public async Task StringsReload() + { + _strings.Reload(); + await _marmaladeLoader.ReloadStrings(); + await ReplyConfirmLocalizedAsync(strs.bot_strings_reloaded); + } + + [Cmd] + [OwnerOnly] + public async Task CoordReload() + { + await _coord.Reload(); + await ctx.OkAsync(); + } + + private static UserStatus SettableUserStatusToUserStatus(SettableUserStatus sus) + { + switch (sus) + { + case SettableUserStatus.Online: + return UserStatus.Online; + case SettableUserStatus.Invisible: + return UserStatus.Invisible; + case SettableUserStatus.Idle: + return UserStatus.AFK; + case SettableUserStatus.Dnd: + return UserStatus.DoNotDisturb; + } + + return UserStatus.Online; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Self/SelfService.cs b/src/Ellie.Bot.Modules.Administration/Self/SelfService.cs new file mode 100644 index 0000000..a0006c6 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Self/SelfService.cs @@ -0,0 +1,399 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Services.Database.Models; +using System.Collections.Immutable; + +namespace Ellie.Modules.Administration.Services; + +public sealed class SelfService : IExecNoCommand, IReadyExecutor, IEService +{ + private readonly CommandHandler _cmdHandler; + private readonly DbService _db; + private readonly IBotStrings _strings; + private readonly DiscordSocketClient _client; + + private readonly IBotCredentials _creds; + + private ImmutableDictionary ownerChannels = + new Dictionary().ToImmutableDictionary(); + + private ConcurrentDictionary> autoCommands = new(); + + private readonly IHttpClientFactory _httpFactory; + private readonly BotConfigService _bss; + private readonly IPubSub _pubSub; + private readonly IEmbedBuilderService _eb; + + //keys + private readonly TypedKey _activitySetKey; + private readonly TypedKey _guildLeaveKey; + + public SelfService( + DiscordSocketClient client, + CommandHandler cmdHandler, + DbService db, + IBotStrings strings, + IBotCredentials creds, + IHttpClientFactory factory, + BotConfigService bss, + IPubSub pubSub, + IEmbedBuilderService eb) + { + _cmdHandler = cmdHandler; + _db = db; + _strings = strings; + _client = client; + _creds = creds; + _httpFactory = factory; + _bss = bss; + _pubSub = pubSub; + _eb = eb; + _activitySetKey = new("activity.set"); + _guildLeaveKey = new("guild.leave"); + + HandleStatusChanges(); + + _pubSub.Sub(_guildLeaveKey, + async input => + { + var guildStr = input.ToString().Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(guildStr)) + return; + + var server = _client.Guilds.FirstOrDefault(g => g.Id.ToString() == guildStr + || g.Name.Trim().ToUpperInvariant() == guildStr); + if (server is null) + return; + + if (server.OwnerId != _client.CurrentUser.Id) + { + await server.LeaveAsync(); + Log.Information("Left server {Name} [{Id}]", server.Name, server.Id); + } + else + { + await server.DeleteAsync(); + Log.Information("Deleted server {Name} [{Id}]", server.Name, server.Id); + } + }); + } + + public async Task OnReadyAsync() + { + await using var uow = _db.GetDbContext(); + + autoCommands = uow.Set().AsNoTracking() + .Where(x => x.Interval >= 5) + .AsEnumerable() + .GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, + y => y.ToDictionary(x => x.Id, TimerFromAutoCommand).ToConcurrent()) + .ToConcurrent(); + + var startupCommands = uow.Set().AsNoTracking().Where(x => x.Interval == 0); + foreach (var cmd in startupCommands) + { + try + { + await ExecuteCommand(cmd); + } + catch + { + } + } + + if (_client.ShardId == 0) + await LoadOwnerChannels(); + } + + private Timer TimerFromAutoCommand(AutoCommand x) + => new(async obj => await ExecuteCommand((AutoCommand)obj), x, x.Interval * 1000, x.Interval * 1000); + + private async Task ExecuteCommand(AutoCommand cmd) + { + try + { + if (cmd.GuildId is null) + return; + + var guildShard = (int)((cmd.GuildId.Value >> 22) % (ulong)_creds.TotalShards); + if (guildShard != _client.ShardId) + return; + var prefix = _cmdHandler.GetPrefix(cmd.GuildId); + //if someone already has .die as their startup command, ignore it + if (cmd.CommandText.StartsWith(prefix + "die", StringComparison.InvariantCulture)) + return; + await _cmdHandler.ExecuteExternal(cmd.GuildId, cmd.ChannelId, cmd.CommandText); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in SelfService ExecuteCommand"); + } + } + + public void AddNewAutoCommand(AutoCommand cmd) + { + using (var uow = _db.GetDbContext()) + { + uow.Set().Add(cmd); + uow.SaveChanges(); + } + + if (cmd.Interval >= 5) + { + var autos = autoCommands.GetOrAdd(cmd.GuildId, new ConcurrentDictionary()); + autos.AddOrUpdate(cmd.Id, + _ => TimerFromAutoCommand(cmd), + (_, old) => + { + old.Change(Timeout.Infinite, Timeout.Infinite); + return TimerFromAutoCommand(cmd); + }); + } + } + + public IEnumerable GetStartupCommands() + { + using var uow = _db.GetDbContext(); + return uow.Set().AsNoTracking().Where(x => x.Interval == 0).OrderBy(x => x.Id).ToList(); + } + + public IEnumerable GetAutoCommands() + { + using var uow = _db.GetDbContext(); + return uow.Set().AsNoTracking().Where(x => x.Interval >= 5).OrderBy(x => x.Id).ToList(); + } + + private async Task LoadOwnerChannels() + { + var channels = await _creds.OwnerIds.Select(id => + { + var user = _client.GetUser(id); + if (user is null) + return Task.FromResult(null); + + return user.CreateDMChannelAsync(); + }) + .WhenAll(); + + ownerChannels = channels.Where(x => x is not null) + .ToDictionary(x => x.Recipient.Id, x => x) + .ToImmutableDictionary(); + + if (!ownerChannels.Any()) + { + Log.Warning( + "No owner channels created! Make sure you've specified the correct OwnerId in the creds.yml file and invited the bot to a Discord server"); + } + else + { + Log.Information("Created {OwnerChannelCount} out of {TotalOwnerChannelCount} owner message channels", + ownerChannels.Count, + _creds.OwnerIds.Count); + } + } + + public Task LeaveGuild(string guildStr) + => _pubSub.Pub(_guildLeaveKey, guildStr); + + // forwards dms + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + var bs = _bss.Data; + if (msg.Channel is IDMChannel && bs.ForwardMessages && (ownerChannels.Any() || bs.ForwardToChannel is not null)) + { + var title = _strings.GetText(strs.dm_from) + $" [{msg.Author}]({msg.Author.Id})"; + + var attachamentsTxt = _strings.GetText(strs.attachments); + + var toSend = msg.Content; + + if (msg.Attachments.Count > 0) + { + toSend += $"\n\n{Format.Code(attachamentsTxt)}:\n" + + string.Join("\n", msg.Attachments.Select(a => a.ProxyUrl)); + } + + if (bs.ForwardToAllOwners) + { + var allOwnerChannels = ownerChannels.Values; + + foreach (var ownerCh in allOwnerChannels.Where(ch => ch.Recipient.Id != msg.Author.Id)) + { + try + { + await ownerCh.SendConfirmAsync(_eb, title, toSend); + } + catch + { + Log.Warning("Can't contact owner with id {OwnerId}", ownerCh.Recipient.Id); + } + } + } + else if (bs.ForwardToChannel is ulong cid) + { + try + { + if (_client.GetChannel(cid) is ITextChannel ch) + await ch.SendConfirmAsync(_eb, title, toSend); + } + catch + { + Log.Warning("Error forwarding message to the channel"); + } + } + else + { + var firstOwnerChannel = ownerChannels.Values.First(); + if (firstOwnerChannel.Recipient.Id != msg.Author.Id) + { + try + { + await firstOwnerChannel.SendConfirmAsync(_eb, title, toSend); + } + catch + { + // ignored + } + } + } + } + } + + public bool RemoveStartupCommand(int index, out AutoCommand cmd) + { + using var uow = _db.GetDbContext(); + cmd = uow.Set().AsNoTracking().Where(x => x.Interval == 0).Skip(index).FirstOrDefault(); + + if (cmd is not null) + { + uow.Remove(cmd); + uow.SaveChanges(); + return true; + } + + return false; + } + + public bool RemoveAutoCommand(int index, out AutoCommand cmd) + { + using var uow = _db.GetDbContext(); + cmd = uow.Set().AsNoTracking().Where(x => x.Interval >= 5).Skip(index).FirstOrDefault(); + + if (cmd is not null) + { + uow.Remove(cmd); + if (autoCommands.TryGetValue(cmd.GuildId, out var autos)) + { + if (autos.TryRemove(cmd.Id, out var timer)) + timer.Change(Timeout.Infinite, Timeout.Infinite); + } + + uow.SaveChanges(); + return true; + } + + return false; + } + + public async Task SetAvatar(string img) + { + if (string.IsNullOrWhiteSpace(img)) + return false; + + if (!Uri.IsWellFormedUriString(img, UriKind.Absolute)) + return false; + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + if (!sr.IsImage()) + return false; + + // i can't just do ReadAsStreamAsync because dicord.net's image poops itself + var imgData = await sr.Content.ReadAsByteArrayAsync(); + await using var imgStream = imgData.ToStream(); + await _client.CurrentUser.ModifyAsync(u => u.Avatar = new Image(imgStream)); + + return true; + } + + public void ClearStartupCommands() + { + using var uow = _db.GetDbContext(); + var toRemove = uow.Set().AsNoTracking().Where(x => x.Interval == 0); + + uow.Set().RemoveRange(toRemove); + uow.SaveChanges(); + } + + public bool ForwardMessages() + { + var isForwarding = false; + _bss.ModifyConfig(config => { isForwarding = config.ForwardMessages = !config.ForwardMessages; }); + + return isForwarding; + } + + public bool ForwardToAll() + { + var isToAll = false; + _bss.ModifyConfig(config => { isToAll = config.ForwardToAllOwners = !config.ForwardToAllOwners; }); + return isToAll; + } + + public bool ForwardToChannel(ulong? channelId) + { + using var uow = _db.GetDbContext(); + + _bss.ModifyConfig(config => + { + config.ForwardToChannel = channelId == config.ForwardToChannel + ? null + : channelId; + }); + + return channelId is not null; + } + + private void HandleStatusChanges() + => _pubSub.Sub(_activitySetKey, + async data => + { + try + { + await _client.SetGameAsync(data.Name, data.Link, data.Type); + } + catch (Exception ex) + { + Log.Warning(ex, "Error setting activity"); + } + }); + + public Task SetGameAsync(string game, ActivityType type) + => _pubSub.Pub(_activitySetKey, + new() + { + Name = game, + Link = null, + Type = type + }); + + public Task SetStreamAsync(string name, string link) + => _pubSub.Pub(_activitySetKey, + new() + { + Name = name, + Link = link, + Type = ActivityType.Streaming + }); + + private sealed class ActivityPubData + { + public string Name { get; init; } + public string Link { get; init; } + public ActivityType Type { get; init; } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs b/src/Ellie.Bot.Modules.Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs new file mode 100644 index 0000000..6a0fc2a --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs @@ -0,0 +1,234 @@ +#nullable disable +using Ellie.Modules.Administration.Services; +using System.Text; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class SelfAssignedRolesCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [BotPerm(GuildPerm.ManageMessages)] + public async Task AdSarm() + { + var newVal = _service.ToggleAdSarm(ctx.Guild.Id); + + if (newVal) + await ReplyConfirmLocalizedAsync(strs.adsarm_enable(prefix)); + else + await ReplyConfirmLocalizedAsync(strs.adsarm_disable(prefix)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(1)] + public Task Asar([Leftover] IRole role) + => Asar(0, role); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task Asar(int group, [Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + return; + + var succ = _service.AddNew(ctx.Guild.Id, role, group); + + if (succ) + { + await ReplyConfirmLocalizedAsync(strs.role_added(Format.Bold(role.Name), + Format.Bold(@group.ToString()))); + } + else + await ReplyErrorLocalizedAsync(strs.role_in_list(Format.Bold(role.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task Sargn(int group, [Leftover] string name = null) + { + var set = await _service.SetNameAsync(ctx.Guild.Id, group, name); + + if (set) + { + await ReplyConfirmLocalizedAsync( + strs.group_name_added(Format.Bold(@group.ToString()), Format.Bold(name))); + } + else + await ReplyConfirmLocalizedAsync(strs.group_name_removed(Format.Bold(group.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + public async Task Rsar([Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + return; + + var success = _service.RemoveSar(role.Guild.Id, role.Id); + if (!success) + await ReplyErrorLocalizedAsync(strs.self_assign_not); + else + await ReplyConfirmLocalizedAsync(strs.self_assign_rem(Format.Bold(role.Name))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Lsar(int page = 1) + { + if (--page < 0) + return; + + var (exclusive, roles, groups) = _service.GetRoles(ctx.Guild); + + await ctx.SendPaginatedConfirmAsync(page, + cur => + { + var rolesStr = new StringBuilder(); + var roleGroups = roles.OrderBy(x => x.Model.Group) + .Skip(cur * 20) + .Take(20) + .GroupBy(x => x.Model.Group) + .OrderBy(x => x.Key); + + foreach (var kvp in roleGroups) + { + string groupNameText; + if (!groups.TryGetValue(kvp.Key, out var name)) + groupNameText = Format.Bold(GetText(strs.self_assign_group(kvp.Key))); + else + groupNameText = Format.Bold($"{kvp.Key} - {name.TrimTo(25, true)}"); + + rolesStr.AppendLine("\t\t\t\t ⟪" + groupNameText + "⟫"); + foreach (var (model, role) in kvp.AsEnumerable()) + { + if (role is null) + { + } + else + { + // first character is invisible space + if (model.LevelRequirement == 0) + rolesStr.AppendLine("‌‌ " + role.Name); + else + rolesStr.AppendLine("‌‌ " + role.Name + $" (lvl {model.LevelRequirement}+)"); + } + } + + rolesStr.AppendLine(); + } + + return _eb.Create() + .WithOkColor() + .WithTitle(Format.Bold(GetText(strs.self_assign_list(roles.Count())))) + .WithDescription(rolesStr.ToString()) + .WithFooter(exclusive + ? GetText(strs.self_assign_are_exclusive) + : GetText(strs.self_assign_are_not_exclusive)); + }, + roles.Count(), + 20); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task Togglexclsar() + { + var areExclusive = _service.ToggleEsar(ctx.Guild.Id); + if (areExclusive) + await ReplyConfirmLocalizedAsync(strs.self_assign_excl); + else + await ReplyConfirmLocalizedAsync(strs.self_assign_no_excl); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RoleLevelReq(int level, [Leftover] IRole role) + { + if (level < 0) + return; + + var succ = _service.SetLevelReq(ctx.Guild.Id, role, level); + + if (!succ) + { + await ReplyErrorLocalizedAsync(strs.self_assign_not); + return; + } + + await ReplyConfirmLocalizedAsync(strs.self_assign_level_req(Format.Bold(role.Name), + Format.Bold(level.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Iam([Leftover] IRole role) + { + var guildUser = (IGuildUser)ctx.User; + + var (result, autoDelete, extra) = await _service.Assign(guildUser, role); + + IUserMessage msg; + if (result == SelfAssignedRolesService.AssignResult.ErrNotAssignable) + msg = await ReplyErrorLocalizedAsync(strs.self_assign_not); + else if (result == SelfAssignedRolesService.AssignResult.ErrLvlReq) + msg = await ReplyErrorLocalizedAsync(strs.self_assign_not_level(Format.Bold(extra.ToString()))); + else if (result == SelfAssignedRolesService.AssignResult.ErrAlreadyHave) + msg = await ReplyErrorLocalizedAsync(strs.self_assign_already(Format.Bold(role.Name))); + else if (result == SelfAssignedRolesService.AssignResult.ErrNotPerms) + msg = await ReplyErrorLocalizedAsync(strs.self_assign_perms); + else + msg = await ReplyConfirmLocalizedAsync(strs.self_assign_success(Format.Bold(role.Name))); + + if (autoDelete) + { + msg.DeleteAfter(3); + ctx.Message.DeleteAfter(3); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Iamnot([Leftover] IRole role) + { + var guildUser = (IGuildUser)ctx.User; + + var (result, autoDelete) = await _service.Remove(guildUser, role); + + IUserMessage msg; + if (result == SelfAssignedRolesService.RemoveResult.ErrNotAssignable) + msg = await ReplyErrorLocalizedAsync(strs.self_assign_not); + else if (result == SelfAssignedRolesService.RemoveResult.ErrNotHave) + msg = await ReplyErrorLocalizedAsync(strs.self_assign_not_have(Format.Bold(role.Name))); + else if (result == SelfAssignedRolesService.RemoveResult.ErrNotPerms) + msg = await ReplyErrorLocalizedAsync(strs.self_assign_perms); + else + msg = await ReplyConfirmLocalizedAsync(strs.self_assign_remove(Format.Bold(role.Name))); + + if (autoDelete) + { + msg.DeleteAfter(3); + ctx.Message.DeleteAfter(3); + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/SelfAssignableRoles/SelfAssignedRolesService.cs b/src/Ellie.Bot.Modules.Administration/SelfAssignableRoles/SelfAssignedRolesService.cs new file mode 100644 index 0000000..4939659 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/SelfAssignableRoles/SelfAssignedRolesService.cs @@ -0,0 +1,234 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration.Services; + +public class SelfAssignedRolesService : IEService +{ + public enum AssignResult + { + Assigned, // successfully removed + ErrNotAssignable, // not assignable (error) + ErrAlreadyHave, // you already have that role (error) + ErrNotPerms, // bot doesn't have perms (error) + ErrLvlReq // you are not required level (error) + } + + public enum RemoveResult + { + Removed, // successfully removed + ErrNotAssignable, // not assignable (error) + ErrNotHave, // you don't have a role you want to remove (error) + ErrNotPerms // bot doesn't have perms (error) + } + + private readonly DbService _db; + + public SelfAssignedRolesService(DbService db) + => _db = db; + + public bool AddNew(ulong guildId, IRole role, int group) + { + using var uow = _db.GetDbContext(); + var roles = uow.Set().GetFromGuild(guildId); + if (roles.Any(s => s.RoleId == role.Id && s.GuildId == role.Guild.Id)) + return false; + + uow.Set().Add(new() + { + Group = group, + RoleId = role.Id, + GuildId = role.Guild.Id + }); + uow.SaveChanges(); + return true; + } + + public bool ToggleAdSarm(ulong guildId) + { + bool newval; + using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + newval = config.AutoDeleteSelfAssignedRoleMessages = !config.AutoDeleteSelfAssignedRoleMessages; + uow.SaveChanges(); + return newval; + } + + public async Task<(AssignResult Result, bool AutoDelete, object extra)> Assign(IGuildUser guildUser, IRole role) + { + LevelStats userLevelData; + await using (var uow = _db.GetDbContext()) + { + var stats = uow.GetOrCreateUserXpStats(guildUser.Guild.Id, guildUser.Id); + userLevelData = new(stats.Xp + stats.AwardedXp); + } + + var (autoDelete, exclusive, roles) = GetAdAndRoles(guildUser.Guild.Id); + + var theRoleYouWant = roles.FirstOrDefault(r => r.RoleId == role.Id); + if (theRoleYouWant is null) + return (AssignResult.ErrNotAssignable, autoDelete, null); + if (theRoleYouWant.LevelRequirement > userLevelData.Level) + return (AssignResult.ErrLvlReq, autoDelete, theRoleYouWant.LevelRequirement); + if (guildUser.RoleIds.Contains(role.Id)) + return (AssignResult.ErrAlreadyHave, autoDelete, null); + + var roleIds = roles.Where(x => x.Group == theRoleYouWant.Group).Select(x => x.RoleId).ToArray(); + if (exclusive) + { + var sameRoles = guildUser.RoleIds.Where(r => roleIds.Contains(r)); + + foreach (var roleId in sameRoles) + { + var sameRole = guildUser.Guild.GetRole(roleId); + if (sameRole is not null) + { + try + { + await guildUser.RemoveRoleAsync(sameRole); + await Task.Delay(300); + } + catch + { + // ignored + } + } + } + } + + try + { + await guildUser.AddRoleAsync(role); + } + catch (Exception ex) + { + return (AssignResult.ErrNotPerms, autoDelete, ex); + } + + return (AssignResult.Assigned, autoDelete, null); + } + + public async Task SetNameAsync(ulong guildId, int group, string name) + { + var set = false; + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, y => y.Include(x => x.SelfAssignableRoleGroupNames)); + var toUpdate = gc.SelfAssignableRoleGroupNames.FirstOrDefault(x => x.Number == group); + + if (string.IsNullOrWhiteSpace(name)) + { + if (toUpdate is not null) + gc.SelfAssignableRoleGroupNames.Remove(toUpdate); + } + else if (toUpdate is null) + { + gc.SelfAssignableRoleGroupNames.Add(new() + { + Name = name, + Number = group + }); + set = true; + } + else + { + toUpdate.Name = name; + set = true; + } + + await uow.SaveChangesAsync(); + + return set; + } + + public async Task<(RemoveResult Result, bool AutoDelete)> Remove(IGuildUser guildUser, IRole role) + { + var (autoDelete, _, roles) = GetAdAndRoles(guildUser.Guild.Id); + + if (roles.FirstOrDefault(r => r.RoleId == role.Id) is null) + return (RemoveResult.ErrNotAssignable, autoDelete); + if (!guildUser.RoleIds.Contains(role.Id)) + return (RemoveResult.ErrNotHave, autoDelete); + try + { + await guildUser.RemoveRoleAsync(role); + } + catch (Exception) + { + return (RemoveResult.ErrNotPerms, autoDelete); + } + + return (RemoveResult.Removed, autoDelete); + } + + public bool RemoveSar(ulong guildId, ulong roleId) + { + bool success; + using var uow = _db.GetDbContext(); + success = uow.Set().DeleteByGuildAndRoleId(guildId, roleId); + uow.SaveChanges(); + return success; + } + + public (bool AutoDelete, bool Exclusive, IReadOnlyCollection) GetAdAndRoles(ulong guildId) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + var autoDelete = gc.AutoDeleteSelfAssignedRoleMessages; + var exclusive = gc.ExclusiveSelfAssignedRoles; + var roles = uow.Set().GetFromGuild(guildId); + + return (autoDelete, exclusive, roles); + } + + public bool SetLevelReq(ulong guildId, IRole role, int level) + { + using var uow = _db.GetDbContext(); + var roles = uow.Set().GetFromGuild(guildId); + var sar = roles.FirstOrDefault(x => x.RoleId == role.Id); + if (sar is not null) + { + sar.LevelRequirement = level; + uow.SaveChanges(); + } + else + return false; + + return true; + } + + public bool ToggleEsar(ulong guildId) + { + bool areExclusive; + using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + + areExclusive = config.ExclusiveSelfAssignedRoles = !config.ExclusiveSelfAssignedRoles; + uow.SaveChanges(); + return areExclusive; + } + + public (bool Exclusive, IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> Roles, IDictionary + GroupNames + ) GetRoles(IGuild guild) + { + var exclusive = false; + + IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> roles; + IDictionary groupNames; + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.SelfAssignableRoleGroupNames)); + exclusive = gc.ExclusiveSelfAssignedRoles; + groupNames = gc.SelfAssignableRoleGroupNames.ToDictionary(x => x.Number, x => x.Name); + var roleModels = uow.Set().GetFromGuild(guild.Id); + roles = roleModels.Select(x => (Model: x, Role: guild.GetRole(x.RoleId))) + .ToList(); + uow.Set().RemoveRange(roles.Where(x => x.Role is null).Select(x => x.Model).ToArray()); + uow.SaveChanges(); + } + + return (exclusive, roles.Where(x => x.Role is not null).ToList(), groupNames); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/ServerLog/DummyLogCommandService.cs b/src/Ellie.Bot.Modules.Administration/ServerLog/DummyLogCommandService.cs new file mode 100644 index 0000000..22be6b2 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/ServerLog/DummyLogCommandService.cs @@ -0,0 +1,25 @@ +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration; + +public sealed class DummyLogCommandService : ILogCommandService +#if GLOBAL_ELLIE +, IEService +#endif +{ + public void AddDeleteIgnore(ulong xId) + { + } + + public Task LogServer(ulong guildId, ulong channelId, bool actionValue) + => Task.CompletedTask; + + public bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType) + => false; + + public LogSetting? GetGuildLogSettings(ulong guildId) + => default; + + public bool Log(ulong guildId, ulong? channelId, LogType type) + => false; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/ServerLog/ServerLogCommandService.cs b/src/Ellie.Bot.Modules.Administration/ServerLog/ServerLogCommandService.cs new file mode 100644 index 0000000..770d688 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/ServerLog/ServerLogCommandService.cs @@ -0,0 +1,1358 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Modules.Administration.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration; + +public sealed class LogCommandService : ILogCommandService, IReadyExecutor +#if !GLOBAL_ELLIE + , IEService // don't load this service on global ellie +#endif +{ + public ConcurrentDictionary GuildLogSettings { get; } + + private ConcurrentDictionary> PresenceUpdates { get; } = new(); + private readonly DiscordSocketClient _client; + + private readonly IBotStrings _strings; + private readonly DbService _db; + private readonly MuteService _mute; + private readonly ProtectionService _prot; + private readonly GuildTimezoneService _tz; + private readonly IEmbedBuilderService _eb; + private readonly IMemoryCache _memoryCache; + + private readonly ConcurrentHashSet _ignoreMessageIds = new(); + private readonly UserPunishService _punishService; + + public LogCommandService( + DiscordSocketClient client, + IBotStrings strings, + DbService db, + MuteService mute, + ProtectionService prot, + GuildTimezoneService tz, + IMemoryCache memoryCache, + IEmbedBuilderService eb, + UserPunishService punishService) + { + _client = client; + _memoryCache = memoryCache; + _eb = eb; + _strings = strings; + _db = db; + _mute = mute; + _prot = prot; + _tz = tz; + _punishService = punishService; + + using (var uow = db.GetDbContext()) + { + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set().AsQueryable() + .AsNoTracking() + .Where(x => guildIds.Contains(x.GuildId)) + .Include(ls => ls.LogIgnores) + .ToList(); + + GuildLogSettings = configs.ToDictionary(ls => ls.GuildId).ToConcurrent(); + } + + //_client.MessageReceived += _client_MessageReceived; + _client.MessageUpdated += _client_MessageUpdated; + _client.MessageDeleted += _client_MessageDeleted; + _client.UserBanned += _client_UserBanned; + _client.UserUnbanned += _client_UserUnbanned; + _client.UserJoined += _client_UserJoined; + _client.UserLeft += _client_UserLeft; + // _client.PresenceUpdated += _client_UserPresenceUpdated; + _client.UserVoiceStateUpdated += _client_UserVoiceStateUpdated; + _client.UserVoiceStateUpdated += _client_UserVoiceStateUpdated_TTS; + _client.GuildMemberUpdated += _client_GuildUserUpdated; + _client.PresenceUpdated += _client_PresenceUpdated; + _client.UserUpdated += _client_UserUpdated; + _client.ChannelCreated += _client_ChannelCreated; + _client.ChannelDestroyed += _client_ChannelDestroyed; + _client.ChannelUpdated += _client_ChannelUpdated; + _client.RoleDeleted += _client_RoleDeleted; + + _client.ThreadCreated += _client_ThreadCreated; + _client.ThreadDeleted += _client_ThreadDeleted; + + _mute.UserMuted += MuteCommands_UserMuted; + _mute.UserUnmuted += MuteCommands_UserUnmuted; + + _prot.OnAntiProtectionTriggered += TriggeredAntiProtection; + + _punishService.OnUserWarned += PunishServiceOnOnUserWarned; + } + + private async Task _client_PresenceUpdated(SocketUser user, SocketPresence? before, SocketPresence? after) + { + if (user is not SocketGuildUser gu) + return; + + if (!GuildLogSettings.TryGetValue(gu.Guild.Id, out var logSetting) + || before is null + || after is null) + return; + + ITextChannel? logChannel; + + if (!user.IsBot + && logSetting.LogUserPresenceId is not null + && (logChannel = + await TryGetLogChannel(gu.Guild, logSetting, LogType.UserPresence)) is not null) + { + if (before.Status != after.Status) + { + var str = "🎭" + + Format.Code(PrettyCurrentTime(gu.Guild)) + + GetText(logChannel.Guild, + strs.user_status_change("👤" + Format.Bold(gu.Username), + Format.Bold(after.Status.ToString()))); + PresenceUpdates.AddOrUpdate(logChannel, + new List + { + str + }, + (_, list) => + { + list.Add(str); + return list; + }); + } + else if (before.Activities.FirstOrDefault()?.Name != after.Activities.FirstOrDefault()?.Name) + { + var str = + $"👾`{PrettyCurrentTime(gu.Guild)}`👤__**{gu.Username}**__ is now playing **{after.Activities.FirstOrDefault()?.Name ?? "-"}**."; + PresenceUpdates.AddOrUpdate(logChannel, + new List + { + str + }, + (_, list) => + { + list.Add(str); + return list; + }); + } + } + } + + private Task _client_ThreadDeleted(Cacheable sch) + { + _ = Task.Run(async () => + { + try + { + if (!sch.HasValue) + return; + + var ch = sch.Value; + + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ThreadDeletedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ThreadDeleted)) is null) + return; + + var title = GetText(logChannel.Guild, strs.thread_deleted); + + await logChannel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle("🗑 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ThreadCreated(SocketThreadChannel ch) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ThreadCreatedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ThreadCreated)) is null) + return; + + var title = GetText(logChannel.Guild, strs.thread_created); + + await logChannel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))); + } + catch (Exception) + { + // ignored + } + }); + + return Task.CompletedTask; + } + + public async Task OnReadyAsync() + => await Task.WhenAll(PresenceUpdateTask(), IgnoreMessageIdsClearTask()); + + private async Task IgnoreMessageIdsClearTask() + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + _ignoreMessageIds.Clear(); + } + + private async Task PresenceUpdateTask() + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(15)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var keys = PresenceUpdates.Keys.ToList(); + + await keys.Select(key => + { + if (!((SocketGuild)key.Guild).CurrentUser.GetPermissions(key).SendMessages) + return Task.CompletedTask; + + if (PresenceUpdates.TryRemove(key, out var msgs)) + { + var title = GetText(key.Guild, strs.presence_updates); + var desc = string.Join(Environment.NewLine, msgs); + return key.SendConfirmAsync(_eb, title, desc.TrimTo(2048)!); + } + + return Task.CompletedTask; + }) + .WhenAll(); + } + catch + { + } + } + } + + public LogSetting? GetGuildLogSettings(ulong guildId) + { + GuildLogSettings.TryGetValue(guildId, out var logSetting); + return logSetting; + } + + public void AddDeleteIgnore(ulong messageId) + => _ignoreMessageIds.Add(messageId); + + public bool LogIgnore(ulong gid, ulong itemId, IgnoredItemType itemType) + { + using var uow = _db.GetDbContext(); + var logSetting = uow.LogSettingsFor(gid); + var removed = logSetting.LogIgnores.RemoveAll(x => x.ItemType == itemType && itemId == x.LogItemId); + + if (removed == 0) + { + var toAdd = new IgnoredLogItem + { + LogItemId = itemId, + ItemType = itemType + }; + logSetting.LogIgnores.Add(toAdd); + } + + uow.SaveChanges(); + GuildLogSettings.AddOrUpdate(gid, logSetting, (_, _) => logSetting); + return removed > 0; + } + + private string GetText(IGuild guild, LocStr str) + => _strings.GetText(str, guild.Id); + + private string PrettyCurrentTime(IGuild? g) + { + var time = DateTime.UtcNow; + if (g is not null) + time = TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(g.Id)); + return $"【{time:HH:mm:ss}】"; + } + + private string CurrentTime(IGuild? g) + { + var time = DateTime.UtcNow; + if (g is not null) + time = TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(g.Id)); + + return $"{time:HH:mm:ss}"; + } + + public async Task LogServer(ulong guildId, ulong channelId, bool value) + { + await using var uow = _db.GetDbContext(); + var logSetting = uow.LogSettingsFor(guildId); + + logSetting.LogOtherId = logSetting.MessageUpdatedId = logSetting.MessageDeletedId = logSetting.UserJoinedId = + logSetting.UserLeftId = logSetting.UserBannedId = logSetting.UserUnbannedId = logSetting.UserUpdatedId = + logSetting.ChannelCreatedId = logSetting.ChannelDestroyedId = logSetting.ChannelUpdatedId = + logSetting.LogUserPresenceId = logSetting.LogVoicePresenceId = logSetting.UserMutedId = + logSetting.LogVoicePresenceTTSId = logSetting.ThreadCreatedId = logSetting.ThreadDeletedId + = logSetting.LogWarnsId = value ? channelId : null; + await uow.SaveChangesAsync(); + GuildLogSettings.AddOrUpdate(guildId, _ => logSetting, (_, _) => logSetting); + } + + + private async Task PunishServiceOnOnUserWarned(Warning arg) + { + if (!GuildLogSettings.TryGetValue(arg.GuildId, out var logSetting) || logSetting.LogWarnsId is null) + return; + + var g = _client.GetGuild(arg.GuildId); + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(g, logSetting, LogType.UserWarned)) is null) + return; + + var embed = _eb.Create() + .WithOkColor() + .WithTitle($"⚠️ User Warned") + .WithDescription($"<@{arg.UserId}> | {arg.UserId}") + .AddField("Mod", arg.Moderator) + .AddField("Reason", string.IsNullOrWhiteSpace(arg.Reason) ? "-" : arg.Reason, true) + .WithFooter(CurrentTime(g)); + + await logChannel.EmbedAsync(embed); + } + + private Task _client_UserUpdated(SocketUser before, SocketUser uAfter) + { + _ = Task.Run(async () => + { + try + { + if (uAfter is not SocketGuildUser after) + return; + + var g = after.Guild; + + if (!GuildLogSettings.TryGetValue(g.Id, out var logSetting) || logSetting.UserUpdatedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(g, logSetting, LogType.UserUpdated)) is null) + return; + + var embed = _eb.Create(); + + if (before.Username != after.Username) + { + embed.WithTitle("👥 " + GetText(g, strs.username_changed)) + .WithDescription($"{before.Username}#{before.Discriminator} | {before.Id}") + .AddField("Old Name", $"{before.Username}", true) + .AddField("New Name", $"{after.Username}", true) + .WithFooter(CurrentTime(g)) + .WithOkColor(); + } + else if (before.AvatarId != after.AvatarId) + { + embed.WithTitle("👥" + GetText(g, strs.avatar_changed)) + .WithDescription($"{before.Username}#{before.Discriminator} | {before.Id}") + .WithFooter(CurrentTime(g)) + .WithOkColor(); + + var bav = before.RealAvatarUrl(); + if (bav.IsAbsoluteUri) + embed.WithThumbnailUrl(bav.ToString()); + + var aav = after.RealAvatarUrl(); + if (aav.IsAbsoluteUri) + embed.WithImageUrl(aav.ToString()); + } + else + return; + + await logChannel.EmbedAsync(embed); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + public bool Log(ulong gid, ulong? cid, LogType type /*, string options*/) + { + ulong? channelId = null; + using (var uow = _db.GetDbContext()) + { + var logSetting = uow.LogSettingsFor(gid); + GuildLogSettings.AddOrUpdate(gid, _ => logSetting, (_, _) => logSetting); + switch (type) + { + case LogType.Other: + channelId = logSetting.LogOtherId = logSetting.LogOtherId is null ? cid : default; + break; + case LogType.MessageUpdated: + channelId = logSetting.MessageUpdatedId = logSetting.MessageUpdatedId is null ? cid : default; + break; + case LogType.MessageDeleted: + channelId = logSetting.MessageDeletedId = logSetting.MessageDeletedId is null ? cid : default; + //logSetting.DontLogBotMessageDeleted = (options == "nobot"); + break; + case LogType.UserJoined: + channelId = logSetting.UserJoinedId = logSetting.UserJoinedId is null ? cid : default; + break; + case LogType.UserLeft: + channelId = logSetting.UserLeftId = logSetting.UserLeftId is null ? cid : default; + break; + case LogType.UserBanned: + channelId = logSetting.UserBannedId = logSetting.UserBannedId is null ? cid : default; + break; + case LogType.UserUnbanned: + channelId = logSetting.UserUnbannedId = logSetting.UserUnbannedId is null ? cid : default; + break; + case LogType.UserUpdated: + channelId = logSetting.UserUpdatedId = logSetting.UserUpdatedId is null ? cid : default; + break; + case LogType.UserMuted: + channelId = logSetting.UserMutedId = logSetting.UserMutedId is null ? cid : default; + break; + case LogType.ChannelCreated: + channelId = logSetting.ChannelCreatedId = logSetting.ChannelCreatedId is null ? cid : default; + break; + case LogType.ChannelDestroyed: + channelId = logSetting.ChannelDestroyedId = logSetting.ChannelDestroyedId is null ? cid : default; + break; + case LogType.ChannelUpdated: + channelId = logSetting.ChannelUpdatedId = logSetting.ChannelUpdatedId is null ? cid : default; + break; + case LogType.UserPresence: + channelId = logSetting.LogUserPresenceId = logSetting.LogUserPresenceId is null ? cid : default; + break; + case LogType.VoicePresence: + channelId = logSetting.LogVoicePresenceId = logSetting.LogVoicePresenceId is null ? cid : default; + break; + case LogType.VoicePresenceTts: + channelId = logSetting.LogVoicePresenceTTSId = + logSetting.LogVoicePresenceTTSId is null ? cid : default; + break; + case LogType.UserWarned: + channelId = logSetting.LogWarnsId = logSetting.LogWarnsId is null ? cid : default; + break; + case LogType.ThreadDeleted: + channelId = logSetting.ThreadDeletedId = logSetting.ThreadDeletedId is null ? cid : default; + break; + case LogType.ThreadCreated: + channelId = logSetting.ThreadCreatedId = logSetting.ThreadCreatedId is null ? cid : default; + break; + } + + uow.SaveChanges(); + } + + return channelId is not null; + } + + private Task _client_UserVoiceStateUpdated_TTS(SocketUser iusr, SocketVoiceState before, SocketVoiceState after) + { + _ = Task.Run(async () => + { + try + { + if (iusr is not IGuildUser usr) + return; + + var beforeVch = before.VoiceChannel; + var afterVch = after.VoiceChannel; + + if (beforeVch == afterVch) + return; + + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) + || logSetting.LogVoicePresenceTTSId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.VoicePresenceTts)) is null) + return; + + var str = string.Empty; + if (beforeVch?.Guild == afterVch?.Guild) + str = GetText(logChannel.Guild, strs.log_vc_moved(usr.Username, beforeVch?.Name, afterVch?.Name)); + else if (beforeVch is null) + str = GetText(logChannel.Guild, strs.log_vc_joined(usr.Username, afterVch?.Name)); + else if (afterVch is null) + str = GetText(logChannel.Guild, strs.log_vc_left(usr.Username, beforeVch.Name)); + + var toDelete = await logChannel.SendMessageAsync(str, true); + toDelete.DeleteAfter(5); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private void MuteCommands_UserMuted( + IGuildUser usr, + IUser mod, + MuteType muteType, + string reason) + => _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) || logSetting.UserMutedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.UserMuted)) is null) + return; + var mutes = string.Empty; + var mutedLocalized = GetText(logChannel.Guild, strs.muted_sn); + switch (muteType) + { + case MuteType.Voice: + mutes = "🔇 " + GetText(logChannel.Guild, strs.xmuted_voice(mutedLocalized, mod.ToString())); + break; + case MuteType.Chat: + mutes = "🔇 " + GetText(logChannel.Guild, strs.xmuted_text(mutedLocalized, mod.ToString())); + break; + case MuteType.All: + mutes = "🔇 " + + GetText(logChannel.Guild, strs.xmuted_text_and_voice(mutedLocalized, mod.ToString())); + break; + } + + var embed = _eb.Create() + .WithAuthor(mutes) + .WithTitle($"{usr.Username}#{usr.Discriminator} | {usr.Id}") + .WithFooter(CurrentTime(usr.Guild)) + .WithOkColor(); + + await logChannel.EmbedAsync(embed); + } + catch + { + // ignored + } + }); + + private void MuteCommands_UserUnmuted( + IGuildUser usr, + IUser mod, + MuteType muteType, + string reason) + => _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) || logSetting.UserMutedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.UserMuted)) is null) + return; + + var mutes = string.Empty; + var unmutedLocalized = GetText(logChannel.Guild, strs.unmuted_sn); + switch (muteType) + { + case MuteType.Voice: + mutes = "🔊 " + GetText(logChannel.Guild, strs.xmuted_voice(unmutedLocalized, mod.ToString())); + break; + case MuteType.Chat: + mutes = "🔊 " + GetText(logChannel.Guild, strs.xmuted_text(unmutedLocalized, mod.ToString())); + break; + case MuteType.All: + mutes = "🔊 " + + GetText(logChannel.Guild, + strs.xmuted_text_and_voice(unmutedLocalized, mod.ToString())); + break; + } + + var embed = _eb.Create() + .WithAuthor(mutes) + .WithTitle($"{usr.Username}#{usr.Discriminator} | {usr.Id}") + .WithFooter($"{CurrentTime(usr.Guild)}") + .WithOkColor(); + + if (!string.IsNullOrWhiteSpace(reason)) + embed.WithDescription(reason); + + await logChannel.EmbedAsync(embed); + } + catch + { + // ignored + } + }); + + public Task TriggeredAntiProtection(PunishmentAction action, ProtectionType protection, params IGuildUser[] users) + { + _ = Task.Run(async () => + { + try + { + if (users.Length == 0) + return; + + if (!GuildLogSettings.TryGetValue(users.First().Guild.Id, out var logSetting) + || logSetting.LogOtherId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(users.First().Guild, logSetting, LogType.Other)) is null) + return; + + var punishment = string.Empty; + switch (action) + { + case PunishmentAction.Mute: + punishment = "🔇 " + GetText(logChannel.Guild, strs.muted_pl).ToUpperInvariant(); + break; + case PunishmentAction.Kick: + punishment = "👢 " + GetText(logChannel.Guild, strs.kicked_pl).ToUpperInvariant(); + break; + case PunishmentAction.Softban: + punishment = "☣ " + GetText(logChannel.Guild, strs.soft_banned_pl).ToUpperInvariant(); + break; + case PunishmentAction.Ban: + punishment = "⛔️ " + GetText(logChannel.Guild, strs.banned_pl).ToUpperInvariant(); + break; + case PunishmentAction.RemoveRoles: + punishment = "⛔️ " + GetText(logChannel.Guild, strs.remove_roles_pl).ToUpperInvariant(); + break; + } + + var embed = _eb.Create() + .WithAuthor($"🛡 Anti-{protection}") + .WithTitle(GetText(logChannel.Guild, strs.users) + " " + punishment) + .WithDescription(string.Join("\n", users.Select(u => u.ToString()))) + .WithFooter(CurrentTime(logChannel.Guild)) + .WithOkColor(); + + await logChannel.EmbedAsync(embed); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private string GetRoleDeletedKey(ulong roleId) + => $"role_deleted_{roleId}"; + + private Task _client_RoleDeleted(SocketRole socketRole) + { + Serilog.Log.Information("Role deleted {RoleId}", socketRole.Id); + _memoryCache.Set(GetRoleDeletedKey(socketRole.Id), true, TimeSpan.FromMinutes(5)); + return Task.CompletedTask; + } + + private bool IsRoleDeleted(ulong roleId) + { + var isDeleted = _memoryCache.TryGetValue(GetRoleDeletedKey(roleId), out _); + return isDeleted; + } + + private Task _client_GuildUserUpdated(Cacheable optBefore, SocketGuildUser after) + { + _ = Task.Run(async () => + { + try + { + var before = await optBefore.GetOrDownloadAsync(); + + if (before is null) + return; + + if (!GuildLogSettings.TryGetValue(before.Guild.Id, out var logSetting) + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == after.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if (logSetting.UserUpdatedId is not null + && (logChannel = await TryGetLogChannel(before.Guild, logSetting, LogType.UserUpdated)) is not null) + { + var embed = _eb.Create() + .WithOkColor() + .WithFooter(CurrentTime(before.Guild)) + .WithTitle($"{before.Username}#{before.Discriminator} | {before.Id}"); + if (before.Nickname != after.Nickname) + { + embed.WithAuthor("👥 " + GetText(logChannel.Guild, strs.nick_change)) + .AddField(GetText(logChannel.Guild, strs.old_nick), + $"{before.Nickname}#{before.Discriminator}") + .AddField(GetText(logChannel.Guild, strs.new_nick), + $"{after.Nickname}#{after.Discriminator}"); + + await logChannel.EmbedAsync(embed); + } + else if (!before.Roles.SequenceEqual(after.Roles)) + { + if (before.Roles.Count < after.Roles.Count) + { + var diffRoles = after.Roles.Where(r => !before.Roles.Contains(r)).Select(r => r.Name); + embed.WithAuthor("⚔ " + GetText(logChannel.Guild, strs.user_role_add)) + .WithDescription(string.Join(", ", diffRoles).SanitizeMentions()); + + await logChannel.EmbedAsync(embed); + } + else if (before.Roles.Count > after.Roles.Count) + { + await Task.Delay(1000); + var diffRoles = before.Roles.Where(r => !after.Roles.Contains(r) && !IsRoleDeleted(r.Id)) + .Select(r => r.Name) + .ToList(); + + if (diffRoles.Any()) + { + embed.WithAuthor("⚔ " + GetText(logChannel.Guild, strs.user_role_rem)) + .WithDescription(string.Join(", ", diffRoles).SanitizeMentions()); + + await logChannel.EmbedAsync(embed); + } + } + } + } + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ChannelUpdated(IChannel cbefore, IChannel cafter) + { + _ = Task.Run(async () => + { + try + { + if (cbefore is not IGuildChannel before) + return; + + var after = (IGuildChannel)cafter; + + if (!GuildLogSettings.TryGetValue(before.Guild.Id, out var logSetting) + || logSetting.ChannelUpdatedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == after.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(before.Guild, logSetting, LogType.ChannelUpdated)) is null) + return; + + var embed = _eb.Create().WithOkColor().WithFooter(CurrentTime(before.Guild)); + + var beforeTextChannel = cbefore as ITextChannel; + var afterTextChannel = cafter as ITextChannel; + + if (before.Name != after.Name) + { + embed.WithTitle("ℹ️ " + GetText(logChannel.Guild, strs.ch_name_change)) + .WithDescription($"{after} | {after.Id}") + .AddField(GetText(logChannel.Guild, strs.ch_old_name), before.Name); + } + else if (beforeTextChannel?.Topic != afterTextChannel?.Topic) + { + embed.WithTitle("ℹ️ " + GetText(logChannel.Guild, strs.ch_topic_change)) + .WithDescription($"{after} | {after.Id}") + .AddField(GetText(logChannel.Guild, strs.old_topic), beforeTextChannel?.Topic ?? "-") + .AddField(GetText(logChannel.Guild, strs.new_topic), afterTextChannel?.Topic ?? "-"); + } + else + return; + + await logChannel.EmbedAsync(embed); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ChannelDestroyed(IChannel ich) + { + _ = Task.Run(async () => + { + try + { + if (ich is not IGuildChannel ch) + return; + + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ChannelDestroyedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == ch.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ChannelDestroyed)) is null) + return; + + string title; + if (ch is IVoiceChannel) + title = GetText(logChannel.Guild, strs.voice_chan_destroyed); + else + title = GetText(logChannel.Guild, strs.text_chan_destroyed); + + await logChannel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ChannelCreated(IChannel ich) + { + _ = Task.Run(async () => + { + try + { + if (ich is not IGuildChannel ch) + return; + + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ChannelCreatedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ChannelCreated)) is null) + return; + string title; + if (ch is IVoiceChannel) + title = GetText(logChannel.Guild, strs.voice_chan_created); + else + title = GetText(logChannel.Guild, strs.text_chan_created); + + await logChannel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserVoiceStateUpdated(SocketUser iusr, SocketVoiceState before, SocketVoiceState after) + { + _ = Task.Run(async () => + { + try + { + if (iusr is not IGuildUser usr || usr.IsBot) + return; + + var beforeVch = before.VoiceChannel; + var afterVch = after.VoiceChannel; + + if (beforeVch == afterVch) + return; + + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) + || logSetting.LogVoicePresenceId is null + || logSetting.LogIgnores.Any( + ilc => ilc.LogItemId == iusr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.VoicePresence)) is null) + return; + + var str = string.Empty; + if (beforeVch?.Guild == afterVch?.Guild) + { + str = "🎙" + + Format.Code(PrettyCurrentTime(usr.Guild)) + + GetText(logChannel.Guild, + strs.user_vmoved("👤" + Format.Bold(usr.Username + "#" + usr.Discriminator), + Format.Bold(beforeVch?.Name ?? ""), + Format.Bold(afterVch?.Name ?? ""))); + } + else if (beforeVch is null) + { + str = "🎙" + + Format.Code(PrettyCurrentTime(usr.Guild)) + + GetText(logChannel.Guild, + strs.user_vjoined("👤" + Format.Bold(usr.Username + "#" + usr.Discriminator), + Format.Bold(afterVch?.Name ?? ""))); + } + else if (afterVch is null) + { + str = "🎙" + + Format.Code(PrettyCurrentTime(usr.Guild)) + + GetText(logChannel.Guild, + strs.user_vleft("👤" + Format.Bold(usr.Username + "#" + usr.Discriminator), + Format.Bold(beforeVch.Name ?? ""))); + } + + if (!string.IsNullOrWhiteSpace(str)) + { + PresenceUpdates.AddOrUpdate(logChannel, + new List + { + str + }, + (_, list) => + { + list.Add(str); + return list; + }); + } + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserLeft(SocketGuild guild, SocketUser usr) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(guild.Id, out var logSetting) + || logSetting.UserLeftId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == usr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserLeft)) is null) + return; + var embed = _eb.Create() + .WithOkColor() + .WithTitle("❌ " + GetText(logChannel.Guild, strs.user_left)) + .WithDescription(usr.ToString()) + .AddField("Id", usr.Id.ToString()) + .WithFooter(CurrentTime(guild)); + + if (Uri.IsWellFormedUriString(usr.GetAvatarUrl(), UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await logChannel.EmbedAsync(embed); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserJoined(IGuildUser usr) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) || logSetting.UserJoinedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.UserJoined)) is null) + return; + + var embed = _eb.Create() + .WithOkColor() + .WithTitle("✅ " + GetText(logChannel.Guild, strs.user_joined)) + .WithDescription($"{usr.Mention} `{usr}`") + .AddField("Id", usr.Id.ToString()) + .AddField(GetText(logChannel.Guild, strs.joined_server), + $"{usr.JoinedAt?.ToString("dd.MM.yyyy HH:mm") ?? "?"}", + true) + .AddField(GetText(logChannel.Guild, strs.joined_discord), + $"{usr.CreatedAt:dd.MM.yyyy HH:mm}", + true) + .WithFooter(CurrentTime(usr.Guild)); + + if (Uri.IsWellFormedUriString(usr.GetAvatarUrl(), UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await logChannel.EmbedAsync(embed); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserUnbanned(IUser usr, IGuild guild) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(guild.Id, out var logSetting) + || logSetting.UserUnbannedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == usr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserUnbanned)) is null) + return; + var embed = _eb.Create() + .WithOkColor() + .WithTitle("♻️ " + GetText(logChannel.Guild, strs.user_unbanned)) + .WithDescription(usr.ToString()!) + .AddField("Id", usr.Id.ToString()) + .WithFooter(CurrentTime(guild)); + + if (Uri.IsWellFormedUriString(usr.GetAvatarUrl(), UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await logChannel.EmbedAsync(embed); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserBanned(IUser usr, IGuild guild) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(guild.Id, out var logSetting) + || logSetting.UserBannedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == usr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserBanned)) == null) + return; + + + string? reason = null; + try + { + var ban = await guild.GetBanAsync(usr); + reason = ban?.Reason; + } + catch + { + } + + var embed = _eb.Create() + .WithOkColor() + .WithTitle("🚫 " + GetText(logChannel.Guild, strs.user_banned)) + .WithDescription(usr.ToString()!) + .AddField("Id", usr.Id.ToString()) + .AddField("Reason", string.IsNullOrWhiteSpace(reason) ? "-" : reason) + .WithFooter(CurrentTime(guild)); + + var avatarUrl = usr.GetAvatarUrl(); + + if (Uri.IsWellFormedUriString(avatarUrl, UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await logChannel.EmbedAsync(embed); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_MessageDeleted(Cacheable optMsg, Cacheable optCh) + { + _ = Task.Run(async () => + { + try + { + if (optMsg.Value is not IUserMessage msg || msg.IsAuthor(_client)) + return; + + if (_ignoreMessageIds.Contains(msg.Id)) + return; + + var ch = optCh.Value; + if (ch is not ITextChannel channel) + return; + + if (!GuildLogSettings.TryGetValue(channel.Guild.Id, out var logSetting) + || logSetting.MessageDeletedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == channel.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(channel.Guild, logSetting, LogType.MessageDeleted)) is null + || logChannel.Id == msg.Id) + return; + + var resolvedMessage = msg.Resolve(TagHandling.FullName); + var embed = _eb.Create() + .WithOkColor() + .WithTitle("🗑 " + + GetText(logChannel.Guild, strs.msg_del(((ITextChannel)msg.Channel).Name))) + .WithDescription(msg.Author.ToString()!) + .AddField(GetText(logChannel.Guild, strs.content), + string.IsNullOrWhiteSpace(resolvedMessage) ? "-" : resolvedMessage) + .AddField("Id", msg.Id.ToString()) + .WithFooter(CurrentTime(channel.Guild)); + if (msg.Attachments.Any()) + { + embed.AddField(GetText(logChannel.Guild, strs.attachments), + string.Join(", ", msg.Attachments.Select(a => a.Url))); + } + + await logChannel.EmbedAsync(embed); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_MessageUpdated( + Cacheable optmsg, + SocketMessage imsg2, + ISocketMessageChannel ch) + { + _ = Task.Run(async () => + { + try + { + if (imsg2 is not IUserMessage after || after.IsAuthor(_client)) + return; + + if ((optmsg.HasValue ? optmsg.Value : null) is not IUserMessage before) + return; + + if (ch is not ITextChannel channel) + return; + + if (before.Content == after.Content) + return; + + if (before.Author.IsBot) + return; + + if (!GuildLogSettings.TryGetValue(channel.Guild.Id, out var logSetting) + || logSetting.MessageUpdatedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == channel.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(channel.Guild, logSetting, LogType.MessageUpdated)) is null + || logChannel.Id == after.Channel.Id) + return; + + var embed = _eb.Create() + .WithOkColor() + .WithTitle("📝 " + + GetText(logChannel.Guild, + strs.msg_update(((ITextChannel)after.Channel).Name))) + .WithDescription(after.Author.ToString()!) + .AddField(GetText(logChannel.Guild, strs.old_msg), + string.IsNullOrWhiteSpace(before.Content) + ? "-" + : before.Resolve(TagHandling.FullName)) + .AddField(GetText(logChannel.Guild, strs.new_msg), + string.IsNullOrWhiteSpace(after.Content) ? "-" : after.Resolve(TagHandling.FullName)) + .AddField("Id", after.Id.ToString()) + .WithFooter(CurrentTime(channel.Guild)); + + await logChannel.EmbedAsync(embed); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private async Task TryGetLogChannel(IGuild guild, LogSetting logSetting, LogType logChannelType) + { + ulong? id = null; + switch (logChannelType) + { + case LogType.Other: + id = logSetting.LogOtherId; + break; + case LogType.MessageUpdated: + id = logSetting.MessageUpdatedId; + break; + case LogType.MessageDeleted: + id = logSetting.MessageDeletedId; + break; + case LogType.UserJoined: + id = logSetting.UserJoinedId; + break; + case LogType.UserLeft: + id = logSetting.UserLeftId; + break; + case LogType.UserBanned: + id = logSetting.UserBannedId; + break; + case LogType.UserUnbanned: + id = logSetting.UserUnbannedId; + break; + case LogType.UserUpdated: + id = logSetting.UserUpdatedId; + break; + case LogType.ChannelCreated: + id = logSetting.ChannelCreatedId; + break; + case LogType.ChannelDestroyed: + id = logSetting.ChannelDestroyedId; + break; + case LogType.ChannelUpdated: + id = logSetting.ChannelUpdatedId; + break; + case LogType.UserPresence: + id = logSetting.LogUserPresenceId; + break; + case LogType.VoicePresence: + id = logSetting.LogVoicePresenceId; + break; + case LogType.VoicePresenceTts: + id = logSetting.LogVoicePresenceTTSId; + break; + case LogType.UserMuted: + id = logSetting.UserMutedId; + break; + case LogType.UserWarned: + id = logSetting.LogWarnsId; + break; + case LogType.ThreadCreated: + id = logSetting.ThreadCreatedId; + break; + case LogType.ThreadDeleted: + id = logSetting.ThreadDeletedId; + break; + } + + if (id is null or 0) + { + UnsetLogSetting(guild.Id, logChannelType); + return null; + } + + var channel = await guild.GetTextChannelAsync(id.Value); + + if (channel is null) + { + UnsetLogSetting(guild.Id, logChannelType); + return null; + } + + return channel; + } + + private void UnsetLogSetting(ulong guildId, LogType logChannelType) + { + using var uow = _db.GetDbContext(); + var newLogSetting = uow.LogSettingsFor(guildId); + switch (logChannelType) + { + case LogType.Other: + newLogSetting.LogOtherId = null; + break; + case LogType.MessageUpdated: + newLogSetting.MessageUpdatedId = null; + break; + case LogType.MessageDeleted: + newLogSetting.MessageDeletedId = null; + break; + case LogType.UserJoined: + newLogSetting.UserJoinedId = null; + break; + case LogType.UserLeft: + newLogSetting.UserLeftId = null; + break; + case LogType.UserBanned: + newLogSetting.UserBannedId = null; + break; + case LogType.UserUnbanned: + newLogSetting.UserUnbannedId = null; + break; + case LogType.UserUpdated: + newLogSetting.UserUpdatedId = null; + break; + case LogType.UserMuted: + newLogSetting.UserMutedId = null; + break; + case LogType.ChannelCreated: + newLogSetting.ChannelCreatedId = null; + break; + case LogType.ChannelDestroyed: + newLogSetting.ChannelDestroyedId = null; + break; + case LogType.ChannelUpdated: + newLogSetting.ChannelUpdatedId = null; + break; + case LogType.UserPresence: + newLogSetting.LogUserPresenceId = null; + break; + case LogType.VoicePresence: + newLogSetting.LogVoicePresenceId = null; + break; + case LogType.VoicePresenceTts: + newLogSetting.LogVoicePresenceTTSId = null; + break; + case LogType.UserWarned: + newLogSetting.LogWarnsId = null; + break; + } + + GuildLogSettings.AddOrUpdate(guildId, newLogSetting, (_, _) => newLogSetting); + uow.SaveChanges(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/ServerLog/ServerLogCommands.cs b/src/Ellie.Bot.Modules.Administration/ServerLog/ServerLogCommands.cs new file mode 100644 index 0000000..2035c24 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/ServerLog/ServerLogCommands.cs @@ -0,0 +1,171 @@ +using Ellie.Common.TypeReaders.Models; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + [NoPublicBot] + public partial class LogCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogServer(PermissionAction action) + { + await _service.LogServer(ctx.Guild.Id, ctx.Channel.Id, action.Value); + if (action.Value) + await ReplyConfirmLocalizedAsync(strs.log_all); + else + await ReplyConfirmLocalizedAsync(strs.log_disabled); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogIgnore() + { + var settings = _service.GetGuildLogSettings(ctx.Guild.Id); + + var chs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.Channel).ToList() + ?? new List(); + var usrs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.User).ToList() + ?? new List(); + + var eb = _eb.Create(ctx) + .WithOkColor() + .AddField(GetText(strs.log_ignored_channels), + chs.Count == 0 + ? "-" + : string.Join('\n', chs.Select(x => $"{x.LogItemId} | <#{x.LogItemId}>"))) + .AddField(GetText(strs.log_ignored_users), + usrs.Count == 0 + ? "-" + : string.Join('\n', usrs.Select(x => $"{x.LogItemId} | <@{x.LogItemId}>"))); + + await ctx.Channel.EmbedAsync(eb); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogIgnore([Leftover] ITextChannel target) + { + var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.Channel); + + if (!removed) + { + await ReplyConfirmLocalizedAsync( + strs.log_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")"))); + } + else + { + await ReplyConfirmLocalizedAsync( + strs.log_not_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")"))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogIgnore([Leftover] IUser target) + { + var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.User); + + if (!removed) + { + await ReplyConfirmLocalizedAsync( + strs.log_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")"))); + } + else + { + await ReplyConfirmLocalizedAsync( + strs.log_not_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")"))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogEvents() + { + var logSetting = _service.GetGuildLogSettings(ctx.Guild.Id); + var str = string.Join("\n", + Enum.GetNames() + .Select(x => + { + var val = logSetting is null ? null : GetLogProperty(logSetting, Enum.Parse(x)); + if (val is not null) + return $"{Format.Bold(x)} <#{val}>"; + return Format.Bold(x); + })); + + await SendConfirmAsync(Format.Bold(GetText(strs.log_events)) + "\n" + str); + } + + private static ulong? GetLogProperty(LogSetting l, LogType type) + { + switch (type) + { + case LogType.Other: + return l.LogOtherId; + case LogType.MessageUpdated: + return l.MessageUpdatedId; + case LogType.MessageDeleted: + return l.MessageDeletedId; + case LogType.UserJoined: + return l.UserJoinedId; + case LogType.UserLeft: + return l.UserLeftId; + case LogType.UserBanned: + return l.UserBannedId; + case LogType.UserUnbanned: + return l.UserUnbannedId; + case LogType.UserUpdated: + return l.UserUpdatedId; + case LogType.ChannelCreated: + return l.ChannelCreatedId; + case LogType.ChannelDestroyed: + return l.ChannelDestroyedId; + case LogType.ChannelUpdated: + return l.ChannelUpdatedId; + case LogType.UserPresence: + return l.LogUserPresenceId; + case LogType.VoicePresence: + return l.LogVoicePresenceId; + case LogType.VoicePresenceTts: + return l.LogVoicePresenceTTSId; + case LogType.UserMuted: + return l.UserMutedId; + case LogType.UserWarned: + return l.LogWarnsId; + case LogType.ThreadDeleted: + return l.ThreadDeletedId; + case LogType.ThreadCreated: + return l.ThreadCreatedId; + default: + return null; + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task Log(LogType type) + { + var val = _service.Log(ctx.Guild.Id, ctx.Channel.Id, type); + + if (val) + await ReplyConfirmLocalizedAsync(strs.log(Format.Bold(type.ToString()))); + else + await ReplyConfirmLocalizedAsync(strs.log_stop(Format.Bold(type.ToString()))); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Timezone/GuildTimezoneService.cs b/src/Ellie.Bot.Modules.Administration/Timezone/GuildTimezoneService.cs new file mode 100644 index 0000000..0bd504e --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Timezone/GuildTimezoneService.cs @@ -0,0 +1,78 @@ +#nullable disable +using Ellie.Db; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration.Services; + +public sealed class GuildTimezoneService : ITimezoneService, IEService +{ + public static ConcurrentDictionary AllServices { get; } = new(); + private readonly ConcurrentDictionary _timezones; + private readonly DbService _db; + + public GuildTimezoneService(DiscordSocketClient client, IBot bot, DbService db) + { + _timezones = bot.AllGuildConfigs.Select(GetTimzezoneTuple) + .Where(x => x.Timezone is not null) + .ToDictionary(x => x.GuildId, x => x.Timezone) + .ToConcurrent(); + + var curUser = client.CurrentUser; + if (curUser is not null) + AllServices.TryAdd(curUser.Id, this); + _db = db; + + bot.JoinedGuild += Bot_JoinedGuild; + } + + private Task Bot_JoinedGuild(GuildConfig arg) + { + var (guildId, tz) = GetTimzezoneTuple(arg); + if (tz is not null) + _timezones.TryAdd(guildId, tz); + return Task.CompletedTask; + } + + private static (ulong GuildId, TimeZoneInfo Timezone) GetTimzezoneTuple(GuildConfig x) + { + TimeZoneInfo tz; + try + { + if (x.TimeZoneId is null) + tz = null; + else + tz = TimeZoneInfo.FindSystemTimeZoneById(x.TimeZoneId); + } + catch + { + tz = null; + } + + return (x.GuildId, Timezone: tz); + } + + public TimeZoneInfo GetTimeZoneOrDefault(ulong? guildId) + { + if (guildId is ulong gid && _timezones.TryGetValue(gid, out var tz)) + return tz; + + return null; + } + + public void SetTimeZone(ulong guildId, TimeZoneInfo tz) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + + gc.TimeZoneId = tz?.Id; + uow.SaveChanges(); + + if (tz is null) + _timezones.TryRemove(guildId, out tz); + else + _timezones.AddOrUpdate(guildId, tz, (_, _) => tz); + } + + public TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId) + => GetTimeZoneOrDefault(guildId) ?? TimeZoneInfo.Utc; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/Timezone/TimeZoneCommands.cs b/src/Ellie.Bot.Modules.Administration/Timezone/TimeZoneCommands.cs new file mode 100644 index 0000000..2386619 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Timezone/TimeZoneCommands.cs @@ -0,0 +1,75 @@ +#nullable disable +using Ellie.Modules.Administration.Services; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class TimeZoneCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Timezones(int page = 1) + { + page--; + + if (page is < 0 or > 20) + return; + + var timezones = TimeZoneInfo.GetSystemTimeZones().OrderBy(x => x.BaseUtcOffset).ToArray(); + var timezonesPerPage = 20; + + var curTime = DateTimeOffset.UtcNow; + + var i = 0; + var timezoneStrings = timezones.Select(x => (x, ++i % 2 == 0)) + .Select(data => + { + var (tzInfo, flip) = data; + var nameStr = $"{tzInfo.Id,-30}"; + var offset = curTime.ToOffset(tzInfo.GetUtcOffset(curTime)) + .ToString("zzz"); + if (flip) + return $"{offset} {Format.Code(nameStr)}"; + return $"{Format.Code(offset)} {nameStr}"; + }); + + + await ctx.SendPaginatedConfirmAsync(page, + curPage => _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.timezones_available)) + .WithDescription(string.Join("\n", + timezoneStrings.Skip(curPage * timezonesPerPage).Take(timezonesPerPage))), + timezones.Length, + timezonesPerPage); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Timezone() + => await ReplyConfirmLocalizedAsync(strs.timezone_guild(_service.GetTimeZoneOrUtc(ctx.Guild.Id))); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task Timezone([Leftover] string id) + { + TimeZoneInfo tz; + try { tz = TimeZoneInfo.FindSystemTimeZoneById(id); } + catch { tz = null; } + + + if (tz is null) + { + await ReplyErrorLocalizedAsync(strs.timezone_not_found); + return; + } + + _service.SetTimeZone(ctx.Guild.Id, tz); + + await SendConfirmAsync(tz.ToString()); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/UserPunish/UserPunishCommands.cs b/src/Ellie.Bot.Modules.Administration/UserPunish/UserPunishCommands.cs new file mode 100644 index 0000000..b8fd444 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/UserPunish/UserPunishCommands.cs @@ -0,0 +1,932 @@ +#nullable disable +using CommandLine; +using Humanizer.Localisation; +using Ellie.Common.TypeReaders.Models; +using Ellie.Modules.Administration.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class UserPunishCommands : EllieModule + { + public enum AddRole + { + AddRole + } + + private readonly MuteService _mute; + + public UserPunishCommands(MuteService mute) + { + _mute = mute; + } + + private async Task CheckRoleHierarchy(IGuildUser target) + { + var curUser = ((SocketGuild)ctx.Guild).CurrentUser; + var ownerId = ctx.Guild.OwnerId; + var modMaxRole = ((IGuildUser)ctx.User).GetRoles().Max(r => r.Position); + var targetMaxRole = target.GetRoles().Max(r => r.Position); + var botMaxRole = curUser.GetRoles().Max(r => r.Position); + // bot can't punish a user who is higher in the hierarchy. Discord will return 403 + // moderator can be owner, in which case role hierarchy doesn't matter + // otherwise, moderator has to have a higher role + if (botMaxRole <= targetMaxRole + || (ctx.User.Id != ownerId && targetMaxRole >= modMaxRole) + || target.Id == ownerId) + { + await ReplyErrorLocalizedAsync(strs.hierarchy); + return false; + } + + return true; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public Task Warn(IGuildUser user, [Leftover] string reason = null) + => Warn(1, user, reason); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task Warn(int weight, IGuildUser user, [Leftover] string reason = null) + { + if (weight <= 0) + return; + + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + try + { + await user.EmbedAsync(_eb.Create() + .WithErrorColor() + .WithDescription(GetText(strs.warned_on(ctx.Guild.ToString()))) + .AddField(GetText(strs.moderator), ctx.User.ToString()) + .AddField(GetText(strs.reason), reason ?? "-")); + } + catch + { + dmFailed = true; + } + + WarningPunishment punishment; + try + { + punishment = await _service.Warn(ctx.Guild, user.Id, ctx.User, weight, reason); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception occured while warning a user"); + var errorEmbed = _eb.Create().WithErrorColor().WithDescription(GetText(strs.cant_apply_punishment)); + + if (dmFailed) + errorEmbed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await ctx.Channel.EmbedAsync(errorEmbed); + return; + } + + var embed = _eb.Create().WithOkColor(); + if (punishment is null) + embed.WithDescription(GetText(strs.user_warned(Format.Bold(user.ToString())))); + else + { + embed.WithDescription(GetText(strs.user_warned_and_punished(Format.Bold(user.ToString()), + Format.Bold(punishment.Punishment.ToString())))); + } + + if (dmFailed) + embed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await ctx.Channel.EmbedAsync(embed); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [EllieOptions] + [Priority(1)] + public async Task WarnExpire() + { + var expireDays = await _service.GetWarnExpire(ctx.Guild.Id); + + if (expireDays == 0) + await ReplyConfirmLocalizedAsync(strs.warns_dont_expire); + else + await ReplyErrorLocalizedAsync(strs.warns_expire_in(expireDays)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [EllieOptions] + [Priority(2)] + public async Task WarnExpire(int days, params string[] args) + { + if (days is < 0 or > 366) + return; + + var opts = OptionsParser.ParseFrom(args); + + await ctx.Channel.TriggerTypingAsync(); + + await _service.WarnExpireAsync(ctx.Guild.Id, days, opts.Delete); + if (days == 0) + { + await ReplyConfirmLocalizedAsync(strs.warn_expire_reset); + return; + } + + if (opts.Delete) + await ReplyConfirmLocalizedAsync(strs.warn_expire_set_delete(Format.Bold(days.ToString()))); + else + await ReplyConfirmLocalizedAsync(strs.warn_expire_set_clear(Format.Bold(days.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(2)] + public Task Warnlog(int page, [Leftover] IGuildUser user = null) + { + user ??= (IGuildUser)ctx.User; + + return Warnlog(page, user.Id); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(3)] + public Task Warnlog(IGuildUser user = null) + { + user ??= (IGuildUser)ctx.User; + + return ctx.User.Id == user.Id || ((IGuildUser)ctx.User).GuildPermissions.BanMembers + ? Warnlog(user.Id) + : Task.CompletedTask; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(0)] + public Task Warnlog(int page, ulong userId) + => InternalWarnlog(userId, page - 1); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(1)] + public Task Warnlog(ulong userId) + => InternalWarnlog(userId, 0); + + private async Task InternalWarnlog(ulong userId, int inputPage) + { + if (inputPage < 0) + return; + + var allWarnings = _service.UserWarnings(ctx.Guild.Id, userId); + + await ctx.SendPaginatedConfirmAsync(inputPage, + page => + { + var warnings = allWarnings.Skip(page * 9).Take(9).ToArray(); + + var user = (ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString(); + var embed = _eb.Create().WithOkColor().WithTitle(GetText(strs.warnlog_for(user))); + + if (!warnings.Any()) + embed.WithDescription(GetText(strs.warnings_none)); + else + { + var descText = GetText(strs.warn_count( + Format.Bold(warnings.Where(x => !x.Forgiven).Sum(x => x.Weight).ToString()), + Format.Bold(warnings.Sum(x => x.Weight).ToString()))); + + embed.WithDescription(descText); + + var i = page * 9; + foreach (var w in warnings) + { + i++; + var name = GetText(strs.warned_on_by(w.DateAdded?.ToString("dd.MM.yyy"), + w.DateAdded?.ToString("HH:mm"), + w.Moderator)); + + if (w.Forgiven) + name = $"{Format.Strikethrough(name)} {GetText(strs.warn_cleared_by(w.ForgivenBy))}"; + + + embed.AddField($"#`{i}` " + name, + Format.Code(GetText(strs.warn_weight(w.Weight))) + '\n' + w.Reason.TrimTo(1000)); + } + } + + return embed; + }, + allWarnings.Length, + 9); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnlogAll(int page = 1) + { + if (--page < 0) + return; + var warnings = _service.WarnlogAll(ctx.Guild.Id); + + await ctx.SendPaginatedConfirmAsync(page, + curPage => + { + var ws = warnings.Skip(curPage * 15) + .Take(15) + .ToArray() + .Select(x => + { + var all = x.Count(); + var forgiven = x.Count(y => y.Forgiven); + var total = all - forgiven; + var usr = ((SocketGuild)ctx.Guild).GetUser(x.Key); + return (usr?.ToString() ?? x.Key.ToString()) + + $" | {total} ({all} - {forgiven})"; + }); + + return _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.warnings_list)) + .WithDescription(string.Join("\n", ws)); + }, + warnings.Length, + 15); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public Task Warnclear(IGuildUser user, int index = 0) + => Warnclear(user.Id, index); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task Warnclear(ulong userId, int index = 0) + { + if (index < 0) + return; + var success = await _service.WarnClearAsync(ctx.Guild.Id, userId, index, ctx.User.ToString()); + var userStr = Format.Bold((ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString()); + if (index == 0) + await ReplyErrorLocalizedAsync(strs.warnings_cleared(userStr)); + else + { + if (success) + await ReplyConfirmLocalizedAsync(strs.warning_cleared(Format.Bold(index.ToString()), userStr)); + else + await ReplyErrorLocalizedAsync(strs.warning_clear_fail); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(1)] + public async Task WarnPunish( + int number, + AddRole _, + IRole role, + StoopidTime time = null) + { + var punish = PunishmentAction.AddRole; + + if (ctx.Guild.OwnerId != ctx.User.Id + && role.Position >= ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position)) + { + await ReplyErrorLocalizedAsync(strs.role_too_high); + return; + } + + var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time, role); + + if (!success) + return; + + if (time is null) + { + await ReplyConfirmLocalizedAsync(strs.warn_punish_set(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()))); + } + else + { + await ReplyConfirmLocalizedAsync(strs.warn_punish_set_timed(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()), + Format.Bold(time.Input))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnPunish(int number, PunishmentAction punish, StoopidTime time = null) + { + // this should never happen. Addrole has its own method with higher priority + // also disallow warn punishment for getting warned + if (punish is PunishmentAction.AddRole or PunishmentAction.Warn) + return; + + // you must specify the time for timeout + if (punish is PunishmentAction.TimeOut && time is null) + return; + + var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time); + + if (!success) + return; + + if (time is null) + { + await ReplyConfirmLocalizedAsync(strs.warn_punish_set(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()))); + } + else + { + await ReplyConfirmLocalizedAsync(strs.warn_punish_set_timed(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()), + Format.Bold(time.Input))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnPunish(int number) + { + if (!_service.WarnPunishRemove(ctx.Guild.Id, number)) + return; + + await ReplyConfirmLocalizedAsync(strs.warn_punish_rem(Format.Bold(number.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WarnPunishList() + { + var ps = _service.WarnPunishList(ctx.Guild.Id); + + string list; + if (ps.Any()) + { + list = string.Join("\n", + ps.Select(x + => $"{x.Count} -> {x.Punishment} {(x.Punishment == PunishmentAction.AddRole ? $"<@&{x.RoleId}>" : "")} {(x.Time <= 0 ? "" : x.Time + "m")} ")); + } + else + list = GetText(strs.warnpl_none); + + await SendConfirmAsync(GetText(strs.warn_punish_list), list); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(1)] + public Task Ban(StoopidTime time, IUser user, [Leftover] string msg = null) + => Ban(time, user.Id, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(0)] + public async Task Ban(StoopidTime time, ulong userId, [Leftover] string msg = null) + { + if (time.Time > TimeSpan.FromDays(49)) + return; + + var guildUser = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + + + if (guildUser is not null && !await CheckRoleHierarchy(guildUser)) + return; + + var dmFailed = false; + + if (guildUser is not null) + { + try + { + var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg)); + var embed = _service.GetBanUserDmEmbed(Context, guildUser, defaultMessage, msg, time.Time); + if (embed is not null) + await guildUser.SendAsync(embed); + } + catch + { + dmFailed = true; + } + } + + var user = await ctx.Client.GetUserAsync(userId); + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await _mute.TimedBan(ctx.Guild, userId, time.Time, (ctx.User + " | " + msg).TrimTo(512), banPrune); + var toSend = _eb.Create() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField(GetText(strs.username), user?.ToString() ?? userId.ToString(), true) + .AddField("ID", userId.ToString(), true) + .AddField(GetText(strs.duration), + time.Time.Humanize(3, minUnit: TimeUnit.Minute, culture: Culture), + true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await ctx.Channel.EmbedAsync(toSend); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(0)] + public async Task Ban(ulong userId, [Leftover] string msg = null) + { + var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + if (user is null) + { + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await ctx.Guild.AddBanAsync(userId, banPrune, (ctx.User + " | " + msg).TrimTo(512)); + + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField("ID", userId.ToString(), true)); + } + else + await Ban(user, msg); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(2)] + public async Task Ban(IGuildUser user, [Leftover] string msg = null) + { + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg)); + var embed = _service.GetBanUserDmEmbed(Context, user, defaultMessage, msg, null); + if (embed is not null) + await user.SendAsync(embed); + } + catch + { + dmFailed = true; + } + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await ctx.Guild.AddBanAsync(user, banPrune, (ctx.User + " | " + msg).TrimTo(512)); + + var toSend = _eb.Create() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await ctx.Channel.EmbedAsync(toSend); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanPrune(int days) + { + if (days < 0 || days > 7) + { + await ReplyErrorLocalizedAsync(strs.invalid_input); + return; + } + + await _service.SetBanPruneAsync(ctx.Guild.Id, days); + + if (days == 0) + await ReplyConfirmLocalizedAsync(strs.ban_prune_disabled); + else + await ReplyConfirmLocalizedAsync(strs.ban_prune(days)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanMessage([Leftover] string message = null) + { + if (message is null) + { + var template = _service.GetBanTemplate(ctx.Guild.Id); + if (template is null) + { + await ReplyConfirmLocalizedAsync(strs.banmsg_default); + return; + } + + await SendConfirmAsync(template); + return; + } + + _service.SetBanTemplate(ctx.Guild.Id, message); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanMsgReset() + { + _service.SetBanTemplate(ctx.Guild.Id, null); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(0)] + public Task BanMessageTest([Leftover] string reason = null) + => InternalBanMessageTest(reason, null); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(1)] + public Task BanMessageTest(StoopidTime duration, [Leftover] string reason = null) + => InternalBanMessageTest(reason, duration.Time); + + private async Task InternalBanMessageTest(string reason, TimeSpan? duration) + { + var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), reason)); + var embed = _service.GetBanUserDmEmbed(Context, (IGuildUser)ctx.User, defaultMessage, reason, duration); + + if (embed is null) + await ConfirmLocalizedAsync(strs.banmsg_disabled); + else + { + try + { + await ctx.User.SendAsync(embed); + } + catch (Exception) + { + await ReplyErrorLocalizedAsync(strs.unable_to_dm_user); + return; + } + + await ctx.OkAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task Unban([Leftover] string user) + { + var bans = await ctx.Guild.GetBansAsync().FlattenAsync(); + + var bun = bans.FirstOrDefault(x => x.User.ToString()!.ToLowerInvariant() == user.ToLowerInvariant()); + + if (bun is null) + { + await ReplyErrorLocalizedAsync(strs.user_not_found); + return; + } + + await UnbanInternal(bun.User); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task Unban(ulong userId) + { + var bun = await ctx.Guild.GetBanAsync(userId); + + if (bun is null) + { + await ReplyErrorLocalizedAsync(strs.user_not_found); + return; + } + + await UnbanInternal(bun.User); + } + + private async Task UnbanInternal(IUser user) + { + await ctx.Guild.RemoveBanAsync(user); + + await ReplyConfirmLocalizedAsync(strs.unbanned_user(Format.Bold(user.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)] + [BotPerm(GuildPerm.BanMembers)] + public Task Softban(IGuildUser user, [Leftover] string msg = null) + => SoftbanInternal(user, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)] + [BotPerm(GuildPerm.BanMembers)] + public async Task Softban(ulong userId, [Leftover] string msg = null) + { + var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + if (user is null) + return; + + await SoftbanInternal(user, msg); + } + + private async Task SoftbanInternal(IGuildUser user, [Leftover] string msg = null) + { + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + await user.SendErrorAsync(_eb, GetText(strs.sbdm(Format.Bold(ctx.Guild.Name), msg))); + } + catch + { + dmFailed = true; + } + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await ctx.Guild.AddBanAsync(user, banPrune, ("Softban | " + ctx.User + " | " + msg).TrimTo(512)); + try { await ctx.Guild.RemoveBanAsync(user); } + catch { await ctx.Guild.RemoveBanAsync(user); } + + var toSend = _eb.Create() + .WithOkColor() + .WithTitle("☣ " + GetText(strs.sb_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await ctx.Channel.EmbedAsync(toSend); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers)] + [BotPerm(GuildPerm.KickMembers)] + [Priority(1)] + public Task Kick(IGuildUser user, [Leftover] string msg = null) + => KickInternal(user, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers)] + [BotPerm(GuildPerm.KickMembers)] + [Priority(0)] + public async Task Kick(ulong userId, [Leftover] string msg = null) + { + var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + if (user is null) + return; + + await KickInternal(user, msg); + } + + private async Task KickInternal(IGuildUser user, string msg = null) + { + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + await user.SendErrorAsync(_eb, GetText(strs.kickdm(Format.Bold(ctx.Guild.Name), msg))); + } + catch + { + dmFailed = true; + } + + await user.KickAsync((ctx.User + " | " + msg).TrimTo(512)); + + var toSend = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.kicked_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await ctx.Channel.EmbedAsync(toSend); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ModerateMembers)] + [BotPerm(GuildPerm.ModerateMembers)] + [Priority(2)] + public async Task Timeout(IUser globalUser, StoopidTime time, [Leftover] string msg = null) + { + var user = await ctx.Guild.GetUserAsync(globalUser.Id); + + if (user is null) + return; + + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + var dmMessage = GetText(strs.timeoutdm(Format.Bold(ctx.Guild.Name), msg)); + await user.EmbedAsync(_eb.Create(ctx) + .WithPendingColor() + .WithDescription(dmMessage)); + } + catch + { + dmFailed = true; + } + + await user.SetTimeOutAsync(time.Time); + + var toSend = _eb.Create() + .WithOkColor() + .WithTitle("⏳ " + GetText(strs.timedout_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await ctx.Channel.EmbedAsync(toSend); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Ratelimit(30)] + public async Task MassBan(params string[] userStrings) + { + if (userStrings.Length == 0) + return; + + var missing = new List(); + var banning = new HashSet(); + + await ctx.Channel.TriggerTypingAsync(); + foreach (var userStr in userStrings) + { + if (ulong.TryParse(userStr, out var userId)) + { + IUser user = await ctx.Guild.GetUserAsync(userId) + ?? await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, + userId); + + if (user is null) + { + // if IGuildUser is null, try to get IUser + user = await ((DiscordSocketClient)Context.Client).Rest.GetUserAsync(userId); + + // only add to missing if *still* null + if (user is null) + { + missing.Add(userStr); + continue; + } + } + + //Hierachy checks only if the user is in the guild + if (user is IGuildUser gu && !await CheckRoleHierarchy(gu)) + return; + + banning.Add(user); + } + else + missing.Add(userStr); + } + + var missStr = string.Join("\n", missing); + if (string.IsNullOrWhiteSpace(missStr)) + missStr = "-"; + + var toSend = _eb.Create(ctx) + .WithDescription(GetText(strs.mass_ban_in_progress(banning.Count))) + .AddField(GetText(strs.invalid(missing.Count)), missStr) + .WithPendingColor(); + + var banningMessage = await ctx.Channel.EmbedAsync(toSend); + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + foreach (var toBan in banning) + { + try + { + await ctx.Guild.AddBanAsync(toBan.Id, banPrune, $"{ctx.User} | Massban"); + } + catch (Exception ex) + { + Log.Warning(ex, "Error banning {User} user in {GuildId} server", toBan.Id, ctx.Guild.Id); + } + } + + await banningMessage.ModifyAsync(x => x.Embed = _eb.Create() + .WithDescription( + GetText(strs.mass_ban_completed(banning.Count()))) + .AddField(GetText(strs.invalid(missing.Count)), missStr) + .WithOkColor() + .Build()); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [OwnerOnly] + public async Task MassKill([Leftover] string people) + { + if (string.IsNullOrWhiteSpace(people)) + return; + + var (bans, missing) = _service.MassKill((SocketGuild)ctx.Guild, people); + + var missStr = string.Join("\n", missing); + if (string.IsNullOrWhiteSpace(missStr)) + missStr = "-"; + + //send a message but don't wait for it + var banningMessageTask = ctx.Channel.EmbedAsync(_eb.Create() + .WithDescription( + GetText(strs.mass_kill_in_progress(bans.Count()))) + .AddField(GetText(strs.invalid(missing)), missStr) + .WithPendingColor()); + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + //do the banning + await Task.WhenAll(bans.Where(x => x.Id.HasValue) + .Select(x => ctx.Guild.AddBanAsync(x.Id.Value, + banPrune, + x.Reason, + new() + { + RetryMode = RetryMode.AlwaysRetry + }))); + + //wait for the message and edit it + var banningMessage = await banningMessageTask; + + await banningMessage.ModifyAsync(x => x.Embed = _eb.Create() + .WithDescription( + GetText(strs.mass_kill_completed(bans.Count()))) + .AddField(GetText(strs.invalid(missing)), missStr) + .WithOkColor() + .Build()); + } + + public class WarnExpireOptions : IEllieCommandOptions + { + [Option('d', "delete", Default = false, HelpText = "Delete warnings instead of clearing them.")] + public bool Delete { get; set; } = false; + + public void NormalizeOptions() + { + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/UserPunish/UserPunishService.cs b/src/Ellie.Bot.Modules.Administration/UserPunish/UserPunishService.cs new file mode 100644 index 0000000..aecd109 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/UserPunish/UserPunishService.cs @@ -0,0 +1,595 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Common.TypeReaders.Models; +using Ellie.Db; +using Ellie.Modules.Permissions.Services; +using Ellie.Services.Database.Models; +using Newtonsoft.Json; + +namespace Ellie.Modules.Administration.Services; + +public class UserPunishService : IEService, IReadyExecutor +{ + private readonly MuteService _mute; + private readonly DbService _db; + private readonly BlacklistService _blacklistService; + private readonly BotConfigService _bcs; + private readonly DiscordSocketClient _client; + + public event Func OnUserWarned = static delegate { return Task.CompletedTask; }; + + public UserPunishService( + MuteService mute, + DbService db, + BlacklistService blacklistService, + BotConfigService bcs, + DiscordSocketClient client) + { + _mute = mute; + _db = db; + _blacklistService = blacklistService; + _bcs = bcs; + _client = client; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var expiryTimer = new PeriodicTimer(TimeSpan.FromHours(12)); + do + { + try + { + await CheckAllWarnExpiresAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "Unexpected error while checking for warn expiries: {ErrorMessage}", ex.Message); + } + } while (await expiryTimer.WaitForNextTickAsync()); + } + + public async Task Warn( + IGuild guild, + ulong userId, + IUser mod, + long weight, + string reason) + { + if (weight <= 0) + throw new ArgumentOutOfRangeException(nameof(weight)); + + var modName = mod.ToString(); + + if (string.IsNullOrWhiteSpace(reason)) + reason = "-"; + + var guildId = guild.Id; + + var warn = new Warning + { + UserId = userId, + GuildId = guildId, + Forgiven = false, + Reason = reason, + Moderator = modName, + Weight = weight + }; + + long previousCount; + List ps; + await using (var uow = _db.GetDbContext()) + { + ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments; + + previousCount = uow.Set().ForId(guildId, userId) + .Where(w => !w.Forgiven && w.UserId == userId) + .Sum(x => x.Weight); + + uow.Set().Add(warn); + + await uow.SaveChangesAsync(); + } + + _ = OnUserWarned(warn); + + var totalCount = previousCount + weight; + + var p = ps.Where(x => x.Count > previousCount && x.Count <= totalCount) + .MaxBy(x => x.Count); + + if (p is not null) + { + var user = await guild.GetUserAsync(userId); + if (user is null) + return null; + + await ApplyPunishment(guild, user, mod, p.Punishment, p.Time, p.RoleId, "Warned too many times."); + return p; + } + + return null; + } + + public async Task ApplyPunishment( + IGuild guild, + IGuildUser user, + IUser mod, + PunishmentAction p, + int minutes, + ulong? roleId, + string reason) + { + if (!await CheckPermission(guild, p)) + return; + + int banPrune; + switch (p) + { + case PunishmentAction.Mute: + if (minutes == 0) + await _mute.MuteUser(user, mod, reason: reason); + else + await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), reason: reason); + break; + case PunishmentAction.VoiceMute: + if (minutes == 0) + await _mute.MuteUser(user, mod, MuteType.Voice, reason); + else + await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Voice, reason); + break; + case PunishmentAction.ChatMute: + if (minutes == 0) + await _mute.MuteUser(user, mod, MuteType.Chat, reason); + else + await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Chat, reason); + break; + case PunishmentAction.Kick: + await user.KickAsync(reason); + break; + case PunishmentAction.Ban: + banPrune = await GetBanPruneAsync(user.GuildId) ?? 7; + if (minutes == 0) + await guild.AddBanAsync(user, reason: reason, pruneDays: banPrune); + else + await _mute.TimedBan(user.Guild, user.Id, TimeSpan.FromMinutes(minutes), reason, banPrune); + break; + case PunishmentAction.Softban: + banPrune = await GetBanPruneAsync(user.GuildId) ?? 7; + await guild.AddBanAsync(user, banPrune, $"Softban | {reason}"); + try + { + await guild.RemoveBanAsync(user); + } + catch + { + await guild.RemoveBanAsync(user); + } + + break; + case PunishmentAction.RemoveRoles: + await user.RemoveRolesAsync(user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole)); + break; + case PunishmentAction.AddRole: + if (roleId is null) + return; + var role = guild.GetRole(roleId.Value); + if (role is not null) + { + if (minutes == 0) + await user.AddRoleAsync(role); + else + await _mute.TimedRole(user, TimeSpan.FromMinutes(minutes), reason, role); + } + else + { + Log.Warning("Can't find role {RoleId} on server {GuildId} to apply punishment", + roleId.Value, + guild.Id); + } + + break; + case PunishmentAction.Warn: + await Warn(guild, user.Id, mod, 1, reason); + break; + case PunishmentAction.TimeOut: + await user.SetTimeOutAsync(TimeSpan.FromMinutes(minutes)); + break; + } + } + + /// + /// Used to prevent the bot from hitting 403's when it needs to + /// apply punishments with insufficient permissions + /// + /// Guild the punishment is applied in + /// Punishment to apply + /// Whether the bot has sufficient permissions + private async Task CheckPermission(IGuild guild, PunishmentAction punish) + { + var botUser = await guild.GetCurrentUserAsync(); + switch (punish) + { + case PunishmentAction.Mute: + return botUser.GuildPermissions.MuteMembers && botUser.GuildPermissions.ManageRoles; + case PunishmentAction.Kick: + return botUser.GuildPermissions.KickMembers; + case PunishmentAction.Ban: + return botUser.GuildPermissions.BanMembers; + case PunishmentAction.Softban: + return botUser.GuildPermissions.BanMembers; // ban + unban + case PunishmentAction.RemoveRoles: + return botUser.GuildPermissions.ManageRoles; + case PunishmentAction.ChatMute: + return botUser.GuildPermissions.ManageRoles; // adds nadeko-mute role + case PunishmentAction.VoiceMute: + return botUser.GuildPermissions.MuteMembers; + case PunishmentAction.AddRole: + return botUser.GuildPermissions.ManageRoles; + case PunishmentAction.TimeOut: + return botUser.GuildPermissions.ModerateMembers; + default: + return true; + } + } + + public async Task CheckAllWarnExpiresAsync() + { + await using var uow = _db.GetDbContext(); + var cleared = await uow.Set() + .Where(x => uow.Set() + .Any(y => y.GuildId == x.GuildId + && y.WarnExpireHours > 0 + && y.WarnExpireAction == WarnExpireAction.Clear) + && x.Forgiven == false + && x.DateAdded + < DateTime.UtcNow.AddHours(-uow.Set() + .Where(y => x.GuildId == y.GuildId) + .Select(y => y.WarnExpireHours) + .First())) + .UpdateAsync(_ => new() + { + Forgiven = true, + ForgivenBy = "expiry" + }); + + var deleted = await uow.Set() + .Where(x => uow.Set() + .Any(y => y.GuildId == x.GuildId + && y.WarnExpireHours > 0 + && y.WarnExpireAction == WarnExpireAction.Delete) + && x.DateAdded + < DateTime.UtcNow.AddHours(-uow.Set() + .Where(y => x.GuildId == y.GuildId) + .Select(y => y.WarnExpireHours) + .First())) + .DeleteAsync(); + + if (cleared > 0 || deleted > 0) + { + Log.Information("Cleared {ClearedWarnings} warnings and deleted {DeletedWarnings} warnings due to expiry", + cleared, + deleted); + } + + await uow.SaveChangesAsync(); + } + + public async Task CheckWarnExpiresAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, inc => inc); + + if (config.WarnExpireHours == 0) + return; + + if (config.WarnExpireAction == WarnExpireAction.Clear) + { + await uow.Set() + .Where(x => x.GuildId == guildId + && x.Forgiven == false + && x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours)) + .UpdateAsync(_ => new() + { + Forgiven = true, + ForgivenBy = "expiry" + }); + } + else if (config.WarnExpireAction == WarnExpireAction.Delete) + { + await uow.Set() + .Where(x => x.GuildId == guildId + && x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours)) + .DeleteAsync(); + } + + await uow.SaveChangesAsync(); + } + + public Task GetWarnExpire(ulong guildId) + { + using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + return Task.FromResult(config.WarnExpireHours / 24); + } + + public async Task WarnExpireAsync(ulong guildId, int days, bool delete) + { + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guildId, inc => inc); + + config.WarnExpireHours = days * 24; + config.WarnExpireAction = delete ? WarnExpireAction.Delete : WarnExpireAction.Clear; + await uow.SaveChangesAsync(); + + // no need to check for warn expires + if (config.WarnExpireHours == 0) + return; + } + + await CheckWarnExpiresAsync(guildId); + } + + public IGrouping[] WarnlogAll(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetForGuild(gid).GroupBy(x => x.UserId).ToArray(); + } + + public Warning[] UserWarnings(ulong gid, ulong userId) + { + using var uow = _db.GetDbContext(); + return uow.Set().ForId(gid, userId); + } + + public async Task WarnClearAsync( + ulong guildId, + ulong userId, + int index, + string moderator) + { + var toReturn = true; + await using var uow = _db.GetDbContext(); + if (index == 0) + await uow.Set().ForgiveAll(guildId, userId, moderator); + else + toReturn = uow.Set().Forgive(guildId, userId, moderator, index - 1); + await uow.SaveChangesAsync(); + return toReturn; + } + + public bool WarnPunish( + ulong guildId, + int number, + PunishmentAction punish, + StoopidTime time, + IRole role = null) + { + // these 3 don't make sense with time + if (punish is PunishmentAction.Softban or PunishmentAction.Kick or PunishmentAction.RemoveRoles + && time is not null) + return false; + if (number <= 0 || (time is not null && time.Time > TimeSpan.FromDays(49))) + return false; + + if (punish is PunishmentAction.AddRole && role is null) + return false; + + if (punish is PunishmentAction.TimeOut && time is null) + return false; + + using var uow = _db.GetDbContext(); + var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments; + var toDelete = ps.Where(x => x.Count == number); + + uow.RemoveRange(toDelete); + + ps.Add(new() + { + Count = number, + Punishment = punish, + Time = (int?)time?.Time.TotalMinutes ?? 0, + RoleId = punish == PunishmentAction.AddRole ? role!.Id : default(ulong?) + }); + uow.SaveChanges(); + return true; + } + + public bool WarnPunishRemove(ulong guildId, int number) + { + if (number <= 0) + return false; + + using var uow = _db.GetDbContext(); + var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments; + var p = ps.FirstOrDefault(x => x.Count == number); + + if (p is not null) + { + uow.Remove(p); + uow.SaveChanges(); + } + + return true; + } + + public WarningPunishment[] WarnPunishList(ulong guildId) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(guildId, gc => gc.Include(x => x.WarnPunishments)) + .WarnPunishments.OrderBy(x => x.Count) + .ToArray(); + } + + public (IReadOnlyCollection<(string Original, ulong? Id, string Reason)> Bans, int Missing) MassKill( + SocketGuild guild, + string people) + { + var gusers = guild.Users; + //get user objects and reasons + var bans = people.Split("\n") + .Select(x => + { + var split = x.Trim().Split(" "); + + var reason = string.Join(" ", split.Skip(1)); + + if (ulong.TryParse(split[0], out var id)) + return (Original: split[0], Id: id, Reason: reason); + + return (Original: split[0], + gusers.FirstOrDefault(u => u.ToString().ToLowerInvariant() == x)?.Id, + Reason: reason); + }) + .ToArray(); + + //if user is null, means that person couldn't be found + var missing = bans.Count(x => !x.Id.HasValue); + + //get only data for found users + var found = bans.Where(x => x.Id.HasValue).Select(x => x.Id.Value).ToList(); + + _blacklistService.BlacklistUsers(found); + + return (bans, missing); + } + + public string GetBanTemplate(ulong guildId) + { + using var uow = _db.GetDbContext(); + var template = uow.Set().AsQueryable().FirstOrDefault(x => x.GuildId == guildId); + return template?.Text; + } + + public void SetBanTemplate(ulong guildId, string text) + { + using var uow = _db.GetDbContext(); + var template = uow.Set().AsQueryable().FirstOrDefault(x => x.GuildId == guildId); + + if (text is null) + { + if (template is null) + return; + + uow.Remove(template); + } + else if (template is null) + { + uow.Set().Add(new() + { + GuildId = guildId, + Text = text + }); + } + else + template.Text = text; + + uow.SaveChanges(); + } + + public async Task SetBanPruneAsync(ulong guildId, int? pruneDays) + { + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guildId, + Text = null, + DateAdded = DateTime.UtcNow, + PruneDays = pruneDays + }, + old => new() + { + PruneDays = pruneDays + }, + () => new() + { + GuildId = guildId + }); + } + + public async Task GetBanPruneAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.Set() + .Where(x => x.GuildId == guildId) + .Select(x => x.PruneDays) + .FirstOrDefaultAsyncLinqToDB(); + } + + public SmartText GetBanUserDmEmbed( + ICommandContext context, + IGuildUser target, + string defaultMessage, + string banReason, + TimeSpan? duration) + => GetBanUserDmEmbed((DiscordSocketClient)context.Client, + (SocketGuild)context.Guild, + (IGuildUser)context.User, + target, + defaultMessage, + banReason, + duration); + + public SmartText GetBanUserDmEmbed( + DiscordSocketClient client, + SocketGuild guild, + IGuildUser moderator, + IGuildUser target, + string defaultMessage, + string banReason, + TimeSpan? duration) + { + var template = GetBanTemplate(guild.Id); + + banReason = string.IsNullOrWhiteSpace(banReason) ? "-" : banReason; + + var replacer = new ReplacementBuilder().WithServer(client, guild) + .WithOverride("%ban.mod%", () => moderator.ToString()) + .WithOverride("%ban.mod.fullname%", () => moderator.ToString()) + .WithOverride("%ban.mod.name%", () => moderator.Username) + .WithOverride("%ban.mod.discrim%", () => moderator.Discriminator) + .WithOverride("%ban.user%", () => target.ToString()) + .WithOverride("%ban.user.fullname%", () => target.ToString()) + .WithOverride("%ban.user.name%", () => target.Username) + .WithOverride("%ban.user.discrim%", () => target.Discriminator) + .WithOverride("%reason%", () => banReason) + .WithOverride("%ban.reason%", () => banReason) + .WithOverride("%ban.duration%", + () => duration?.ToString(@"d\.hh\:mm") ?? "perma") + .Build(); + + // if template isn't set, use the old message style + if (string.IsNullOrWhiteSpace(template)) + { + template = JsonConvert.SerializeObject(new + { + color = _bcs.Data.Color.Error.PackedValue >> 8, + description = defaultMessage + }); + } + // if template is set to "-" do not dm the user + else if (template == "-") + return default; + // if template is an embed, send that embed with replacements + // otherwise, treat template as a regular string with replacements + else if (SmartText.CreateFrom(template) is not { IsEmbed: true } or { IsEmbedArray: true }) + { + template = JsonConvert.SerializeObject(new + { + color = _bcs.Data.Color.Error.PackedValue >> 8, + description = template + }); + } + + var output = SmartText.CreateFrom(template); + return replacer.Replace(output); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/VcRole/VcRoleCommands.cs b/src/Ellie.Bot.Modules.Administration/VcRole/VcRoleCommands.cs new file mode 100644 index 0000000..9e534ad --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/VcRole/VcRoleCommands.cs @@ -0,0 +1,77 @@ +#nullable disable +using Ellie.Modules.Administration.Services; + +namespace Ellie.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class VcRoleCommands : EllieModule + { + [Cmd] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task VcRoleRm(ulong vcId) + { + if (_service.RemoveVcRole(ctx.Guild.Id, vcId)) + await ReplyConfirmLocalizedAsync(strs.vcrole_removed(Format.Bold(vcId.ToString()))); + else + await ReplyErrorLocalizedAsync(strs.vcrole_not_found); + } + + [Cmd] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task VcRole([Leftover] IRole role = null) + { + var user = (IGuildUser)ctx.User; + + var vc = user.VoiceChannel; + + if (vc is null || vc.GuildId != user.GuildId) + { + await ReplyErrorLocalizedAsync(strs.must_be_in_voice); + return; + } + + if (role is null) + { + if (_service.RemoveVcRole(ctx.Guild.Id, vc.Id)) + await ReplyConfirmLocalizedAsync(strs.vcrole_removed(Format.Bold(vc.Name))); + } + else + { + _service.AddVcRole(ctx.Guild.Id, role, vc.Id); + await ReplyConfirmLocalizedAsync(strs.vcrole_added(Format.Bold(vc.Name), Format.Bold(role.Name))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task VcRoleList() + { + var guild = (SocketGuild)ctx.Guild; + string text; + if (_service.VcRoles.TryGetValue(ctx.Guild.Id, out var roles)) + { + if (!roles.Any()) + text = GetText(strs.no_vcroles); + else + { + text = string.Join("\n", + roles.Select(x + => $"{Format.Bold(guild.GetVoiceChannel(x.Key)?.Name ?? x.Key.ToString())} => {x.Value}")); + } + } + else + text = GetText(strs.no_vcroles); + + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.vc_role_list)) + .WithDescription(text)); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Administration/VcRole/VcRoleService.cs b/src/Ellie.Bot.Modules.Administration/VcRole/VcRoleService.cs new file mode 100644 index 0000000..b9ba4d0 --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/VcRole/VcRoleService.cs @@ -0,0 +1,209 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Administration.Services; + +public class VcRoleService : IEService +{ + public ConcurrentDictionary> VcRoles { get; } + public ConcurrentDictionary> ToAssign { get; } + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public VcRoleService(DiscordSocketClient client, IBot bot, DbService db) + { + _db = db; + _client = client; + + _client.UserVoiceStateUpdated += ClientOnUserVoiceStateUpdated; + VcRoles = new(); + ToAssign = new(); + + using (var uow = db.GetDbContext()) + { + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + uow.Set() + .AsQueryable() + .Include(x => x.VcRoleInfos) + .Where(x => guildIds.Contains(x.GuildId)) + .AsEnumerable() + .Select(InitializeVcRole) + .WhenAll(); + } + + Task.Run(async () => + { + while (true) + { + Task Selector(System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)> queue) + { + return Task.Run(async () => + { + while (queue.TryDequeue(out var item)) + { + var (add, user, role) = item; + + try + { + if (add) + { + if (!user.RoleIds.Contains(role.Id)) + await user.AddRoleAsync(role); + } + else + { + if (user.RoleIds.Contains(role.Id)) + await user.RemoveRoleAsync(role); + } + } + catch + { + } + + await Task.Delay(250); + } + }); + } + + await ToAssign.Values.Select(Selector).Append(Task.Delay(1000)).WhenAll(); + } + }); + + _client.LeftGuild += _client_LeftGuild; + bot.JoinedGuild += Bot_JoinedGuild; + } + + private Task Bot_JoinedGuild(GuildConfig arg) + { + // includeall no longer loads vcrole + // need to load new guildconfig with vc role included + using (var uow = _db.GetDbContext()) + { + var configWithVcRole = uow.GuildConfigsForId(arg.GuildId, set => set.Include(x => x.VcRoleInfos)); + _ = InitializeVcRole(configWithVcRole); + } + + return Task.CompletedTask; + } + + private Task _client_LeftGuild(SocketGuild arg) + { + VcRoles.TryRemove(arg.Id, out _); + ToAssign.TryRemove(arg.Id, out _); + return Task.CompletedTask; + } + + private async Task InitializeVcRole(GuildConfig gconf) + { + var g = _client.GetGuild(gconf.GuildId); + if (g is null) + return; + + var infos = new ConcurrentDictionary(); + var missingRoles = new List(); + VcRoles.AddOrUpdate(gconf.GuildId, infos, delegate { return infos; }); + foreach (var ri in gconf.VcRoleInfos) + { + var role = g.GetRole(ri.RoleId); + if (role is null) + { + missingRoles.Add(ri); + continue; + } + + infos.TryAdd(ri.VoiceChannelId, role); + } + + if (missingRoles.Any()) + { + await using var uow = _db.GetDbContext(); + uow.RemoveRange(missingRoles); + await uow.SaveChangesAsync(); + + Log.Warning("Removed {MissingRoleCount} missing roles from {ServiceName}", + missingRoles.Count, + nameof(VcRoleService)); + } + } + + public void AddVcRole(ulong guildId, IRole role, ulong vcId) + { + if (role is null) + throw new ArgumentNullException(nameof(role)); + + var guildVcRoles = VcRoles.GetOrAdd(guildId, new ConcurrentDictionary()); + + guildVcRoles.AddOrUpdate(vcId, role, (_, _) => role); + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos)); + var toDelete = conf.VcRoleInfos.FirstOrDefault(x => x.VoiceChannelId == vcId); // remove old one + if (toDelete is not null) + uow.Remove(toDelete); + conf.VcRoleInfos.Add(new() + { + VoiceChannelId = vcId, + RoleId = role.Id + }); // add new one + uow.SaveChanges(); + } + + public bool RemoveVcRole(ulong guildId, ulong vcId) + { + if (!VcRoles.TryGetValue(guildId, out var guildVcRoles)) + return false; + + if (!guildVcRoles.TryRemove(vcId, out _)) + return false; + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos)); + var toRemove = conf.VcRoleInfos.Where(x => x.VoiceChannelId == vcId).ToList(); + uow.RemoveRange(toRemove); + uow.SaveChanges(); + + return true; + } + + private Task ClientOnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState) + { + if (usr is not SocketGuildUser gusr) + return Task.CompletedTask; + + var oldVc = oldState.VoiceChannel; + var newVc = newState.VoiceChannel; + _ = Task.Run(() => + { + try + { + if (oldVc != newVc) + { + ulong guildId; + guildId = newVc?.Guild.Id ?? oldVc.Guild.Id; + + if (VcRoles.TryGetValue(guildId, out var guildVcRoles)) + { + //remove old + if (oldVc is not null && guildVcRoles.TryGetValue(oldVc.Id, out var role)) + Assign(false, gusr, role); + //add new + if (newVc is not null && guildVcRoles.TryGetValue(newVc.Id, out role)) + Assign(true, gusr, role); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error in VcRoleService VoiceStateUpdate"); + } + }); + return Task.CompletedTask; + } + + private void Assign(bool v, SocketGuildUser gusr, IRole role) + { + var queue = ToAssign.GetOrAdd(gusr.Guild.Id, new System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)>()); + queue.Enqueue((v, gusr, role)); + } +} \ No newline at end of file