diff --git a/src/EllieBot/Modules/Administration/Administration.cs b/src/EllieBot/Modules/Administration/Administration.cs new file mode 100644 index 0000000..9e18e8b --- /dev/null +++ b/src/EllieBot/Modules/Administration/Administration.cs @@ -0,0 +1,499 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration._common.results; +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.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 Response().Confirm(strs.imageonly_enable).SendAsync(); + else + await Response().Pending(strs.imageonly_disable).SendAsync(); + } + + [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 Response().Confirm(strs.linkonly_enable).SendAsync(); + else + await Response().Pending(strs.linkonly_disable).SendAsync(); + } + + [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 = _sender.CreateEmbed() + .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 Response().Embed(embed).SendAsync(); + } + + [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 Response().Confirm(strs.delmsg_on).SendAsync(); + } + else + { + _service.DeleteMessagesOnCommand.TryRemove(ctx.Guild.Id); + await Response().Confirm(strs.delmsg_off).SendAsync(); + } + } + + [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 Response().Confirm(strs.delmsg_channel_off).SendAsync(); + else if (s == State.Enable) + await Response().Confirm(strs.delmsg_channel_on).SendAsync(); + else + await Response().Confirm(strs.delmsg_channel_inherit).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.DeafenMembers)] + [BotPerm(GuildPerm.DeafenMembers)] + public async Task Deafen(params IGuildUser[] users) + { + await _service.DeafenUsers(true, users); + await Response().Confirm(strs.deafen).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.DeafenMembers)] + [BotPerm(GuildPerm.DeafenMembers)] + public async Task UnDeafen(params IGuildUser[] users) + { + await _service.DeafenUsers(false, users); + await Response().Confirm(strs.undeafen).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task DelVoiChanl([Leftover] IVoiceChannel voiceChannel) + { + await voiceChannel.DeleteAsync(); + await Response().Confirm(strs.delvoich(Format.Bold(voiceChannel.Name))).SendAsync(); + } + + [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 Response().Confirm(strs.createvoich(Format.Bold(ch.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task DelTxtChanl([Leftover] ITextChannel toDelete) + { + await toDelete.DeleteAsync(new RequestOptions() + { + AuditLogReason = $"Deleted by {ctx.User.Username}" + }); + await Response().Confirm(strs.deltextchan(Format.Bold(toDelete.Name))).SendAsync(); + } + + [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 Response().Confirm(strs.createtextchan(Format.Bold(txtCh.Name))).SendAsync(); + } + + [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 Response().Confirm(strs.set_topic).SendAsync(); + } + + [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 Response().Confirm(strs.set_channel_name).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task AgeRestrictToggle() + { + var channel = (ITextChannel)ctx.Channel; + var isEnabled = channel.IsNsfw; + + await channel.ModifyAsync(c => c.IsNsfw = !isEnabled); + + if (isEnabled) + await Response().Confirm(strs.nsfw_set_false).SendAsync(); + else + await Response().Confirm(strs.nsfw_set_true).SendAsync(); + } + + [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 Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + if (!botPerms.Has(ChannelPermission.ViewChannel)) + { + await Response().Error(strs.insuf_perms_i).SendAsync(); + 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 Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + if (!botPerms.Has(ChannelPermission.ManageMessages)) + { + await Response().Error(strs.insuf_perms_i).SendAsync(); + return; + } + + + var msg = await channel.GetMessageAsync(messageId); + if (msg is null) + { + await Response().Error(strs.msg_not_found).SendAsync(); + 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 Response().Error(strs.time_too_long).SendAsync(); + 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 Response().Error(strs.not_found).SendAsync(); + return; + } + + await t.DeleteAsync(); + await ctx.OkAsync(); + } + + [Cmd] + [UserPerm(ChannelPerm.ManageMessages)] + public async Task AutoPublish() + { + if (ctx.Channel.GetChannelType() != ChannelType.News) + { + await Response().Error(strs.req_announcement_channel).SendAsync(); + return; + } + + var newState = await _autoPubService.ToggleAutoPublish(ctx.Guild.Id, ctx.Channel.Id); + + if (newState) + { + await Response().Confirm(strs.autopublish_enable).SendAsync(); + } + else + { + await Response().Confirm(strs.autopublish_disable).SendAsync(); + } + } + + [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 Response().Confirm(strs.bot_nick(Format.Bold(newNick) ?? "-")).SendAsync(); + } + + [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 Response().Error(strs.insuf_perms_i).SendAsync(); + return; + } + + await gu.ModifyAsync(u => u.Nickname = newNick); + + await Response() + .Confirm(strs.user_nick(Format.Bold(gu.ToString()), Format.Bold(newNick) ?? "-")) + .SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPermission.ManageGuild)] + [BotPerm(GuildPermission.ManageGuild)] + public async Task SetServerBanner([Leftover] string img = null) + { + // Tier2 or higher is required to set a banner. + if (ctx.Guild.PremiumTier is PremiumTier.Tier1 or PremiumTier.None) return; + + var result = await _service.SetServerBannerAsync(ctx.Guild, img); + + switch (result) + { + case SetServerBannerResult.Success: + await Response().Confirm(strs.set_srvr_banner).SendAsync(); + break; + case SetServerBannerResult.InvalidFileType: + await Response().Error(strs.srvr_banner_invalid).SendAsync(); + break; + case SetServerBannerResult.Toolarge: + await Response().Error(strs.srvr_banner_too_large).SendAsync(); + break; + case SetServerBannerResult.InvalidURL: + await Response().Error(strs.srvr_banner_invalid_url).SendAsync(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPermission.ManageGuild)] + [BotPerm(GuildPermission.ManageGuild)] + public async Task SetServerIcon([Leftover] string img = null) + { + var result = await _service.SetServerIconAsync(ctx.Guild, img); + + switch (result) + { + case SetServerIconResult.Success: + await Response().Confirm(strs.set_srvr_icon).SendAsync(); + break; + case SetServerIconResult.InvalidFileType: + await Response().Error(strs.srvr_banner_invalid).SendAsync(); + break; + case SetServerIconResult.InvalidURL: + await Response().Error(strs.srvr_banner_invalid_url).SendAsync(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AdministrationService.cs b/src/EllieBot/Modules/Administration/AdministrationService.cs new file mode 100644 index 0000000..b1bec59 --- /dev/null +++ b/src/EllieBot/Modules/Administration/AdministrationService.cs @@ -0,0 +1,205 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; +using EllieBot.Modules.Administration._common.results; + +namespace EllieBot.Modules.Administration.Services; + +public class AdministrationService : IEService +{ + public ConcurrentHashSet DeleteMessagesOnCommand { get; } + public ConcurrentDictionary DeleteMessagesOnCommandChannels { get; } + + private readonly DbService _db; + private readonly IReplacementService _repSvc; + private readonly ILogCommandService _logService; + private readonly IHttpClientFactory _httpFactory; + + public AdministrationService( + IBot bot, + CommandHandler cmdHandler, + DbService db, + IReplacementService repSvc, + ILogCommandService logService, + IHttpClientFactory factory) + { + _db = db; + _repSvc = repSvc; + _logService = logService; + _httpFactory = factory; + + 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 repCtx = new ReplacementContext(context); + + var text = SmartText.CreateFrom(input); + text = await _repSvc.ReplaceAsync(text, repCtx); + + await umsg.EditAsync(text); + } + + public async Task SetServerBannerAsync(IGuild guild, string img) + { + if (!IsValidUri(img)) return SetServerBannerResult.InvalidURL; + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + + if (!sr.IsImage()) return SetServerBannerResult.InvalidFileType; + + if (sr.GetContentLength() > 8.Megabytes()) + { + return SetServerBannerResult.Toolarge; + } + + await using var imageStream = await sr.Content.ReadAsStreamAsync(); + + await guild.ModifyAsync(x => x.Banner = new Image(imageStream)); + return SetServerBannerResult.Success; + } + + public async Task SetServerIconAsync(IGuild guild, string img) + { + if (!IsValidUri(img)) return SetServerIconResult.InvalidURL; + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + + if (!sr.IsImage()) return SetServerIconResult.InvalidFileType; + + await using var imageStream = await sr.Content.ReadAsStreamAsync(); + + await guild.ModifyAsync(x => x.Icon = new Image(imageStream)); + return SetServerIconResult.Success; + } + + private bool IsValidUri(string img) => !string.IsNullOrWhiteSpace(img) && Uri.IsWellFormedUriString(img, UriKind.Absolute); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs new file mode 100644 index 0000000..dc687cc --- /dev/null +++ b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs @@ -0,0 +1,60 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.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 Response().Error(strs.hierarchy).SendAsync(); + return; + } + + var roles = await _service.ToggleAarAsync(ctx.Guild.Id, role.Id); + if (roles.Count == 0) + await Response().Confirm(strs.aar_disabled).SendAsync(); + else if (roles.Contains(role.Id)) + await AutoAssignRole(); + else + await Response().Confirm(strs.aar_role_removed(Format.Bold(role.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task AutoAssignRole() + { + if (!_service.TryGetRoles(ctx.Guild.Id, out var roles)) + { + await Response().Confirm(strs.aar_none).SendAsync(); + 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 Response() + .Confirm(strs.aar_roles( + '\n' + existing.Select(x => Format.Bold(x.ToString())).Join(",\n"))) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs new file mode 100644 index 0000000..5e232f8 --- /dev/null +++ b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs @@ -0,0 +1,158 @@ +#nullable disable +using EllieBot.Db.Models; +using System.Net; +using System.Threading.Channels; +using LinqToDB; +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.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/EllieBot/Modules/Administration/AutoPublishService.cs b/src/EllieBot/Modules/Administration/AutoPublishService.cs new file mode 100644 index 0000000..3dab522 --- /dev/null +++ b/src/EllieBot/Modules/Administration/AutoPublishService.cs @@ -0,0 +1,89 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.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 + }); + } + + // todo GUILDS + + 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/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs new file mode 100644 index 0000000..742e869 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs @@ -0,0 +1,74 @@ +using EllieBot.Modules.Administration.DangerousCommands; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class CleanupCommands : CleanupModuleBase + { + private readonly ICleanupService _svc; + private readonly IBotCredsProvider _creds; + + public CleanupCommands(ICleanupService svc, IBotCredsProvider creds) + { + _svc = svc; + _creds = creds; + } + + [Cmd] + [OwnerOnly] + [RequireContext(ContextType.DM)] + public async Task CleanupGuildData() + { + var result = await _svc.DeleteMissingGuildDataAsync(); + + if (result is null) + { + await ctx.ErrorAsync(); + return; + } + + await Response() + .Confirm($"{result.GuildCount} guilds' data remain in the database.") + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task Keep() + { + var result = await _svc.KeepGuild(Context.Guild.Id); + + await Response().Text("This guild's bot data will be saved.").SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task LeaveUnkeptServers(int startShardId, int shardMultiplier = 3000) + { + var keptGuildCount = await _svc.GetKeptGuildCount(); + + var response = await PromptUserConfirmAsync(new EmbedBuilder() + .WithDescription($""" + Do you want the bot to leave all unkept servers? + + There are currently {keptGuildCount} kept servers. + + **This is a highly destructive and irreversible action.** + """)); + + if (!response) + return; + + for (var shardId = startShardId; shardId < _creds.GetCreds().TotalShards; shardId++) + { + await _svc.StartLeavingUnkeptServers(shardId); + await Task.Delay(shardMultiplier * 1000); + } + + await ctx.OkAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs new file mode 100644 index 0000000..39c1e60 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs @@ -0,0 +1,271 @@ +using LinqToDB; +using LinqToDB.Data; +using LinqToDB.EntityFrameworkCore; +using LinqToDB.Mapping; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class CleanupService : ICleanupService, IReadyExecutor, IEService +{ + private TypedKey _cleanupReportKey = new("cleanup:report"); + private TypedKey _cleanupTriggerKey = new("cleanup:trigger"); + + private TypedKey _keepTriggerKey = new("keep:trigger"); + + private readonly IPubSub _pubSub; + private readonly DiscordSocketClient _client; + private ConcurrentDictionary guildIds = new(); + private readonly IBotCredsProvider _creds; + private readonly DbService _db; + + public CleanupService( + IPubSub pubSub, + DiscordSocketClient client, + IBotCredsProvider creds, + DbService db) + { + _pubSub = pubSub; + _client = client; + _creds = creds; + _db = db; + } + + public async Task OnReadyAsync() + { + await _pubSub.Sub(_cleanupTriggerKey, OnCleanupTrigger); + await _pubSub.Sub(_keepTriggerKey, InternalTriggerKeep); + + _client.JoinedGuild += ClientOnJoinedGuild; + + if (_client.ShardId == 0) + await _pubSub.Sub(_cleanupReportKey, OnKeepReport); + } + + private bool keepTriggered = false; + + private async ValueTask InternalTriggerKeep(int shardId) + { + if (_client.ShardId != shardId) + return; + + if (keepTriggered) + return; + + keepTriggered = true; + try + { + var allGuildIds = _client.Guilds.Select(x => x.Id).ToArray(); + + HashSet dontDelete; + await using (var db = _db.GetDbContext()) + { + await using var ctx = db.CreateLinqToDBContext(); + var table = ctx.CreateTable(tableOptions: TableOptions.CheckExistence); + + var dontDeleteList = await table + .Where(x => allGuildIds.Contains(x.GuildId)) + .Select(x => x.GuildId) + .ToListAsyncLinqToDB(); + + dontDelete = dontDeleteList.ToHashSet(); + } + + Log.Information("Leaving {RemainingCount} guilds, 1 every second. {DontDeleteCount} will remain", + allGuildIds.Length - dontDelete.Count, + dontDelete.Count); + + foreach (var guildId in allGuildIds) + { + if (dontDelete.Contains(guildId)) + continue; + + await Task.Delay(1016); + + SocketGuild? guild = null; + try + { + guild = _client.GetGuild(guildId); + + if (guild is null) + { + Log.Warning("Unable to find guild {GuildId}", guildId); + continue; + } + + await guild.LeaveAsync(); + } + catch (Exception ex) + { + Log.Warning("Unable to leave guild {GuildName} [{GuildId}]: {ErrorMessage}", + guild?.Name, + guildId, + ex.Message); + } + } + } + finally + { + keepTriggered = false; + } + } + + public async Task DeleteMissingGuildDataAsync() + { + guildIds = new(); + var totalShards = _creds.GetCreds().TotalShards; + await _pubSub.Pub(_cleanupTriggerKey, true); + var counter = 0; + while (guildIds.Keys.Count < totalShards) + { + await Task.Delay(1000); + counter++; + + if (counter >= 5) + break; + } + + if (guildIds.Keys.Count < totalShards) + return default; + + var allIds = guildIds.SelectMany(x => x.Value) + .ToArray(); + + await using var ctx = _db.GetDbContext(); + await using var linqCtx = ctx.CreateLinqToDBContext(); + await using var tempTable = linqCtx.CreateTempTable(); + + foreach (var chunk in allIds.Chunk(20000)) + { + await tempTable.BulkCopyAsync(chunk.Select(x => new CleanupId() + { + GuildId = x + })); + } + + // delete guild configs + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + // delete guild xp + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + // delete expressions + await ctx.GetTable() + .Where(x => x.GuildId != null + && !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId.Value)) + .DeleteAsync(); + + // delete quotes + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + // delete planted currencies + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + // delete image only channels + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + // delete reaction roles + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + // delete ignored users + await ctx.GetTable() + .Where(x => x.GuildId != null + && !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId.Value)) + .DeleteAsync(); + + // delete perm overrides + await ctx.GetTable() + .Where(x => x.GuildId != null + && !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId.Value)) + .DeleteAsync(); + + // delete repeaters + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + return new() + { + GuildCount = guildIds.Keys.Count, + }; + } + + public async Task KeepGuild(ulong guildId) + { + await using var db = _db.GetDbContext(); + await using var ctx = db.CreateLinqToDBContext(); + var table = ctx.CreateTable(tableOptions: TableOptions.CheckExistence); + if (await table.AnyAsyncLinqToDB(x => x.GuildId == guildId)) + return false; + + await table.InsertAsync(() => new() + { + GuildId = guildId + }); + + return true; + } + + public async Task GetKeptGuildCount() + { + await using var db = _db.GetDbContext(); + await using var ctx = db.CreateLinqToDBContext(); + var table = ctx.CreateTable(tableOptions: TableOptions.CheckExistence); + return await table.CountAsync(); + } + + public async Task StartLeavingUnkeptServers(int shardId) + => await _pubSub.Pub(_keepTriggerKey, shardId); + + private ValueTask OnKeepReport(KeepReport report) + { + guildIds[report.ShardId] = report.GuildIds; + return default; + } + + private async Task ClientOnJoinedGuild(SocketGuild arg) + { + await KeepGuild(arg.Id); + } + + private ValueTask OnCleanupTrigger(bool arg) + { + _pubSub.Pub(_cleanupReportKey, + new KeepReport() + { + ShardId = _client.ShardId, + GuildIds = _client.GetGuildIds(), + }); + + return default; + } +} + +public class KeptGuilds +{ + [PrimaryKey] + public ulong GuildId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs new file mode 100644 index 0000000..80484c3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs @@ -0,0 +1,164 @@ +#nullable disable +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration; +using EllieBot.Modules.Gambling; +using EllieBot.Modules.Administration.Services; +using EllieBot.Modules.Xp; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + [OwnerOnly] + [NoPublicBot] + public partial class DangerousCommands : CleanupModuleBase + { + private readonly DangerousCommandsService _ds; + private readonly IGamblingCleanupService _gcs; + private readonly IXpCleanupService _xcs; + + public DangerousCommands( + DangerousCommandsService ds, + IGamblingCleanupService gcs, + IXpCleanupService xcs) + { + _ds = ds; + _gcs = gcs; + _xcs = xcs; + } + + [Cmd] + [OwnerOnly] + public Task SqlSelect([Leftover] string sql) + { + var result = _ds.SelectSql(sql); + + return Response() + .Paginated() + .Items(result.Results) + .PageSize(20) + .Page((items, _) => + { + if (!items.Any()) + return _sender.CreateEmbed().WithErrorColor().WithFooter(sql).WithDescription("-"); + + return _sender.CreateEmbed() + .WithOkColor() + .WithFooter(sql) + .WithTitle(string.Join(" ║ ", result.ColumnNames)) + .WithDescription(string.Join('\n', items.Select(x => string.Join(" ║ ", x)))); + }) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SqlSelectCsv([Leftover] string sql) + { + var result = _ds.SelectSql(sql); + + // create a file stream and write the data as csv + using var ms = new MemoryStream(); + await using var sw = new StreamWriter(ms); + await using var csv = new CsvWriter(sw, + new CsvConfiguration(CultureInfo.InvariantCulture) + { + Delimiter = "," + }); + + foreach (var cn in result.ColumnNames) + { + csv.WriteField(cn); + } + + await csv.NextRecordAsync(); + + foreach (var row in result.Results) + { + foreach (var field in row) + { + csv.WriteField(field); + } + + await csv.NextRecordAsync(); + } + + + await csv.FlushAsync(); + ms.Position = 0; + + // send the file + await ctx.Channel.SendFileAsync(ms, $"query_result_{DateTime.UtcNow.Ticks}.csv"); + } + + [Cmd] + [OwnerOnly] + public async Task SqlExec([Leftover] string sql) + { + try + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.sql_confirm_exec)) + .WithDescription(Format.Code(sql)); + + if (!await PromptUserConfirmAsync(embed)) + return; + + var res = await _ds.ExecuteSql(sql); + await Response().Confirm(res.ToString()).SendAsync(); + } + catch (Exception ex) + { + await Response().Error(ex.ToString()).SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task PurgeUser(ulong userId) + { + var embed = _sender.CreateEmbed() + .WithDescription(GetText(strs.purge_user_confirm(Format.Bold(userId.ToString())))); + + if (!await PromptUserConfirmAsync(embed)) + return; + + await _ds.PurgeUserAsync(userId); + await ctx.OkAsync(); + } + + [Cmd] + [OwnerOnly] + public Task PurgeUser([Leftover] IUser user) + => PurgeUser(user.Id); + + [Cmd] + [OwnerOnly] + public Task DeleteXp() + => ConfirmActionInternalAsync("Delete Xp", () => _xcs.DeleteXp()); + + + [Cmd] + [OwnerOnly] + public Task DeleteWaifus() + => ConfirmActionInternalAsync("Delete Waifus", () => _gcs.DeleteWaifus()); + + [Cmd] + [OwnerOnly] + public async Task DeleteWaifu(IUser user) + => await DeleteWaifu(user.Id); + + [Cmd] + [OwnerOnly] + public Task DeleteWaifu(ulong userId) + => ConfirmActionInternalAsync($"Delete Waifu {userId}", () => _gcs.DeleteWaifu(userId)); + + + [Cmd] + [OwnerOnly] + public Task DeleteCurrency() + => ConfirmActionInternalAsync("Delete Currency", () => _gcs.DeleteCurrency()); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs new file mode 100644 index 0000000..bbea48b --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs @@ -0,0 +1,103 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.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/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs new file mode 100644 index 0000000..cd6d742 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class CleanupId +{ + [Key] + public ulong GuildId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs new file mode 100644 index 0000000..8988f32 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public interface ICleanupService +{ + Task DeleteMissingGuildDataAsync(); + Task KeepGuild(ulong guildId); + Task GetKeptGuildCount(); + Task StartLeavingUnkeptServers(int shardId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs new file mode 100644 index 0000000..44ecee2 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class KeepReport +{ + public required int ShardId { get; init; } + public required ulong[] GuildIds { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs new file mode 100644 index 0000000..52c8051 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class KeepResult +{ + public required int GuildCount { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs new file mode 100644 index 0000000..8099573 --- /dev/null +++ b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs @@ -0,0 +1,36 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.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 Response().Error(strs.not_in_voice).SendAsync(); + return; + } + + var id = _service.ToggleGameVoiceChannel(ctx.Guild.Id, vch.Id); + + if (id is null) + await Response().Confirm(strs.gvc_disabled).SendAsync(); + else + { + _service.GameVoiceChannels.Add(vch.Id); + await Response().Confirm(strs.gvc_enabled(Format.Bold(vch.Name))).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs new file mode 100644 index 0000000..e83e5a3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs @@ -0,0 +1,125 @@ +#nullable disable +namespace EllieBot.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/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs new file mode 100644 index 0000000..c7ee52a --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs @@ -0,0 +1,226 @@ +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class GreetCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task Boost() + => Toggle(GreetType.Boost); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task BoostDel(int timer = 30) + => SetDel(GreetType.Boost, timer); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task BoostMsg([Leftover] string? text = null) + => SetMsg(GreetType.Boost, text); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task Greet() + => Toggle(GreetType.Greet); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task GreetDel(int timer = 30) + => SetDel(GreetType.Greet, timer); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task GreetMsg([Leftover] string? text = null) + => SetMsg(GreetType.Greet, text); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task GreetDm() + => Toggle(GreetType.GreetDm); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task GreetDmMsg([Leftover] string? text = null) + => SetMsg(GreetType.GreetDm, text); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task Bye() + => Toggle(GreetType.Bye); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task ByeDel(int timer = 30) + => SetDel(GreetType.Bye, timer); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task ByeMsg([Leftover] string? text = null) + => SetMsg(GreetType.Bye, text); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task GreetTest([Leftover] IGuildUser? user = null) + => Test(GreetType.Greet, user); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public Task GreetDmTest([Leftover] IGuildUser? user = null) + => Test(GreetType.GreetDm, user); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public Task ByeTest([Leftover] IGuildUser? user = null) + => Test(GreetType.Bye, user); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public Task BoostTest([Leftover] IGuildUser? user = null) + => Test(GreetType.Boost, user); + + + public async Task Toggle(GreetType type) + { + var enabled = await _service.SetGreet(ctx.Guild.Id, ctx.Channel.Id, type); + + if (enabled) + await Response() + .Confirm( + type switch + { + GreetType.Boost => strs.boost_on, + GreetType.Greet => strs.greet_on, + GreetType.Bye => strs.bye_on, + GreetType.GreetDm => strs.greetdm_on, + _ => strs.error + } + ) + .SendAsync(); + else + await Response() + .Pending( + type switch + { + GreetType.Boost => strs.boost_off, + GreetType.Greet => strs.greet_off, + GreetType.Bye => strs.bye_off, + GreetType.GreetDm => strs.greetdm_off, + _ => strs.error + } + ) + .SendAsync(); + } + + + public async Task SetDel(GreetType type, int timer) + { + if (timer is < 0 or > 600) + return; + + await _service.SetDeleteTimer(ctx.Guild.Id, type, timer); + + if (timer > 0) + await Response() + .Confirm( + type switch + { + GreetType.Boost => strs.boostdel_on(timer), + GreetType.Greet => strs.greetdel_on(timer), + GreetType.Bye => strs.byedel_on(timer), + _ => strs.error + } + ) + .SendAsync(); + else + await Response() + .Pending( + type switch + { + GreetType.Boost => strs.boostdel_off, + GreetType.Greet => strs.greetdel_off, + GreetType.Bye => strs.byedel_off, + _ => strs.error + }) + .SendAsync(); + } + + + public async Task SetMsg(GreetType type, string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var conf = await _service.GetGreetSettingsAsync(ctx.Guild.Id, type); + var msg = conf?.MessageText ?? GreetService.GetDefaultGreet(type); + await Response() + .Confirm( + type switch + { + GreetType.Boost => strs.boostmsg_cur(msg), + GreetType.Greet => strs.greetmsg_cur(msg), + GreetType.Bye => strs.byemsg_cur(msg), + GreetType.GreetDm => strs.greetdmmsg_cur(msg), + _ => strs.error + }) + .SendAsync(); + return; + } + + var isEnabled = await _service.SetMessage(ctx.Guild.Id, type, text); + + await Response() + .Confirm(type switch + { + GreetType.Boost => strs.boostmsg_new, + GreetType.Greet => strs.greetmsg_new, + GreetType.Bye => strs.byemsg_new, + GreetType.GreetDm => strs.greetdmmsg_new, + _ => strs.error + }) + .SendAsync(); + + + if (!isEnabled) + { + var cmdName = type switch + { + GreetType.Greet => "greet", + GreetType.Bye => "bye", + GreetType.Boost => "boost", + GreetType.GreetDm => "greetdm", + _ => "unknown_command" + }; + + await Response().Pending(strs.boostmsg_enable($"`{prefix}{cmdName}`")).SendAsync(); + } + } + + public async Task Test(GreetType type, IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + await _service.Test(ctx.Guild.Id, type, (ITextChannel)ctx.Channel, user); + var conf = await _service.GetGreetSettingsAsync(ctx.Guild.Id, type); + if (conf?.IsEnabled is not true) + await Response().Pending(strs.boostmsg_enable($"`{prefix}boost`")).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetService.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetService.cs new file mode 100644 index 0000000..f733636 --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetService.cs @@ -0,0 +1,480 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using LinqToDB.Tools; +using EllieBot.Common.ModuleBehaviors; +using System.Threading.Channels; + +namespace EllieBot.Services; + +public class GreetService : IEService, IReadyExecutor +{ + private readonly DbService _db; + + private ConcurrentDictionary> _enabled = new(); + + private readonly DiscordSocketClient _client; + + private readonly IReplacementService _repSvc; + private readonly IBotCache _cache; + private readonly IMessageSenderService _sender; + + private readonly Channel<(GreetSettings, IUser, ITextChannel?)> _greetQueue = + Channel.CreateBounded<(GreetSettings, IUser, ITextChannel?)>( + new BoundedChannelOptions(60) + { + FullMode = BoundedChannelFullMode.DropOldest + }); + + public GreetService( + DiscordSocketClient client, + DbService db, + IMessageSenderService sender, + IReplacementService repSvc, + IBotCache cache + ) + { + _db = db; + _client = client; + _repSvc = repSvc; + _cache = cache; + _sender = sender; + + + foreach (var type in Enum.GetValues()) + { + _enabled[type] = new(); + } + } + + public async Task OnReadyAsync() + { + // cache all enabled guilds + await using (var uow = _db.GetDbContext()) + { + var guilds = _client.Guilds.Select(x => x.Id).ToList(); + var enabled = await uow.GetTable() + .Where(x => x.GuildId.In(guilds)) + .Where(x => x.IsEnabled) + .Select(x => new + { + x.GuildId, + x.GreetType + }) + .ToListAsync(); + + foreach (var e in enabled) + { + _enabled[e.GreetType].Add(e.GuildId); + } + } + + _client.UserJoined += OnUserJoined; + _client.UserLeft += OnUserLeft; + + _client.LeftGuild += OnClientLeftGuild; + + _client.GuildMemberUpdated += ClientOnGuildMemberUpdated; + + var timer = new PeriodicTimer(TimeSpan.FromSeconds(2)); + while (await timer.WaitForNextTickAsync()) + { + var (conf, user, ch) = await _greetQueue.Reader.ReadAsync(); + await GreetUsers(conf, ch, user); + } + } + + 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)) + { + _ = Task.Run(async () => + { + var conf = await GetGreetSettingsAsync(newUser.Guild.Id, GreetType.Boost); + + if (conf is null || !conf.IsEnabled) + return; + + ITextChannel? channel = null; + if (conf.ChannelId is { } cid) + channel = newUser.Guild.GetTextChannel(cid); + + if (channel is null) + return; + + await GreetUsers(conf, channel, newUser); + }); + } + + return Task.CompletedTask; + } + + private async Task OnClientLeftGuild(SocketGuild guild) + { + foreach (var gt in Enum.GetValues()) + { + _enabled[gt].TryRemove(guild.Id); + } + + await using var uow = _db.GetDbContext(); + await uow.GetTable() + .Where(x => x.GuildId == guild.Id) + .DeleteAsync(); + } + + private Task OnUserLeft(SocketGuild guild, SocketUser user) + { + _ = Task.Run(async () => + { + try + { + var conf = await GetGreetSettingsAsync(guild.Id, GreetType.Bye); + + if (conf is null) + return; + + var channel = guild.TextChannels.FirstOrDefault(c => c.Id == conf.ChannelId); + + if (channel is null) //maybe warn the server owner that the channel is missing + { + await SetGreet(guild.Id, null, GreetType.Bye, false); + return; + } + + await _greetQueue.Writer.WriteAsync((conf, user, channel)); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private readonly TypedKey _greetSettingsKey = new("greet_settings"); + + public async Task GetGreetSettingsAsync(ulong gid, GreetType type) + => await _cache.GetOrAddAsync(_greetSettingsKey, + () => InternalGetGreetSettingsAsync(gid, type), + TimeSpan.FromSeconds(3)); + + private async Task InternalGetGreetSettingsAsync(ulong gid, GreetType type) + { + await using var uow = _db.GetDbContext(); + var res = await uow.GetTable() + .Where(x => x.GuildId == gid && x.GreetType == type) + .FirstOrDefaultAsync(); + + if (res is not null) + res.MessageText ??= GetDefaultGreet(type); + + return res; + } + + private async Task GreetUsers(GreetSettings conf, ITextChannel? channel, IUser user) + { + if (conf.GreetType == GreetType.GreetDm) + { + if (user is not IGuildUser gu) + return; + + await GreetDmUserInternal(conf, gu); + return; + } + + if (channel is null) + return; + + var repCtx = new ReplacementContext(client: _client, + guild: channel.Guild, + channel: channel, + user: user); + + var text = SmartText.CreateFrom(conf.MessageText); + text = await _repSvc.ReplaceAsync(text, repCtx); + try + { + var toDelete = await _sender.Response(channel).Text(text).Sanitize(false).SendAsync(); + if (conf.AutoDeleteTimer > 0) + toDelete.DeleteAfter(conf.AutoDeleteTimer); + } + catch (HttpException ex) when (ex.DiscordCode is DiscordErrorCode.InsufficientPermissions + or DiscordErrorCode.MissingPermissions + or 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, GreetType.Greet, false); + } + catch (Exception ex) + { + Log.Warning(ex, "Error embeding greet message"); + } + } + + + private async Task GreetDmUser(GreetSettings conf, IGuildUser user) + { + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await _greetQueue.Writer.WriteAsync((conf, user, null)); + return await completionSource.Task; + } + + private async Task GreetDmUserInternal(GreetSettings conf, IGuildUser user) + { + try + { + var repCtx = new ReplacementContext(client: _client, guild: user.Guild, user: user); + var smartText = SmartText.CreateFrom(conf.MessageText); + smartText = await _repSvc.ReplaceAsync(smartText, repCtx); + + if (smartText is SmartPlainText pt) + { + smartText = new SmartEmbedText() + { + Description = pt.Text + }; + } + + if (smartText is SmartEmbedText set) + { + smartText = set with + { + Footer = CreateFooterSource(user) + }; + } + else if (smartText 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 + smartText = seta with + { + Embeds = + [ + 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 _sender.Response(user).Text(smartText).Sanitize(false).SendAsync(); + } + 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 = await GetGreetSettingsAsync(user.GuildId, GreetType.Greet); + + if (conf is not null && conf.IsEnabled && conf.ChannelId is { } channelId) + { + var channel = await user.Guild.GetTextChannelAsync(channelId); + if (channel is not null) + { + await _greetQueue.Writer.WriteAsync((conf, user, channel)); + } + } + + var confDm = await GetGreetSettingsAsync(user.GuildId, GreetType.GreetDm); + + if (confDm?.IsEnabled ?? false) + await GreetDmUser(confDm, user); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + + public static string GetDefaultGreet(GreetType greetType) + => greetType switch + { + GreetType.Boost => "%user.mention% has boosted the server!", + GreetType.Greet => "%user.mention% has joined the server!", + GreetType.Bye => "%user.name% has left the server!", + GreetType.GreetDm => "Welcome to the server %user.name%", + _ => "%user.name% did something new!" + }; + + public async Task SetGreet( + ulong guildId, + ulong? channelId, + GreetType greetType, + bool? value = null) + { + await using var uow = _db.GetDbContext(); + var q = uow.GetTable(); + + if(value is null) + value = !_enabled[greetType].Contains(guildId); + + if (value is { } v) + { + await q + .InsertOrUpdateAsync(() => new() + { + GuildId = guildId, + GreetType = greetType, + IsEnabled = v, + ChannelId = channelId, + }, + (old) => new() + { + IsEnabled = v, + ChannelId = channelId, + }, + () => new() + { + GuildId = guildId, + GreetType = greetType, + }); + } + + if (value is true) + { + _enabled[greetType].Add(guildId); + return true; + } + + _enabled[greetType].TryRemove(guildId); + return false; + } + + + public async Task SetMessage(ulong guildId, GreetType greetType, string? message) + { + await using (var uow = _db.GetDbContext()) + { + await uow.GetTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guildId, + GreetType = greetType, + MessageText = message + }, + x => new() + { + MessageText = message + }, + () => new() + { + GuildId = guildId, + GreetType = greetType + }); + } + + var conf = await GetGreetSettingsAsync(guildId, greetType); + + return conf?.IsEnabled ?? false; + } + + public async Task SetDeleteTimer(ulong guildId, GreetType greetType, int timer) + { + if (timer < 0 || timer > 3600) + throw new ArgumentOutOfRangeException(nameof(timer)); + + await using (var uow = _db.GetDbContext()) + { + await uow.GetTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guildId, + GreetType = greetType, + AutoDeleteTimer = timer, + }, + x => new() + { + AutoDeleteTimer = timer + }, + () => new() + { + GuildId = guildId, + GreetType = greetType + }); + } + + var conf = await GetGreetSettingsAsync(guildId, greetType); + + return conf?.IsEnabled ?? false; + } + + + public async Task Test( + ulong guildId, + GreetType type, + IMessageChannel channel, + IGuildUser user) + { + var conf = await GetGreetSettingsAsync(guildId, type); + if (conf is null) + return false; + + await SendMessage(conf, channel, user); + return true; + } + + public async Task SendMessage(GreetSettings conf, IMessageChannel channel, IGuildUser user) + { + if (conf.GreetType == GreetType.GreetDm) + { + await _greetQueue.Writer.WriteAsync((conf, user, channel as ITextChannel)); + return await GreetDmUser(conf, user); + } + + if (channel is not ITextChannel ch) + return false; + + await GreetUsers(conf, ch, user); + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs new file mode 100644 index 0000000..d798f4f --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs @@ -0,0 +1,21 @@ +namespace EllieBot.Services; + +public enum GreetType +{ + Greet, + GreetDm, + Bye, + Boost, +} + +public class GreetSettings +{ + public int Id { get; set; } + + public ulong GuildId { get; set; } + public GreetType GreetType { get; set; } + public string? MessageText { get; set; } + public bool IsEnabled { get; set; } + public ulong? ChannelId { get; set; } + public int AutoDeleteTimer { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Honeypot/HoneyPotService.cs b/src/EllieBot/Modules/Administration/Honeypot/HoneyPotService.cs new file mode 100644 index 0000000..8ff20ac --- /dev/null +++ b/src/EllieBot/Modules/Administration/Honeypot/HoneyPotService.cs @@ -0,0 +1,95 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using System.Threading.Channels; + +namespace EllieBot.Modules.Administration.Honeypot; + +public sealed class HoneyPotService : IHoneyPotService, IReadyExecutor, IExecNoCommand, IEService +{ + private readonly DbService _db; + private readonly CommandHandler _handler; + + private ConcurrentHashSet _channels = new(); + + private Channel _punishments = Channel.CreateBounded( + new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + + public HoneyPotService(DbService db, CommandHandler handler) + { + _db = db; + _handler = handler; + } + + public async Task ToggleHoneypotChannel(ulong guildId, ulong channelId) + { + await using var uow = _db.GetDbContext(); + + var deleted = await uow.HoneyPotChannels + .Where(x => x.GuildId == guildId) + .DeleteWithOutputAsync(); + + if (deleted.Length > 0) + { + _channels.TryRemove(deleted[0].ChannelId); + return false; + } + + await uow.HoneyPotChannels + .ToLinqToDBTable() + .InsertAsync(() => new HoneypotChannel + { + GuildId = guildId, + ChannelId = channelId + }); + + _channels.Add(channelId); + + return true; + } + + public async Task OnReadyAsync() + { + await using var uow = _db.GetDbContext(); + + var channels = await uow.HoneyPotChannels + .Select(x => x.ChannelId) + .ToListAsyncLinqToDB(); + + _channels = new(channels); + + while (await _punishments.Reader.WaitToReadAsync()) + { + while (_punishments.Reader.TryRead(out var user)) + { + try + { + Log.Information("Honeypot caught user {User} [{UserId}]", user, user.Id); + await user.BanAsync(pruneDays: 1); + await user.Guild.RemoveBanAsync(user.Id); + } + catch (Exception e) + { + Log.Warning(e, "Failed banning {User} due to {Error}", user, e.Message); + } + + await Task.Delay(1000); + } + } + } + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (_channels.Contains(msg.Channel.Id) && msg.Author is SocketGuildUser sgu) + { + if (!sgu.GuildPermissions.BanMembers) + await _punishments.Writer.WriteAsync(sgu); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Honeypot/HoneypotCommands.cs b/src/EllieBot/Modules/Administration/Honeypot/HoneypotCommands.cs new file mode 100644 index 0000000..897d019 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Honeypot/HoneypotCommands.cs @@ -0,0 +1,29 @@ +using EllieBot.Modules.Administration.Honeypot; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class HoneypotCommands : EllieModule + { + private readonly IHoneyPotService _service; + + public HoneypotCommands(IHoneyPotService service) + => _service = service; + + [Cmd] + [RequireContext(ContextType.Guild)] + [RequireUserPermission(GuildPermission.Administrator)] + [RequireBotPermission(GuildPermission.BanMembers)] + public async Task Honeypot() + { + var enabled = await _service.ToggleHoneypotChannel(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.honeypot_on).SendAsync(); + else + await Response().Confirm(strs.honeypot_off).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Honeypot/IHoneyPotService.cs b/src/EllieBot/Modules/Administration/Honeypot/IHoneyPotService.cs new file mode 100644 index 0000000..1483590 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Honeypot/IHoneyPotService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Administration.Honeypot; + +public interface IHoneyPotService +{ + public Task ToggleHoneypotChannel(ulong guildId, ulong channelId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs b/src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs new file mode 100644 index 0000000..1b45cc6 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs @@ -0,0 +1,235 @@ +#nullable disable +using LinqToDB; +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using System.Net; +using System.Threading.Channels; +using EllieBot.Db.Models; + +namespace EllieBot.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 or 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/EllieBot/Modules/Administration/LocalizationCommands.cs b/src/EllieBot/Modules/Administration/LocalizationCommands.cs new file mode 100644 index 0000000..9715d0b --- /dev/null +++ b/src/EllieBot/Modules/Administration/LocalizationCommands.cs @@ -0,0 +1,264 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.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 Response().Confirm(strs.lang_set_show(Format.Bold(Culture.ToString()), + Format.Bold(Culture.NativeName))).SendAsync(); + + [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 CultureInfo(name); + if (!_supportedLocales.ContainsKey(ci.Name)) + { + await LanguagesList(); + return; + } + + _localization.SetGuildCulture(ctx.Guild, ci); + } + + var nativeName = ci.NativeName; + if (ci.Name == "ts-TS") + nativeName = _supportedLocales[ci.Name]; + await Response().Confirm(strs.lang_set(Format.Bold(ci.ToString()), Format.Bold(nativeName))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.lang_set_fail).SendAsync(); + } + } + + [Cmd] + public async Task LanguageSetDefault() + { + var cul = _localization.DefaultCultureInfo; + await Response().Error(strs.lang_set_bot_show(cul, cul.NativeName)).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task LanguageSetDefault(string name) + { + try + { + CultureInfo ci; + if (name.Trim().ToLowerInvariant() == "default") + { + _localization.ResetDefaultCulture(); + ci = _localization.DefaultCultureInfo; + } + else + { + ci = new CultureInfo(name); + if (!_supportedLocales.ContainsKey(ci.Name)) + { + await LanguagesList(); + return; + } + _localization.SetDefaultCulture(ci); + } + + await Response().Confirm(strs.lang_set_bot(Format.Bold(ci.ToString()), + Format.Bold(ci.NativeName))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.lang_set_fail).SendAsync(); + } + } + + [Cmd] + public async Task LanguagesList() + => await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.lang_list)) + .WithDescription(string.Join("\n", + _supportedLocales.Select( + x => $"{Format.Code(x.Key),-10} => {x.Value}")))).SendAsync(); + } +} +/* 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/EllieBot/Modules/Administration/Mute/MuteCommands.cs b/src/EllieBot/Modules/Administration/Mute/MuteCommands.cs new file mode 100644 index 0000000..94e797a --- /dev/null +++ b/src/EllieBot/Modules/Administration/Mute/MuteCommands.cs @@ -0,0 +1,231 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.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 Response().Error(strs.mute_perms).SendAsync(); + 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 Response().Confirm(strs.mute_role(Format.Code(muteRole.Name))).SendAsync(); + return; + } + + if (ctx.User.Id != ctx.Guild.OwnerId + && role.Position >= ((SocketGuildUser)ctx.User).Roles.Max(x => x.Position)) + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + await _service.SetMuteRoleAsync(ctx.Guild.Id, role.Name); + + await Response().Confirm(strs.mute_role_set).SendAsync(); + } + + [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 Response().Confirm(strs.user_muted(Format.Bold(target.ToString()))).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception in the mute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [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 Response().Confirm(strs.user_muted_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in mute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [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 Response().Confirm(strs.user_unmuted(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [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 Response().Confirm(strs.user_chat_mute(Format.Bold(user.ToString()))).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception in the chatmute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [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 Response().Confirm(strs.user_chat_mute_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in chatmute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [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 Response().Confirm(strs.user_chat_unmute(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [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 Response().Confirm(strs.user_voice_mute(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [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 Response().Confirm(strs.user_voice_mute_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [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 Response().Confirm(strs.user_voice_unmute(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Mute/MuteService.cs b/src/EllieBot/Modules/Administration/Mute/MuteService.cs new file mode 100644 index 0000000..39400fa --- /dev/null +++ b/src/EllieBot/Modules/Administration/Mute/MuteService.cs @@ -0,0 +1,503 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.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 IMessageSenderService _sender; + + public MuteService(DiscordSocketClient client, DbService db, IMessageSenderService sender) + { + _client = client; + _db = db; + _sender = sender; + + 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(() => _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithDescription($"You've been muted in {user.Guild} server") + .AddField("Mute Type", type.ToString()) + .AddField("Moderator", mod.ToString()) + .AddField("Reason", reason)) + .SendAsync()); + } + + private void OnUserUnmuted( + IGuildUser user, + IUser mod, + MuteType type, + string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + return; + + _ = Task.Run(() => _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithDescription($"You've been unmuted in {user.Guild} server") + .AddField("Unmute Type", type.ToString()) + .AddField("Moderator", mod.ToString()) + .AddField("Reason", reason)) + .SendAsync()); + } + + 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) + { + ArgumentNullException.ThrowIfNull(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/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs b/src/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs new file mode 100644 index 0000000..cc43b31 --- /dev/null +++ b/src/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs @@ -0,0 +1,83 @@ +#nullable disable +using EllieBot.Common.TypeReaders; + +namespace EllieBot.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 Response().Confirm(strs.perm_override_reset).SendAsync(); + return; + } + + var aggregatePerms = perms.Aggregate((acc, seed) => seed | acc); + await _service.AddOverride(ctx.Guild.Id, cmd.Name, aggregatePerms); + + await Response() + .Confirm(strs.perm_override(Format.Bold(aggregatePerms.ToString()), + Format.Code(cmd.Name))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverrideReset() + { + var result = await PromptUserConfirmAsync(_sender.CreateEmbed() + .WithOkColor() + .WithDescription(GetText(strs.perm_override_all_confirm))); + + if (!result) + return; + + await _service.ClearAllOverrides(ctx.Guild.Id); + + await Response().Confirm(strs.perm_override_all).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverrideList(int page = 1) + { + if (--page < 0) + return; + + var allOverrides = await _service.GetAllOverrides(ctx.Guild.Id); + + await Response() + .Paginated() + .Items(allOverrides) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + var eb = _sender.CreateEmbed().WithTitle(GetText(strs.perm_overrides)).WithOkColor(); + + if (items.Count == 0) + eb.WithDescription(GetText(strs.perm_override_page_none)); + else + { + eb.WithDescription(items.Select(ov => $"{ov.Command} => {ov.Perm.ToString()}") + .Join("\n")); + } + + return eb; + }) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs new file mode 100644 index 0000000..16120f2 --- /dev/null +++ b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs @@ -0,0 +1,68 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PlayingRotateCommands : EllieModule + { + [Cmd] + [OwnerOnly] + public async Task RotatePlaying() + { + if (_service.ToggleRotatePlaying()) + await Response().Confirm(strs.ropl_enabled).SendAsync(); + else + await Response().Confirm(strs.ropl_disabled).SendAsync(); + } + + + [Cmd] + [OwnerOnly] + public Task AddPlaying([Leftover] string status) + => AddPlaying(ActivityType.CustomStatus, status); + + [Cmd] + [OwnerOnly] + public async Task AddPlaying(ActivityType statusType, [Leftover] string status) + { + await _service.AddPlaying(statusType, status); + + await Response().Confirm(strs.ropl_added).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ListPlaying() + { + var statuses = _service.GetRotatingStatuses(); + + if (!statuses.Any()) + await Response().Error(strs.ropl_not_set).SendAsync(); + else + { + var i = 1; + await Response() + .Confirm(strs.ropl_list(string.Join("\n\t", + statuses.Select(rs => $"`{i++}.` *{rs.Type}* {rs.Status}")))) + .SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task RemovePlaying(int index) + { + index -= 1; + + var msg = await _service.RemovePlayingAsync(index); + + if (msg is null) + return; + + await Response().Confirm(strs.reprm(msg)).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs new file mode 100644 index 0000000..0c9afff --- /dev/null +++ b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs @@ -0,0 +1,109 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class PlayingRotateService : IEService, IReadyExecutor +{ + private readonly BotConfigService _bss; + private readonly SelfService _selfService; + private readonly IReplacementService _repService; + // private readonly Replacer _rep; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public PlayingRotateService( + DiscordSocketClient client, + DbService db, + BotConfigService bss, + IEnumerable phProviders, + SelfService selfService, + IReplacementService repService) + { + _db = db; + _bss = bss; + _selfService = selfService; + _repService = repService; + _client = client; + + } + + 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 = await _repService.ReplaceAsync(playingStatus.Status, new (client: _client)); + await _selfService.SetActivityAsync(statusText, (ActivityType)playingStatus.Type); + } + catch (Exception ex) + { + Log.Warning(ex, "Rotating playing status errored: {ErrorMessage}", ex.Message); + } + } + } + + public async Task RemovePlayingAsync(int index) + { + ArgumentOutOfRangeException.ThrowIfNegative(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 = (EllieBot.Db.DbActivityType)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/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs b/src/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs new file mode 100644 index 0000000..3b8ea72 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs @@ -0,0 +1,57 @@ +#nullable disable +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PrefixCommands : EllieModule + { + public enum Set + { + Set + } + + [Cmd] + [Priority(1)] + public async Task Prefix() + => await Response().Confirm(strs.prefix_current(Format.Code(_cmdHandler.GetPrefix(ctx.Guild)))).SendAsync(); + + [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 Response().Confirm(strs.prefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task DefPrefix([Leftover] string toSet = null) + { + if (string.IsNullOrWhiteSpace(toSet)) + { + await Response().Confirm(strs.defprefix_current(_cmdHandler.GetPrefix())).SendAsync(); + return; + } + + var oldPrefix = _cmdHandler.GetPrefix(); + var newPrefix = _cmdHandler.SetDefaultPrefix(toSet); + + await Response().Confirm(strs.defprefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs b/src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs new file mode 100644 index 0000000..64648b8 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs @@ -0,0 +1,292 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; + +namespace EllieBot.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 Response().Confirm(strs.prot_disable("Anti-Alt")).SendAsync(); + return; + } + + await Response().Confirm(strs.protection_not_running("Anti-Alt")).SendAsync(); + } + + [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 Response().Confirm(strs.prot_disable("Anti-Raid")).SendAsync(); + return Response().Pending(strs.protection_not_running("Anti-Raid")).SendAsync(); + } + + [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 Response().Error(strs.punishment_unsupported(action)).SendAsync(); + return; + } + + if (userThreshold is < 2 or > 30) + { + await Response().Error(strs.raid_cnt(2, 30)).SendAsync(); + return; + } + + if (seconds is < 2 or > 300) + { + await Response().Error(strs.raid_time(2, 300)).SendAsync(); + return; + } + + if (punishTime is not null) + { + if (!_service.IsDurationAllowed(action)) + await Response().Error(strs.prot_cant_use_time).SendAsync(); + } + + 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 Response() + .Confirm(GetText(strs.prot_enable("Anti-Raid")), + $"{ctx.User.Mention} {GetAntiRaidString(stats)}") + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public Task AntiSpam() + { + if (_service.TryStopAntiSpam(ctx.Guild.Id)) + return Response().Confirm(strs.prot_disable("Anti-Spam")).SendAsync(); + return Response().Pending(strs.protection_not_running("Anti-Spam")).SendAsync(); + } + + [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 Response().Error(strs.prot_cant_use_time).SendAsync(); + } + + 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 Response() + .Confirm(GetText(strs.prot_enable("Anti-Spam")), + $"{ctx.User.Mention} {GetAntiSpamString(stats)}") + .SendAsync(); + } + + [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 Response().Error(strs.protection_not_running("Anti-Spam")).SendAsync(); + return; + } + + if (added.Value) + await Response().Confirm(strs.spam_ignore("Anti-Spam")).SendAsync(); + else + await Response().Confirm(strs.spam_not_ignore("Anti-Spam")).SendAsync(); + } + + [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 Response().Confirm(strs.prot_none).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().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 Response().Embed(embed).SendAsync(); + } + + 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/EllieBot/Modules/Administration/Protection/ProtectionService.cs b/src/EllieBot/Modules/Administration/Protection/ProtectionService.cs new file mode 100644 index 0000000..c72a941 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/ProtectionService.cs @@ -0,0 +1,498 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; +using System.Threading.Channels; + +namespace EllieBot.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; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs b/src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs new file mode 100644 index 0000000..f45db4e --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs @@ -0,0 +1,52 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.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/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs b/src/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs new file mode 100644 index 0000000..9cff02e --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs @@ -0,0 +1,13 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.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/EllieBot/Modules/Administration/Protection/UserSpamStats.cs b/src/EllieBot/Modules/Administration/Protection/UserSpamStats.cs new file mode 100644 index 0000000..ad4a9bf --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/UserSpamStats.cs @@ -0,0 +1,64 @@ +#nullable disable +namespace EllieBot.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/EllieBot/Modules/Administration/Prune/PruneCommands.cs b/src/EllieBot/Modules/Administration/Prune/PruneCommands.cs new file mode 100644 index 0000000..5ebafab --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prune/PruneCommands.cs @@ -0,0 +1,227 @@ +#nullable disable +using CommandLine; +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.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(); + + var progressMsg = await Response().Pending(strs.prune_progress(0, 100)).SendAsync(); + var progress = GetProgressTracker(progressMsg); + + PruneResult result; + if (opts.Safe) + result = await _service.PruneWhere((ITextChannel)ctx.Channel, + 100, + x => x.Author.Id == user.Id && !x.IsPinned, + progress, + opts.After); + else + result = await _service.PruneWhere((ITextChannel)ctx.Channel, + 100, + x => x.Author.Id == user.Id, + progress, + opts.After); + + ctx.Message.DeleteAfter(3); + + await SendResult(result); + await progressMsg.DeleteAsync(); + } + + // 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); + + var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync(); + var progress = GetProgressTracker(progressMsg); + + PruneResult result; + if (opts.Safe) + result = await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + x => !x.IsPinned && x.Id != progressMsg.Id, + progress, + opts.After); + else + result = await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + x => x.Id != progressMsg.Id, + progress, + opts.After); + + await SendResult(result); + await progressMsg.DeleteAsync(); + } + + private IProgress<(int, int)> GetProgressTracker(IUserMessage progressMsg) + { + var progress = new Progress<(int, int)>(async (x) => + { + var (deleted, total) = x; + try + { + await progressMsg.ModifyAsync(props => + { + props.Embed = _sender.CreateEmbed() + .WithPendingColor() + .WithDescription(GetText(strs.prune_progress(deleted, total))) + .Build(); + }); + } + catch + { + } + }); + + return progress; + } + + //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); + + var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync(); + var progress = GetProgressTracker(progressMsg); + + PruneResult result; + if (opts.Safe) + { + result = await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks && !m.IsPinned, + progress, + opts.After + ); + } + else + { + result = await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks, + progress, + opts.After + ); + } + + await SendResult(result); + await progressMsg.DeleteAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + public async Task PruneCancel() + { + var ok = await _service.CancelAsync(ctx.Guild.Id); + + if (!ok) + { + await Response().Error(strs.prune_not_found).SendAsync(); + return; + } + + + await Response().Confirm(strs.prune_cancelled).SendAsync(); + } + + + private async Task SendResult(PruneResult result) + { + switch (result) + { + case PruneResult.Success: + break; + case PruneResult.AlreadyRunning: + var msg = await Response().Pending(strs.prune_already_running).SendAsync(); + msg.DeleteAfter(5); + break; + case PruneResult.FeatureLimit: + var msg2 = await Response().Pending(strs.feature_limit_reached_owner).SendAsync(); + msg2.DeleteAfter(10); + break; + default: + Log.Error("Unhandled result received in prune: {Result}", result); + await Response().Error(strs.error_occured).SendAsync(); + break; + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Prune/PruneResult.cs b/src/EllieBot/Modules/Administration/Prune/PruneResult.cs new file mode 100644 index 0000000..b6ac515 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prune/PruneResult.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Administration.Services; + +public enum PruneResult +{ + Success, + AlreadyRunning, + FeatureLimit, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Prune/PruneService.cs b/src/EllieBot/Modules/Administration/Prune/PruneService.cs new file mode 100644 index 0000000..b44406f --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prune/PruneService.cs @@ -0,0 +1,114 @@ +#nullable disable +using EllieBot.Modules.Patronage; + +namespace EllieBot.Modules.Administration.Services; + +public class PruneService : IEService +{ + //channelids where prunes are currently occuring + private readonly ConcurrentDictionary _pruningGuilds = new(); + private readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14); + private readonly ILogCommandService _logService; + private readonly IPatronageService _ps; + + public PruneService(ILogCommandService logService, IPatronageService ps) + { + _logService = logService; + _ps = ps; + } + + public async Task PruneWhere( + ITextChannel channel, + int amount, + Func predicate, + IProgress<(int deleted, int total)> progress, + ulong? after = null + ) + { + ArgumentNullException.ThrowIfNull(channel, nameof(channel)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + var originalAmount = amount; + + using var cancelSource = new CancellationTokenSource(); + if (!_pruningGuilds.TryAdd(channel.GuildId, cancelSource)) + return PruneResult.AlreadyRunning; + + try + { + if (!await _ps.LimitHitAsync(LimitedFeatureName.Prune, channel.Guild.OwnerId)) + { + return PruneResult.FeatureLimit; + } + + var now = DateTime.UtcNow; + IMessage[] msgs; + IMessage lastMessage = null; + + while (amount > 0 && !cancelSource.IsCancellationRequested) + { + var dled = lastMessage is null + ? await channel.GetMessagesAsync(50).FlattenAsync() + : await channel.GetMessagesAsync(lastMessage, Direction.Before, 50).FlattenAsync(); + + msgs = dled + .Where(predicate) + .Where(x => after is not ulong a || x.Id > a) + .Take(amount) + .ToArray(); + + if (!msgs.Any()) + return PruneResult.Success; + + 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); + amount -= msgs.Length; + progress.Report((originalAmount - amount, originalAmount)); + await Task.Delay(2000, cancelSource.Token); + } + + foreach (var group in singleDeletable.Chunk(5)) + { + await group.Select(x => x.DeleteAsync()).WhenAll(); + amount -= 5; + progress.Report((originalAmount - amount, originalAmount)); + await Task.Delay(5000, cancelSource.Token); + } + } + } + catch + { + //ignore + } + finally + { + _pruningGuilds.TryRemove(channel.GuildId, out _); + } + + return PruneResult.Success; + } + + public async Task CancelAsync(ulong guildId) + { + if (!_pruningGuilds.TryRemove(guildId, out var source)) + return false; + + await source.CancelAsync(); + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs b/src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs new file mode 100644 index 0000000..1f2911d --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs @@ -0,0 +1,51 @@ +#nullable disable +using EllieBot.Db.Models; +using OneOf; +using OneOf.Types; + +namespace EllieBot.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); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs b/src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs new file mode 100644 index 0000000..cfcad28 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs @@ -0,0 +1,174 @@ +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.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 ReRoAdd( + 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 Response().Error(strs.rero_message_not_found).SendAsync(); + return; + } + + if (ctx.User.Id != ctx.Guild.OwnerId + && ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position) <= role.Position) + { + await Response().Error(strs.hierarchy).SendAsync(); + 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(), + async fl => + { + _ = msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser); + await Response().Pending(strs.feature_limit_reached_owner).SendAsync(); + }); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoList(int page = 1) + { + if (--page < 0) + return; + + var allReros = await _rero.GetReactionRolesAsync(ctx.Guild.Id); + + await Response() + .Paginated() + .Items(allReros.OrderBy(x => x.Group).ToList()) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => + { + var embed = _sender.CreateEmbed() + .WithOkColor(); + + var content = string.Empty; + foreach (var g in items + .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; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoRemove(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 ReRoDeleteAll() + { + await _rero.RemoveAllReactionRoles(ctx.Guild.Id); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Ratelimit(60)] + public async Task ReRoTransfer(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/EllieBot/Modules/Administration/Role/ReactionRolesService.cs b/src/EllieBot/Modules/Administration/Role/ReactionRolesService.cs new file mode 100644 index 0000000..caa3e19 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/ReactionRolesService.cs @@ -0,0 +1,404 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Patronage; +using EllieBot.Db.Models; +using OneOf.Types; +using OneOf; + +namespace EllieBot.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; + + public ReactionRolesService( + DiscordSocketClient client, + IPatronageService ps, + DbService db, + IBotCredentials creds) + { + _db = db; + _client = client; + _creds = creds; + _ps = ps; + _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, new RequestOptions() + { + AuditLogReason = $"Reaction role" + }); + } + } + 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() + .ToArray(); + + + if (exclusive.Any()) + { + try + { + await user.RemoveRolesAsync(exclusive, + new RequestOptions() + { + AuditLogReason = "Reaction role exclusive group" + }); + } + 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, new() + { + AuditLogReason = "Reaction role" + }); + } + } + 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) + { + ArgumentOutOfRangeException.ThrowIfNegative(group); + + ArgumentOutOfRangeException.ThrowIfNegative(levelReq); + + 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 limit = await _ps.GetUserLimit(LimitedFeatureName.ReactionRole, guild.OwnerId); + + if (!_creds.IsOwner(guild.OwnerId) && (activeReactionRoles >= limit.Quota && limit.Quota >= 0)) + { + return new Error(); + } + + 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, + _ => [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/EllieBot/Modules/Administration/Role/RoleCommands.cs b/src/EllieBot/Modules/Administration/Role/RoleCommands.cs new file mode 100644 index 0000000..7f5daea --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/RoleCommands.cs @@ -0,0 +1,209 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + public partial class RoleCommands : EllieModule + { + public enum Exclude + { + Excl + } + + private readonly IServiceProvider _services; + private StickyRolesService _stickyRoleSvc; + + public RoleCommands(IServiceProvider services, StickyRolesService stickyRoleSvc) + { + _services = services; + _stickyRoleSvc = stickyRoleSvc; + } + + [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, new RequestOptions() + { + AuditLogReason = $"Added by [{ctx.User.Username}]" + }); + + await Response().Confirm(strs.setrole(Format.Bold(roleToAdd.Name), + Format.Bold(targetUser.ToString()))).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in setrole command"); + await Response().Error(strs.setrole_err).SendAsync(); + } + } + + [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 Response().Confirm(strs.remrole(Format.Bold(roleToRemove.Name), + Format.Bold(targetUser.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.remrole_err).SendAsync(); + } + } + + [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 Response().Error(strs.renrole_perms).SendAsync(); + return; + } + + await roleToEdit.ModifyAsync(g => g.Name = newname); + await Response().Confirm(strs.renrole).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.renrole_err).SendAsync(); + } + } + + [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 Response().Confirm(strs.rar(Format.Bold(user.ToString()))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.rar_err).SendAsync(); + } + } + + [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 Response().Confirm(strs.cr(Format.Bold(r.Name))).SendAsync(); + } + + [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 Response().Confirm(strs.dr(Format.Bold(role.Name))).SendAsync(); + } + + [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 Response().Confirm(strs.rolehoist_enabled(Format.Bold(role.Name))).SendAsync(); + else + await Response().Confirm(strs.rolehoist_disabled(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task RoleColor([Leftover] IRole role) + => await Response().Confirm("Role Color", role.Color.RawValue.ToString("x6")).SendAsync(); + + [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 Response().Confirm(strs.rc(Format.Bold(role.Name))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.rc_perms).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task StickyRoles() + { + var newState = await _stickyRoleSvc.ToggleStickyRoles(ctx.Guild.Id); + + if (newState) + { + await Response().Confirm(strs.sticky_roles_enabled).SendAsync(); + } + else + { + await Response().Confirm(strs.sticky_roles_disabled).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/StickyRolesService.cs b/src/EllieBot/Modules/Administration/Role/StickyRolesService.cs new file mode 100644 index 0000000..ede5b63 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/StickyRolesService.cs @@ -0,0 +1,138 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Administration; + +public sealed class StickyRolesService : IEService, IReadyExecutor +{ + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + private readonly DbService _db; + private HashSet _stickyRoles = new(); + + public StickyRolesService( + DiscordSocketClient client, + IBotCredentials creds, + DbService db) + { + _client = client; + _creds = creds; + _db = db; + } + + + public async Task OnReadyAsync() + { + await using (var ctx = _db.GetDbContext()) + { + _stickyRoles = (await ctx + .Set() + .ToLinqToDBTable() + .Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, + _creds.TotalShards, + _client.ShardId)) + .Where(x => x.StickyRoles) + .Select(x => x.GuildId) + .ToListAsync()) + .ToHashSet(); + } + + _client.UserJoined += ClientOnUserJoined; + _client.UserLeft += ClientOnUserLeft; + + // cleanup old ones every hour + // 30 days retention + if (_client.ShardId == 0) + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .Where(x => x.DateAdded < DateTime.UtcNow - TimeSpan.FromDays(30)) + .DeleteAsync(); + } + } + } + + private Task ClientOnUserLeft(SocketGuild guild, SocketUser user) + { + if (user is not SocketGuildUser gu) + return Task.CompletedTask; + + if (!_stickyRoles.Contains(guild.Id)) + return Task.CompletedTask; + + _ = Task.Run(async () => await SaveRolesAsync(guild.Id, gu.Id, gu.Roles)); + + return Task.CompletedTask; + } + + private async Task SaveRolesAsync(ulong guildId, ulong userId, IReadOnlyCollection guRoles) + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertAsync(() => new() + { + GuildId = guildId, + UserId = userId, + RoleIds = string.Join(',', + guRoles.Where(x => !x.IsEveryone && !x.IsManaged).Select(x => x.Id.ToString())), + DateAdded = DateTime.UtcNow + }); + } + + private Task ClientOnUserJoined(SocketGuildUser user) + { + if (!_stickyRoles.Contains(user.Guild.Id)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var roles = await GetRolesAsync(user.Guild.Id, user.Id); + + await user.AddRolesAsync(roles); + }); + + return Task.CompletedTask; + } + + private async Task GetRolesAsync(ulong guildId, ulong userId) + { + await using var ctx = _db.GetDbContext(); + var stickyRolesEntry = await ctx + .GetTable() + .Where(x => x.GuildId == guildId && x.UserId == userId) + .DeleteWithOutputAsync(); + + if (stickyRolesEntry is { Length: > 0 }) + { + return stickyRolesEntry[0].GetRoleIds(); + } + + return []; + } + + public async Task ToggleStickyRoles(ulong guildId, bool? newState = null) + { + await using var ctx = _db.GetDbContext(); + var config = ctx.GuildConfigsForId(guildId, set => set); + + config.StickyRoles = newState ?? !config.StickyRoles; + await ctx.SaveChangesAsync(); + + if (config.StickyRoles) + { + _stickyRoles.Add(guildId); + } + else + { + _stickyRoles.Remove(guildId); + } + + return config.StickyRoles; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs b/src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs new file mode 100644 index 0000000..4b782dd --- /dev/null +++ b/src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs @@ -0,0 +1,169 @@ +using System.Net.Http.Json; +using System.Text; +using EllieBot.Common.ModuleBehaviors; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Administration.Self; + +public sealed class ToastielabReleaseModel +{ + [JsonPropertyName("tag_name")] + public required string TagName { get; init; } +} +public sealed class CheckForUpdatesService : IEService, IReadyExecutor +{ + private readonly BotConfigService _bcs; + private readonly IBotCredsProvider _bcp; + private readonly IHttpClientFactory _httpFactory; + private readonly DiscordSocketClient _client; + private readonly IMessageSenderService _sender; + + + private const string RELEASES_URL = "https://toastielab.dev/Emotions-stuff/elliebot/releases"; + + public CheckForUpdatesService( + BotConfigService bcs, + IBotCredsProvider bcp, + IHttpClientFactory httpFactory, + DiscordSocketClient client, + IMessageSenderService sender) + { + _bcs = bcs; + _bcp = bcp; + _httpFactory = httpFactory; + _client = client; + _sender = sender; + } + + 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 + { + using var http = _httpFactory.CreateClient(); + var toastielabRelease = (await http.GetFromJsonAsync(RELEASES_URL)) + ?.FirstOrDefault(); + + if (toastielabRelease?.TagName is null) + continue; + + var latest = toastielabRelease.TagName; + 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/elliebot/raw/branch/v5/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 = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor($"EllieBot v{latest} Released!") + .WithTitle("Changelog") + .WithUrl("https://toastielab.dev/Emotions-stuff/elliebot/src/branch/v5/CHANGELOG.md") + .WithDescription(thisVersionChangelog.TrimTo(4096)) + .WithFooter( + "You may disable these messages by typing '.conf bot checkforupdates false'"); + + await _sender.Response(user).Embed(eb).SendAsync(); + }) + .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/EllieBot/Modules/Administration/Self/SelfCommands.cs b/src/EllieBot/Modules/Administration/Self/SelfCommands.cs new file mode 100644 index 0000000..57f5765 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Self/SelfCommands.cs @@ -0,0 +1,597 @@ +#nullable disable +using Discord.Rest; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; +using Ellie.Common.Marmalade; + +namespace EllieBot.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 IMarmaladeLoaderService _marmaladeLoader; + private readonly ICoordinator _coord; + private readonly DbService _db; + + public SelfCommands( + DiscordSocketClient client, + DbService db, + IBotStrings strings, + ICoordinator coord, + IMarmaladeLoaderService marmaladeLoader) + { + _client = client; + _db = db; + _strings = strings; + _coord = coord; + _marmaladeLoader = marmaladeLoader; + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public Task CacheUsers() + => CacheUsers(ctx.Guild); + + [Cmd] + [OwnerOnly] + public async Task CacheUsers(IGuild guild) + { + var downloadUsersTask = guild.DownloadUsersAsync(); + var message = await Response().Pending(strs.cache_users_pending).SendAsync(); + + await downloadUsersTask; + + var users = (await guild.GetUsersAsync(CacheMode.CacheOnly)) + .Cast() + .ToList(); + + var (added, updated) = await _service.RefreshUsersAsync(users); + + await message.ModifyAsync(x => + x.Embed = _sender.CreateEmbed() + .WithDescription(GetText(strs.cache_users_done(added, updated))) + .WithOkColor() + .Build() + ); + } + + [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 Response().Error(strs.error_occured).SendAsync(); + } + } + + [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 Response() + .Embed(_sender.CreateEmbed() + .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)) + .SendAsync(); + } + + [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 Response().Confirm(strs.autocmd_add(Format.Code(Format.Sanitize(cmdText)), cmd.Interval)).SendAsync(); + } + + [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 Response().Error(strs.startcmdlist_none).SendAsync(); + else + { + var i = 0; + await Response() + .Confirm(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))) + .SendAsync(); + } + } + + [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 Response().Error(strs.autocmdlist_none).SendAsync(); + else + { + var i = 0; + await Response() + .Confirm(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))) + .SendAsync(); + } + } + + 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 Response().Confirm($"⏲ {miliseconds}ms").SendAsync(); + 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 Response().Error(strs.acrm_fail).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task StartupCommandRemove([Leftover] int index) + { + if (!_service.RemoveStartupCommand(--index, out _)) + await Response().Error(strs.scrm_fail).SendAsync(); + else + await Response().Confirm(strs.scrm).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task StartupCommandsClear() + { + _service.ClearStartupCommands(); + + await Response().Confirm(strs.startcmds_cleared).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ForwardMessages() + { + var enabled = _service.ForwardMessages(); + + if (enabled) + await Response().Confirm(strs.fwdm_start).SendAsync(); + else + await Response().Pending(strs.fwdm_stop).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ForwardToAll() + { + var enabled = _service.ForwardToAll(); + + if (enabled) + await Response().Confirm(strs.fwall_start).SendAsync(); + else + await Response().Pending(strs.fwall_stop).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task ForwardToChannel() + { + var enabled = _service.ForwardToChannel(ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.fwch_start).SendAsync(); + else + await Response().Pending(strs.fwch_stop).SendAsync(); + } + + [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 Response() + .Paginated() + .Items(allShardStrings) + .PageSize(25) + .CurrentPage(page) + .Page((items, _) => + { + var str = string.Join("\n", items); + + if (string.IsNullOrWhiteSpace(str)) + str = GetText(strs.no_shards_on_page); + + return _sender.CreateEmbed().WithOkColor().WithDescription($"{status}\n\n{str}"); + }) + .SendAsync(); + } + + 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 Response().Confirm(strs.shard_reconnecting(Format.Bold("#" + shardId))).SendAsync(); + else + await Response().Error(strs.no_shard_id).SendAsync(); + } + + [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 Response().Confirm(strs.deleted_x_servers(toLeave.Count)).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task Die(bool graceful = false) + { + try + { + await _client.SetStatusAsync(UserStatus.Invisible); + _ = _client.StopAsync(); + await Response().Confirm(strs.shutting_down).SendAsync(); + } + catch + { + // ignored + } + + await Task.Delay(2000); + _coord.Die(graceful); + } + + [Cmd] + [OwnerOnly] + public async Task Restart() + { + var success = _coord.RestartBot(); + if (!success) + { + await Response().Error(strs.restart_fail).SendAsync(); + return; + } + + try { await Response().Confirm(strs.restarting).SendAsync(); } + 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 Response().Confirm(strs.bot_name(Format.Bold(newName))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetStatus([Leftover] SettableUserStatus status) + { + await _client.SetStatusAsync(SettableUserStatusToUserStatus(status)); + + await Response().Confirm(strs.bot_status(Format.Bold(status.ToString()))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetAvatar([Leftover] string img = null) + { + var success = await _service.SetAvatar(img); + + if (success) + await Response().Confirm(strs.set_avatar).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetBanner([Leftover] string img = null) + { + var success = await _service.SetBanner(img); + + if (success) + await Response().Confirm(strs.set_banner).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetActivity(ActivityType? type, [Leftover] string game = null) + { + // var rep = new ReplacementBuilder().WithDefault(Context).Build(); + + var repCtx = new ReplacementContext(ctx); + await _service.SetActivityAsync(game is null ? game : await repSvc.ReplaceAsync(game, repCtx), type); + + await Response().Confirm(strs.set_activity).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public Task SetActivity([Leftover] string game = null) + => SetActivity(null, game); + + public class SetActivityOptions + { + public ActivityType? Type { get; set; } + public string Game { get; set; } + } + + [Cmd] + [OwnerOnly] + public async Task SetStream(string url, [Leftover] string name = null) + { + name ??= ""; + + await _service.SetStreamAsync(name, url); + + await Response().Confirm(strs.set_stream).SendAsync(); + } + + public enum SendWhere + { + User = 0, + U = 0, + Usr = 0, + + Channel = 1, + Ch = 1, + Chan = 1, + } + + [Cmd] + [OwnerOnly] + public async Task Send(SendWhere to, ulong id, [Leftover] SmartText text) + { + var ch = to switch + { + SendWhere.User => await ((await _client.Rest.GetUserAsync(id))?.CreateDMChannelAsync() + ?? Task.FromResult(null)), + SendWhere.Channel => await _client.Rest.GetChannelAsync(id) as IMessageChannel, + _ => null + }; + + if (ch is null) + { + await Response().Error(strs.invalid_format).SendAsync(); + return; + } + + + var repCtx = new ReplacementContext(ctx); + text = await repSvc.ReplaceAsync(text, repCtx); + await Response().Channel(ch).Text(text).SendAsync(); + + await ctx.OkAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task StringsReload() + { + _strings.Reload(); + await _marmaladeLoader.ReloadStrings(); + await Response().Confirm(strs.bot_strings_reloaded).SendAsync(); + } + + [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/EllieBot/Modules/Administration/Self/SelfService.cs b/src/EllieBot/Modules/Administration/Self/SelfService.cs new file mode 100644 index 0000000..bbed3f7 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Self/SelfService.cs @@ -0,0 +1,494 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using System.Collections.Immutable; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.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 IMessageSenderService _sender; + + //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, + IMessageSenderService sender) + { + _cmdHandler = cmdHandler; + _db = db; + _strings = strings; + _client = client; + _creds = creds; + _httpFactory = factory; + _bss = bss; + _pubSub = pubSub; + _sender = sender; + _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 _sender.Response(ownerCh).Confirm(title, toSend).SendAsync(); + } + 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 _sender.Response(ch).Confirm(title, toSend).SendAsync(); + } + catch + { + Log.Warning("Error forwarding message to the channel"); + } + } + else + { + var firstOwnerChannel = ownerChannels.Values.First(); + if (firstOwnerChannel.Recipient.Id != msg.Author.Id) + { + try + { + await _sender.Response(firstOwnerChannel).Confirm(title, toSend).SendAsync(); + } + 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 async Task SetBanner(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; + } + + if (sr.GetContentLength() > 8.Megabytes()) + { + return false; + } + + await using var imageStream = await sr.Content.ReadAsStreamAsync(); + + await _client.CurrentUser.ModifyAsync(x => x.Banner = new Image(imageStream)); + 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 + { + if (data.Type is { } activityType) + await _client.SetGameAsync(data.Name, data.Link, activityType); + else + await _client.SetCustomStatusAsync(data.Name); + } + catch (Exception ex) + { + Log.Warning(ex, "Error setting activity"); + } + }); + + public Task SetActivityAsync(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; } + } + + + /// + /// Adds the specified to the database. If a database user with placeholder name + /// and discriminator is present in , their name and discriminator get updated accordingly. + /// + /// This database context. + /// The users to add or update in the database. + /// A tuple with the amount of new users added and old users updated. + public async Task<(long UsersAdded, long UsersUpdated)> RefreshUsersAsync(List users) + { + await using var ctx = _db.GetDbContext(); + var presentDbUsers = await ctx.GetTable() + .Select(x => new + { + x.UserId, + x.Username, + x.Discriminator + }) + .Where(x => users.Select(y => y.Id).Contains(x.UserId)) + .ToArrayAsyncEF(); + + var usersToAdd = users + .Where(x => !presentDbUsers.Select(x => x.UserId).Contains(x.Id)) + .Select(x => new DiscordUser() + { + UserId = x.Id, + AvatarId = x.AvatarId, + Username = x.Username, + Discriminator = x.Discriminator + }); + + var added = (await ctx.BulkCopyAsync(usersToAdd)).RowsCopied; + var toUpdateUserIds = presentDbUsers + .Where(x => x.Username == "Unknown" && x.Discriminator == "????") + .Select(x => x.UserId) + .ToArray(); + + foreach (var user in users.Where(x => toUpdateUserIds.Contains(x.Id))) + { + await ctx.GetTable() + .Where(x => x.UserId == user.Id) + .UpdateAsync(x => new DiscordUser() + { + Username = user.Username, + Discriminator = user.Discriminator, + + // .award tends to set AvatarId and DateAdded to NULL, so account for that. + AvatarId = user.AvatarId, + DateAdded = x.DateAdded ?? DateTime.UtcNow + }); + } + + return (added, toUpdateUserIds.Length); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs new file mode 100644 index 0000000..70581f7 --- /dev/null +++ b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs @@ -0,0 +1,239 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; +using System.Text; + +namespace EllieBot.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 Response().Confirm(strs.adsarm_enable(prefix)).SendAsync(); + else + await Response().Confirm(strs.adsarm_disable(prefix)).SendAsync(); + } + + [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 Response() + .Confirm(strs.role_added(Format.Bold(role.Name), Format.Bold(group.ToString()))) + .SendAsync(); + } + else + await Response().Error(strs.role_in_list(Format.Bold(role.Name))).SendAsync(); + } + + [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 Response() + .Confirm(strs.group_name_added(Format.Bold(group.ToString()), Format.Bold(name))) + .SendAsync(); + } + else + await Response().Confirm(strs.group_name_removed(Format.Bold(group.ToString()))).SendAsync(); + } + + [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 Response().Error(strs.self_assign_not).SendAsync(); + else + await Response().Confirm(strs.self_assign_rem(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Lsar(int page = 1) + { + if (--page < 0) + return; + + var (exclusive, roles, groups) = _service.GetRoles(ctx.Guild); + + await Response() + .Paginated() + .Items(roles.OrderBy(x => x.Model.Group).ToList()) + .PageSize(20) + .CurrentPage(page) + .Page((items, _) => + { + var rolesStr = new StringBuilder(); + var roleGroups = items + .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 _sender.CreateEmbed() + .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)); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task Togglexclsar() + { + var areExclusive = _service.ToggleEsar(ctx.Guild.Id); + if (areExclusive) + await Response().Confirm(strs.self_assign_excl).SendAsync(); + else + await Response().Confirm(strs.self_assign_no_excl).SendAsync(); + } + + [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 Response().Error(strs.self_assign_not).SendAsync(); + return; + } + + await Response() + .Confirm(strs.self_assign_level_req(Format.Bold(role.Name), + Format.Bold(level.ToString()))) + .SendAsync(); + } + + [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 Response().Error(strs.self_assign_not).SendAsync(); + else if (result == SelfAssignedRolesService.AssignResult.ErrLvlReq) + msg = await Response().Error(strs.self_assign_not_level(Format.Bold(extra.ToString()))).SendAsync(); + else if (result == SelfAssignedRolesService.AssignResult.ErrAlreadyHave) + msg = await Response().Error(strs.self_assign_already(Format.Bold(role.Name))).SendAsync(); + else if (result == SelfAssignedRolesService.AssignResult.ErrNotPerms) + msg = await Response().Error(strs.self_assign_perms).SendAsync(); + else + msg = await Response().Confirm(strs.self_assign_success(Format.Bold(role.Name))).SendAsync(); + + 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 Response().Error(strs.self_assign_not).SendAsync(); + else if (result == SelfAssignedRolesService.RemoveResult.ErrNotHave) + msg = await Response().Error(strs.self_assign_not_have(Format.Bold(role.Name))).SendAsync(); + else if (result == SelfAssignedRolesService.RemoveResult.ErrNotPerms) + msg = await Response().Error(strs.self_assign_perms).SendAsync(); + else + msg = await Response().Confirm(strs.self_assign_remove(Format.Bold(role.Name))).SendAsync(); + + if (autoDelete) + { + msg.DeleteAfter(3); + ctx.Message.DeleteAfter(3); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs new file mode 100644 index 0000000..505f56c --- /dev/null +++ b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs @@ -0,0 +1,233 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.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/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs b/src/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs new file mode 100644 index 0000000..00576b7 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs @@ -0,0 +1,25 @@ +using EllieBot.Db.Models; + +namespace EllieBot.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/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs new file mode 100644 index 0000000..3b6cd3e --- /dev/null +++ b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs @@ -0,0 +1,1296 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; + +namespace EllieBot.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 IMemoryCache _memoryCache; + + private readonly ConcurrentHashSet _ignoreMessageIds = []; + private readonly UserPunishService _punishService; + private readonly IMessageSenderService _sender; + + public LogCommandService( + DiscordSocketClient client, + IBotStrings strings, + DbService db, + MuteService mute, + ProtectionService prot, + GuildTimezoneService tz, + IMemoryCache memoryCache, + UserPunishService punishService, + IMessageSenderService sender) + { + _client = client; + _memoryCache = memoryCache; + _sender = sender; + _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.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 + || logSetting.LogIgnores.Any(ilc => ilc.LogItemId == gu.Id && ilc.ItemType == IgnoredItemType.User)) + 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, + [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, + [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 _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🗑 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + 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 _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + 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(channel => + { + if (!((SocketGuild)channel.Guild).CurrentUser.GetPermissions(channel).SendMessages) + return Task.CompletedTask; + + if (PresenceUpdates.TryRemove(channel, out var msgs)) + { + var title = GetText(channel.Guild, strs.presence_updates); + var desc = string.Join(Environment.NewLine, msgs); + return _sender.Response(channel).Confirm(title, desc.TrimTo(2048)!).SendAsync(); + } + + 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.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 = _sender.CreateEmbed() + .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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + + 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 || logSetting.LogIgnores.Any(ilc => ilc.LogItemId == after.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(g, logSetting, LogType.UserUpdated)) is null) + return; + + var embed = _sender.CreateEmbed(); + + if (before.Username != after.Username) + { + embed.WithTitle("👥 " + GetText(g, strs.username_changed)) + .WithDescription($"{before.Username} | {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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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.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 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 = _sender.CreateEmbed() + .WithAuthor(mutes) + .WithTitle($"{usr.Username}#{usr.Discriminator} | {usr.Id}") + .WithFooter(CurrentTime(usr.Guild)) + .WithOkColor(); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 = _sender.CreateEmbed() + .WithAuthor(mutes) + .WithTitle($"{usr.Username}#{usr.Discriminator} | {usr.Id}") + .WithFooter($"{CurrentTime(usr.Guild)}") + .WithOkColor(); + + if (!string.IsNullOrWhiteSpace(reason)) + embed.WithDescription(reason); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 = _sender.CreateEmbed() + .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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 = _sender.CreateEmbed() + .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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + } + } + } + } + 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 = _sender.CreateEmbed().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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + 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 _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + 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), + 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), + 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), + Format.Bold(beforeVch.Name ?? ""))); + } + + if (!string.IsNullOrWhiteSpace(str)) + { + PresenceUpdates.AddOrUpdate(logChannel, + [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 = _sender.CreateEmbed() + .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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 = _sender.CreateEmbed() + .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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 = _sender.CreateEmbed() + .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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 = _sender.CreateEmbed() + .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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 = _sender.CreateEmbed() + .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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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 = _sender.CreateEmbed() + .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 _sender.Response(logChannel).Embed(embed).SendAsync(); + } + 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.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.UserWarned: + newLogSetting.LogWarnsId = null; + break; + } + + GuildLogSettings.AddOrUpdate(guildId, newLogSetting, (_, _) => newLogSetting); + uow.SaveChanges(); + } +} diff --git a/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs new file mode 100644 index 0000000..1632da5 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs @@ -0,0 +1,175 @@ +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Db.Models; + +namespace EllieBot.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 Response().Confirm(strs.log_all).SendAsync(); + else + await Response().Confirm(strs.log_disabled).SendAsync(); + } + + [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 = _sender.CreateEmbed() + .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 Response().Embed(eb).SendAsync(); + } + + [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 Response() + .Confirm( + strs.log_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + else + { + await Response() + .Confirm( + strs.log_not_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + } + + [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 Response() + .Confirm(strs.log_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.log_not_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + } + + [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 Response().Confirm(Format.Bold(GetText(strs.log_events)) + "\n" + str).SendAsync(); + } + + 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.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 Response().Confirm(strs.log(Format.Bold(type.ToString()))).SendAsync(); + else + await Response().Confirm(strs.log_stop(Format.Bold(type.ToString()))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Ticket/TicketCommands.cs b/src/EllieBot/Modules/Administration/Ticket/TicketCommands.cs new file mode 100644 index 0000000..67f5ca6 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Ticket/TicketCommands.cs @@ -0,0 +1,14 @@ +// namespace EllieBot.Modules.Administration; +// +// public partial class Administration +// { +// [Group] +// public partial class TicketCommands : EllieModule +// { +// [Cmd] +// public async Task Ticket() +// { +// +// } +// } +// } \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs b/src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs new file mode 100644 index 0000000..f36d7af --- /dev/null +++ b/src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs @@ -0,0 +1,94 @@ +#nullable disable +using EllieBot.Db.Models; +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class GuildTimezoneService : ITimezoneService, IReadyExecutor, IEService +{ + private readonly ConcurrentDictionary _timezones; + private readonly DbService _db; + private readonly IReplacementPatternStore _repStore; + + public GuildTimezoneService(IBot bot, DbService db, IReplacementPatternStore repStore) + { + _timezones = bot.AllGuildConfigs.Select(GetTimzezoneTuple) + .Where(x => x.Timezone is not null) + .ToDictionary(x => x.GuildId, x => x.Timezone) + .ToConcurrent(); + + _db = db; + _repStore = repStore; + + 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; + + public Task OnReadyAsync() + { + _repStore.Register("%server.time%", + (IGuild g) => + { + var to = TimeZoneInfo.Local; + if (g is not null) + { + to = GetTimeZoneOrDefault(g.Id) ?? TimeZoneInfo.Local; + } + + return TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.Utc, to).ToString("HH:mm ") + + to.StandardName.GetInitials(); + }); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs b/src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs new file mode 100644 index 0000000..a05789d --- /dev/null +++ b/src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs @@ -0,0 +1,78 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.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}"; + }) + .ToList(); + + + await Response() + .Paginated() + .Items(timezoneStrings) + .PageSize(timezonesPerPage) + .CurrentPage(page) + .Page((items, _) => _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.timezones_available)) + .WithDescription(string.Join("\n", items))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Timezone() + => await Response().Confirm(strs.timezone_guild(_service.GetTimeZoneOrUtc(ctx.Guild.Id))).SendAsync(); + + [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 Response().Error(strs.timezone_not_found).SendAsync(); + return; + } + + _service.SetTimeZone(ctx.Guild.Id, tz); + + await Response().Confirm(tz.ToString()).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs b/src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs new file mode 100644 index 0000000..689b743 --- /dev/null +++ b/src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs @@ -0,0 +1,986 @@ +#nullable disable +using CommandLine; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; + +namespace EllieBot.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 Response().Error(strs.hierarchy).SendAsync(); + 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 _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithErrorColor() + .WithDescription(GetText(strs.warned_on(ctx.Guild.ToString()))) + .AddField(GetText(strs.moderator), ctx.User.ToString()) + .AddField(GetText(strs.reason), reason ?? "-")) + .SendAsync(); + } + 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 = _sender.CreateEmbed().WithErrorColor() + .WithDescription(GetText(strs.cant_apply_punishment)); + + if (dmFailed) + errorEmbed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(errorEmbed).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().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 Response().Embed(embed).SendAsync(); + } + + [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 Response().Confirm(strs.warns_dont_expire).SendAsync(); + else + await Response().Error(strs.warns_expire_in(expireDays)).SendAsync(); + } + + [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 Response().Confirm(strs.warn_expire_reset).SendAsync(); + return; + } + + if (opts.Delete) + await Response().Confirm(strs.warn_expire_set_delete(Format.Bold(days.ToString()))).SendAsync(); + else + await Response().Confirm(strs.warn_expire_set_clear(Format.Bold(days.ToString()))).SendAsync(); + } + + [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 Response() + .Paginated() + .Items(allWarnings) + .PageSize(9) + .CurrentPage(inputPage) + .Page((warnings, page) => + { + var user = (ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString(); + var embed = _sender.CreateEmbed().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; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnlogAll(int page = 1) + { + if (--page < 0) + return; + var allWarnings = _service.WarnlogAll(ctx.Guild.Id); + + await Response() + .Paginated() + .Items(allWarnings) + .PageSize(15) + .CurrentPage(page) + .Page((warnings, _) => + { + var ws = warnings + .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 _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.warnings_list)) + .WithDescription(string.Join("\n", ws)); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public Task WarnDelete(IGuildUser user, int index) + => WarnDelete(user.Id, index); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task WarnDelete(ulong userId, int index) + { + if (--index < 0) + return; + + var warn = await _service.WarnDelete(userId, index); + + if (warn is null) + { + await Response().Error(strs.warning_not_found).SendAsync(); + return; + } + + await Response().Confirm(strs.warning_deleted(Format.Bold(index.ToString()))).SendAsync(); + } + + [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 Response().Error(strs.warnings_cleared(userStr)).SendAsync(); + else + { + if (success) + await Response().Confirm(strs.warning_cleared(Format.Bold(index.ToString()), userStr)).SendAsync(); + else + await Response().Error(strs.warning_clear_fail).SendAsync(); + } + } + + [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 Response().Error(strs.role_too_high).SendAsync(); + return; + } + + var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time, role); + + if (!success) + return; + + if (time is null) + { + await Response() + .Confirm(strs.warn_punish_set(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.warn_punish_set_timed(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()), + Format.Bold(time.Input))) + .SendAsync(); + } + } + + [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 Response() + .Confirm(strs.warn_punish_set(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.warn_punish_set_timed(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()), + Format.Bold(time.Input))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnPunish(int number) + { + if (!_service.WarnPunishRemove(ctx.Guild.Id, number)) + return; + + await Response().Confirm(strs.warn_punish_rem(Format.Bold(number.ToString()))).SendAsync(); + } + + [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 Response().Confirm(GetText(strs.warn_punish_list), list).SendAsync(); + } + + [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 smartText = + await _service.GetBanUserDmEmbed(Context, guildUser, defaultMessage, msg, time.Time); + if (smartText is not null) + await Response().User(guildUser).Text(smartText).SendAsync(); + } + 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 = _sender.CreateEmbed() + .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.ToPrettyStringHm(), + true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [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 Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField("ID", userId.ToString(), true)) + .SendAsync(); + } + 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 = await _service.GetBanUserDmEmbed(Context, user, defaultMessage, msg, null); + if (embed is not null) + await Response().User(user).Text(embed).SendAsync(); + } + 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 = _sender.CreateEmbed() + .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 Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanPrune(int days) + { + if (days < 0 || days > 7) + { + await Response().Error(strs.invalid_input).SendAsync(); + return; + } + + await _service.SetBanPruneAsync(ctx.Guild.Id, days); + + if (days == 0) + await Response().Confirm(strs.ban_prune_disabled).SendAsync(); + else + await Response().Confirm(strs.ban_prune(days)).SendAsync(); + } + + [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 Response().Confirm(strs.banmsg_default).SendAsync(); + return; + } + + await Response().Confirm(template).SendAsync(); + 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 smartText = await _service.GetBanUserDmEmbed(Context, + (IGuildUser)ctx.User, + defaultMessage, + reason, + duration); + + if (smartText is null) + await Response().Confirm(strs.banmsg_disabled).SendAsync(); + else + { + try + { + await Response().User(ctx.User).Text(smartText).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.unable_to_dm_user).SendAsync(); + 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 Response().Error(strs.user_not_found).SendAsync(); + 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 Response().Error(strs.user_not_found).SendAsync(); + return; + } + + await UnbanInternal(bun.User); + } + + private async Task UnbanInternal(IUser user) + { + await ctx.Guild.RemoveBanAsync(user); + + await Response().Confirm(strs.unbanned_user(Format.Bold(user.ToString()))).SendAsync(); + } + + [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 Response() + .Channel(await user.CreateDMChannelAsync()) + .Error(strs.sbdm(Format.Bold(ctx.Guild.Name), msg)) + .SendAsync(); + } + 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 = _sender.CreateEmbed() + .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 Response().Embed(toSend).SendAsync(); + } + + [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 Response() + .Channel(await user.CreateDMChannelAsync()) + .Error(GetText(strs.kickdm(Format.Bold(ctx.Guild.Name), msg))) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + await user.KickAsync((ctx.User + " | " + msg).TrimTo(512)); + + var toSend = _sender.CreateEmbed() + .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 Response().Embed(toSend).SendAsync(); + } + + [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 _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithPendingColor() + .WithDescription(dmMessage)) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + await user.SetTimeOutAsync(time.Time); + + var toSend = _sender.CreateEmbed() + .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 Response().Embed(toSend).SendAsync(); + } + + [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 = _sender.CreateEmbed() + .WithDescription(GetText(strs.mass_ban_in_progress(banning.Count))) + .AddField(GetText(strs.invalid(missing.Count)), missStr) + .WithPendingColor(); + + var banningMessage = await Response().Embed(toSend).SendAsync(); + + 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 = _sender.CreateEmbed() + .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 = Response() + .Embed(_sender.CreateEmbed() + .WithDescription( + GetText(strs.mass_kill_in_progress(bans.Count()))) + .AddField(GetText(strs.invalid(missing)), missStr) + .WithPendingColor()) + .SendAsync(); + + 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 = _sender.CreateEmbed() + .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/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs b/src/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs new file mode 100644 index 0000000..3069c33 --- /dev/null +++ b/src/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs @@ -0,0 +1,629 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; +using Newtonsoft.Json; + +namespace EllieBot.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; + private readonly IReplacementService _repSvc; + + public event Func OnUserWarned = static delegate { return Task.CompletedTask; }; + + public UserPunishService( + MuteService mute, + DbService db, + BlacklistService blacklistService, + BotConfigService bcs, + DiscordSocketClient client, + IReplacementService repSvc) + { + _mute = mute; + _db = db; + _blacklistService = blacklistService; + _bcs = bcs; + _client = client; + _repSvc = repSvc; + } + + 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) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(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 toClear = await uow.GetTable() + .Where(x => uow.GetTable() + .Count(y => y.GuildId == x.GuildId + && y.WarnExpireHours > 0 + && y.WarnExpireAction == WarnExpireAction.Clear) + > 0 + && x.Forgiven == false + && x.DateAdded + < DateTime.UtcNow.AddHours(-uow.GetTable() + .Where(y => x.GuildId == y.GuildId) + .Select(y => y.WarnExpireHours) + .First())) + .Select(x => x.Id) + .ToListAsyncLinqToDB(); + + var cleared = await uow.GetTable() + .Where(x => toClear.Contains(x.Id)) + .UpdateAsync(_ => new() + { + Forgiven = true, + ForgivenBy = "expiry" + }); + + var toDelete = await uow.GetTable() + .Where(x => uow.GetTable() + .Count(y => y.GuildId == x.GuildId + && y.WarnExpireHours > 0 + && y.WarnExpireAction == WarnExpireAction.Delete) + > 0 + && x.DateAdded + < DateTime.UtcNow.AddHours(-uow.GetTable() + .Where(y => x.GuildId == y.GuildId) + .Select(y => y.WarnExpireHours) + .First())) + .Select(x => x.Id) + .ToListAsyncLinqToDB(); + + var deleted = await uow.GetTable() + .Where(x => toDelete.Contains(x.Id)) + .DeleteAsync(); + + if (cleared > 0 || deleted > 0) + { + Log.Information("Cleared {ClearedWarnings} warnings and deleted {DeletedWarnings} warnings due to expiry", + cleared, + toDelete.Count); + } + } + + 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 Task 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 async Task 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 repCtx = new ReplacementContext(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"); + + + // 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 await _repSvc.ReplaceAsync(output, repCtx); + } + + public async Task WarnDelete(ulong userId, int index) + { + await using var uow = _db.GetDbContext(); + + var warn = await uow.GetTable() + .Where(x => x.UserId == userId) + .OrderByDescending(x => x.DateAdded) + .Skip(index) + .FirstOrDefaultAsyncLinqToDB(); + + if (warn is not null) + { + await uow.GetTable() + .Where(x => x.Id == warn.Id) + .DeleteAsync(); + } + + return warn; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs b/src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs new file mode 100644 index 0000000..a5f547a --- /dev/null +++ b/src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs @@ -0,0 +1,77 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.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 Response().Confirm(strs.vcrole_removed(Format.Bold(vcId.ToString()))).SendAsync(); + else + await Response().Error(strs.vcrole_not_found).SendAsync(); + } + + [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 Response().Error(strs.must_be_in_voice).SendAsync(); + return; + } + + if (role is null) + { + if (_service.RemoveVcRole(ctx.Guild.Id, vc.Id)) + await Response().Confirm(strs.vcrole_removed(Format.Bold(vc.Name))).SendAsync(); + } + else + { + _service.AddVcRole(ctx.Guild.Id, role, vc.Id); + await Response().Confirm(strs.vcrole_added(Format.Bold(vc.Name), Format.Bold(role.Name))).SendAsync(); + } + } + + [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 Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.vc_role_list)) + .WithDescription(text)).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs b/src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs new file mode 100644 index 0000000..0ba9feb --- /dev/null +++ b/src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs @@ -0,0 +1,207 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.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) + { + ArgumentNullException.ThrowIfNull(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 diff --git a/src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs b/src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs new file mode 100644 index 0000000..bd3eaab --- /dev/null +++ b/src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Administration._common.results; + +public enum SetServerBannerResult +{ + Success, + InvalidFileType, + Toolarge, + InvalidURL +} diff --git a/src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs b/src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs new file mode 100644 index 0000000..f0d0b1a --- /dev/null +++ b/src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Administration._common.results; + +public enum SetServerIconResult +{ + Success, + InvalidFileType, + InvalidURL +} \ No newline at end of file