From 5505052af4c6b3cbe9e32040f7668acef1787fe2 Mon Sep 17 00:00:00 2001 From: Toastie Date: Fri, 20 Sep 2024 23:24:21 +1200 Subject: [PATCH] Added permissions module --- .../Blacklist/BlacklistCommands.cs | 154 +++++ .../CleverbotResponseCmdCdTypeReader.cs | 15 + .../CommandCooldown/CmdCdService.cs | 141 +++++ .../CommandCooldown/CmdCdsCommands.cs | 106 ++++ .../Permissions/Filter/FilterCommands.cs | 326 +++++++++++ .../Permissions/Filter/FilterService.cs | 249 ++++++++ .../Filter/ServerFilterSettings.cs | 10 + .../GlobalPermissionCommands.cs | 77 +++ .../GlobalPermissionService.cs | 92 +++ .../Modules/Permissions/PermissionCache.cs | 11 + .../Permissions/PermissionExtensions.cs | 132 +++++ .../Modules/Permissions/Permissions.cs | 543 ++++++++++++++++++ .../Permissions/PermissionsCollection.cs | 74 +++ .../Modules/Permissions/PermissionsService.cs | 184 ++++++ .../Permissions/ResetPermissionsCommands.cs | 37 ++ 15 files changed, 2151 insertions(+) create mode 100644 src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs create mode 100644 src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs create mode 100644 src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs create mode 100644 src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs create mode 100644 src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs create mode 100644 src/EllieBot/Modules/Permissions/Filter/FilterService.cs create mode 100644 src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs create mode 100644 src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs create mode 100644 src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs create mode 100644 src/EllieBot/Modules/Permissions/PermissionCache.cs create mode 100644 src/EllieBot/Modules/Permissions/PermissionExtensions.cs create mode 100644 src/EllieBot/Modules/Permissions/Permissions.cs create mode 100644 src/EllieBot/Modules/Permissions/PermissionsCollection.cs create mode 100644 src/EllieBot/Modules/Permissions/PermissionsService.cs create mode 100644 src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs diff --git a/src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs b/src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs new file mode 100644 index 0000000..b552636 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs @@ -0,0 +1,154 @@ +#nullable disable +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class BlacklistCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public BlacklistCommands(DiscordSocketClient client) + => _client = client; + + private async Task ListBlacklistInternal(string title, BlacklistType type, int page = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(page); + + var list = _service.GetBlacklist(); + var allItems = await list.Where(x => x.Type == type) + .Select(i => + { + try + { + return Task.FromResult(i.Type switch + { + BlacklistType.Channel => Format.Code(i.ItemId.ToString()) + + " " + + (_client.GetChannel(i.ItemId)?.ToString() + ?? ""), + BlacklistType.User => Format.Code(i.ItemId.ToString()) + + " " + + ((_client.GetUser(i.ItemId)) + ?.ToString() + ?? ""), + BlacklistType.Server => Format.Code(i.ItemId.ToString()) + + " " + + (_client.GetGuild(i.ItemId)?.ToString() ?? ""), + _ => Format.Code(i.ItemId.ToString()) + }); + } + catch + { + Log.Warning("Can't get {BlacklistType} [{BlacklistItemId}]", + i.Type, + i.ItemId); + + return Task.FromResult(Format.Code(i.ItemId.ToString())); + } + }) + .WhenAll(); + + await Response() + .Paginated() + .Items(allItems) + .PageSize(10) + .CurrentPage(page) + .Page((pageItems, _) => + { + if (pageItems.Count == 0) + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(title) + .WithDescription(GetText(strs.empty_page)); + + return _sender.CreateEmbed() + .WithTitle(title) + .WithDescription(allItems.Join('\n')) + .WithOkColor(); + }) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public Task UserBlacklist(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ListBlacklistInternal(GetText(strs.blacklisted_users), BlacklistType.User, page); + } + + [Cmd] + [OwnerOnly] + public Task ChannelBlacklist(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ListBlacklistInternal(GetText(strs.blacklisted_channels), BlacklistType.Channel, page); + } + + [Cmd] + [OwnerOnly] + public Task ServerBlacklist(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ListBlacklistInternal(GetText(strs.blacklisted_servers), BlacklistType.Server, page); + } + + [Cmd] + [OwnerOnly] + public Task UserBlacklist(AddRemove action, ulong id) + => Blacklist(action, id, BlacklistType.User); + + [Cmd] + [OwnerOnly] + public Task UserBlacklist(AddRemove action, IUser usr) + => Blacklist(action, usr.Id, BlacklistType.User); + + [Cmd] + [OwnerOnly] + public Task ChannelBlacklist(AddRemove action, ulong id) + => Blacklist(action, id, BlacklistType.Channel); + + [Cmd] + [OwnerOnly] + public Task ServerBlacklist(AddRemove action, ulong id) + => Blacklist(action, id, BlacklistType.Server); + + [Cmd] + [OwnerOnly] + public Task ServerBlacklist(AddRemove action, IGuild guild) + => Blacklist(action, guild.Id, BlacklistType.Server); + + private async Task Blacklist(AddRemove action, ulong id, BlacklistType type) + { + if (action == AddRemove.Add) + await _service.Blacklist(type, id); + else + await _service.UnBlacklist(type, id); + + if (action == AddRemove.Add) + { + await Response() + .Confirm(strs.blacklisted(Format.Code(type.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.unblacklisted(Format.Code(type.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs b/src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs new file mode 100644 index 0000000..618ec59 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs @@ -0,0 +1,15 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using static EllieBot.Common.TypeReaders.TypeReaderResult; + +namespace EllieBot.Modules.Permissions; + +public class CleverbotResponseCmdCdTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync( + ICommandContext ctx, + string input) + => input.ToLowerInvariant() == CleverBotResponseStr.CLEVERBOT_RESPONSE + ? new(FromSuccess(new CleverBotResponseStr())) + : new(FromError(CommandError.ParseFailed, "Not a valid cleverbot")); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs new file mode 100644 index 0000000..55eb64d --- /dev/null +++ b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs @@ -0,0 +1,141 @@ +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Permissions.Services; + +public sealed class CmdCdService : IExecPreCommand, IReadyExecutor, IEService +{ + private readonly DbService _db; + private readonly ConcurrentDictionary> _settings = new(); + + private readonly ConcurrentDictionary<(ulong, string), ConcurrentDictionary> _activeCooldowns = + new(); + + public int Priority => 0; + + public CmdCdService(IBot bot, DbService db) + { + _db = db; + _settings = bot + .AllGuildConfigs + .ToDictionary(x => x.GuildId, x => x.CommandCooldowns + .DistinctBy(x => x.CommandName.ToLowerInvariant()) + .ToDictionary(c => c.CommandName, c => c.Seconds) + .ToConcurrent()) + .ToConcurrent(); + } + + public Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + => TryBlock(context.Guild, context.User, command.Name.ToLowerInvariant()); + + public Task TryBlock(IGuild? guild, IUser user, string commandName) + { + if (guild is null) + return Task.FromResult(false); + + if (!_settings.TryGetValue(guild.Id, out var cooldownSettings)) + return Task.FromResult(false); + + if (!cooldownSettings.TryGetValue(commandName, out var cdSeconds)) + return Task.FromResult(false); + + var cooldowns = _activeCooldowns.GetOrAdd( + (guild.Id, commandName), + static _ => new()); + + // if user is not already on cooldown, add + if (cooldowns.TryAdd(user.Id, DateTime.UtcNow)) + { + return Task.FromResult(false); + } + + // if there is an entry, maybe it expired. Try to check if it expired and don't fail if it did + // - just update + if (cooldowns.TryGetValue(user.Id, out var oldValue)) + { + var diff = DateTime.UtcNow - oldValue; + if (diff.TotalSeconds > cdSeconds) + { + if (cooldowns.TryUpdate(user.Id, DateTime.UtcNow, oldValue)) + return Task.FromResult(false); + } + } + + return Task.FromResult(true); + } + + public async Task OnReadyAsync() + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + + while (await timer.WaitForNextTickAsync()) + { + // once per hour delete expired entries + foreach (var ((guildId, commandName), dict) in _activeCooldowns) + { + // if this pair no longer has associated config, that means it has been removed. + // remove all cooldowns + if (!_settings.TryGetValue(guildId, out var inner) + || !inner.TryGetValue(commandName, out var cdSeconds)) + { + _activeCooldowns.Remove((guildId, commandName), out _); + continue; + } + + Cleanup(dict, cdSeconds); + } + } + } + + private void Cleanup(ConcurrentDictionary dict, int cdSeconds) + { + var now = DateTime.UtcNow; + foreach (var (key, _) in dict.Where(x => (now - x.Value).TotalSeconds > cdSeconds).ToArray()) + { + dict.TryRemove(key, out _); + } + } + + public void ClearCooldowns(ulong guildId, string cmdName) + { + if (_settings.TryGetValue(guildId, out var dict)) + dict.TryRemove(cmdName, out _); + + _activeCooldowns.TryRemove((guildId, cmdName), out _); + + using var ctx = _db.GetDbContext(); + var gc = ctx.GuildConfigsForId(guildId, x => x.Include(x => x.CommandCooldowns)); + gc.CommandCooldowns.RemoveWhere(x => x.CommandName == cmdName); + ctx.SaveChanges(); + } + + public void AddCooldown(ulong guildId, string name, int secs) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(secs); + + var sett = _settings.GetOrAdd(guildId, static _ => new()); + sett[name] = secs; + + // force cleanup + if (_activeCooldowns.TryGetValue((guildId, name), out var dict)) + Cleanup(dict, secs); + + using var ctx = _db.GetDbContext(); + var gc = ctx.GuildConfigsForId(guildId, x => x.Include(x => x.CommandCooldowns)); + gc.CommandCooldowns.RemoveWhere(x => x.CommandName == name); + gc.CommandCooldowns.Add(new() + { + Seconds = secs, + CommandName = name + }); + ctx.SaveChanges(); + } + + public IReadOnlyCollection<(string CommandName, int Seconds)> GetCommandCooldowns(ulong guildId) + { + if (!_settings.TryGetValue(guildId, out var dict)) + return Array.Empty<(string, int)>(); + + return dict.Select(x => (x.Key, x.Value)).ToArray(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs new file mode 100644 index 0000000..a9b7a13 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs @@ -0,0 +1,106 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class CmdCdsCommands : EllieModule + { + private readonly DbService _db; + private readonly CmdCdService _service; + + public CmdCdsCommands(CmdCdService service, DbService db) + { + _service = service; + _db = db; + } + + private async Task CmdCooldownInternal(string cmdName, int secs) + { + var channel = (ITextChannel)ctx.Channel; + if (secs is < 0 or > 3600) + { + await Response().Error(strs.invalid_second_param_between(0, 3600)).SendAsync(); + return; + } + + var name = cmdName.ToLowerInvariant(); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.CommandCooldowns)); + + var toDelete = config.CommandCooldowns.FirstOrDefault(cc => cc.CommandName == name); + if (toDelete is not null) + uow.Set().Remove(toDelete); + if (secs != 0) + { + var cc = new CommandCooldown + { + CommandName = name, + Seconds = secs + }; + config.CommandCooldowns.Add(cc); + _service.AddCooldown(channel.Guild.Id, name, secs); + } + + await uow.SaveChangesAsync(); + } + + if (secs == 0) + { + _service.ClearCooldowns(ctx.Guild.Id, cmdName); + await Response().Confirm(strs.cmdcd_cleared(Format.Bold(name))).SendAsync(); + } + else + await Response().Confirm(strs.cmdcd_add(Format.Bold(name), Format.Bold(secs.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task CmdCooldown(CleverBotResponseStr command, int secs) + => CmdCooldownInternal(CleverBotResponseStr.CLEVERBOT_RESPONSE, secs); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task CmdCooldown(CommandOrExprInfo command, int secs) + => CmdCooldownInternal(command.Name, secs); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllCmdCooldowns(int page = 1) + { + if (--page < 0) + return; + + var localSet = _service.GetCommandCooldowns(ctx.Guild.Id); + + if (!localSet.Any()) + await Response().Confirm(strs.cmdcd_none).SendAsync(); + else + { + await Response() + .Paginated() + .Items(localSet) + .PageSize(15) + .CurrentPage(page) + .Page((items, _) => + { + var output = items.Select(x => + $"{Format.Code(x.CommandName)}: {x.Seconds}s"); + + return _sender.CreateEmbed() + .WithOkColor() + .WithDescription(output.Join("\n")); + }) + .SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs b/src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs new file mode 100644 index 0000000..fab1a11 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs @@ -0,0 +1,326 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class FilterCommands : EllieModule + { + private readonly DbService _db; + + public FilterCommands(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task FwClear() + { + _service.ClearFilteredWords(ctx.Guild.Id); + await Response().Confirm(strs.fw_cleared).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task FilterList() + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("Server filter settings"); + + var config = await _service.GetFilterSettings(ctx.Guild.Id); + + string GetEnabledEmoji(bool value) + => value ? "\\🟢" : "\\🔴"; + + async Task GetChannelListAsync(IReadOnlyCollection channels) + { + var toReturn = (await channels + .Select(async cid => + { + var ch = await ctx.Guild.GetChannelAsync(cid); + return ch is null + ? $"{cid} *missing*" + : $"<#{cid}>"; + }) + .WhenAll()) + .Join('\n'); + + if (string.IsNullOrWhiteSpace(toReturn)) + return GetText(strs.no_channel_found); + + return toReturn; + } + + embed.AddField($"{GetEnabledEmoji(config.FilterLinksEnabled)} Filter Links", + await GetChannelListAsync(config.FilterLinksChannels)); + + embed.AddField($"{GetEnabledEmoji(config.FilterInvitesEnabled)} Filter Invites", + await GetChannelListAsync(config.FilterInvitesChannels)); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrFilterInv() + { + var channel = (ITextChannel)ctx.Channel; + + bool enabled; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set); + enabled = config.FilterInvites = !config.FilterInvites; + await uow.SaveChangesAsync(); + } + + if (enabled) + { + _service.InviteFilteringServers.Add(channel.Guild.Id); + await Response().Confirm(strs.invite_filter_server_on).SendAsync(); + } + else + { + _service.InviteFilteringServers.TryRemove(channel.Guild.Id); + await Response().Confirm(strs.invite_filter_server_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlFilterInv() + { + var channel = (ITextChannel)ctx.Channel; + + FilterChannelId removed; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, + set => set.Include(gc => gc.FilterInvitesChannelIds)); + var match = new FilterChannelId + { + ChannelId = channel.Id + }; + removed = config.FilterInvitesChannelIds.FirstOrDefault(fc => fc.Equals(match)); + + if (removed is null) + config.FilterInvitesChannelIds.Add(match); + else + uow.Remove(removed); + await uow.SaveChangesAsync(); + } + + if (removed is null) + { + _service.InviteFilteringChannels.Add(channel.Id); + await Response().Confirm(strs.invite_filter_channel_on).SendAsync(); + } + else + { + _service.InviteFilteringChannels.TryRemove(channel.Id); + await Response().Confirm(strs.invite_filter_channel_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrFilterLin() + { + var channel = (ITextChannel)ctx.Channel; + + bool enabled; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set); + enabled = config.FilterLinks = !config.FilterLinks; + await uow.SaveChangesAsync(); + } + + if (enabled) + { + _service.LinkFilteringServers.Add(channel.Guild.Id); + await Response().Confirm(strs.link_filter_server_on).SendAsync(); + } + else + { + _service.LinkFilteringServers.TryRemove(channel.Guild.Id); + await Response().Confirm(strs.link_filter_server_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlFilterLin() + { + var channel = (ITextChannel)ctx.Channel; + + FilterLinksChannelId removed; + await using (var uow = _db.GetDbContext()) + { + var config = + uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.FilterLinksChannelIds)); + var match = new FilterLinksChannelId + { + ChannelId = channel.Id + }; + removed = config.FilterLinksChannelIds.FirstOrDefault(fc => fc.Equals(match)); + + if (removed is null) + config.FilterLinksChannelIds.Add(match); + else + uow.Remove(removed); + await uow.SaveChangesAsync(); + } + + if (removed is null) + { + _service.LinkFilteringChannels.Add(channel.Id); + await Response().Confirm(strs.link_filter_channel_on).SendAsync(); + } + else + { + _service.LinkFilteringChannels.TryRemove(channel.Id); + await Response().Confirm(strs.link_filter_channel_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrFilterWords() + { + var channel = (ITextChannel)ctx.Channel; + + bool enabled; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set); + enabled = config.FilterWords = !config.FilterWords; + await uow.SaveChangesAsync(); + } + + if (enabled) + { + _service.WordFilteringServers.Add(channel.Guild.Id); + await Response().Confirm(strs.word_filter_server_on).SendAsync(); + } + else + { + _service.WordFilteringServers.TryRemove(channel.Guild.Id); + await Response().Confirm(strs.word_filter_server_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlFilterWords() + { + var channel = (ITextChannel)ctx.Channel; + + FilterWordsChannelId removed; + await using (var uow = _db.GetDbContext()) + { + var config = + uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.FilterWordsChannelIds)); + + var match = new FilterWordsChannelId + { + ChannelId = channel.Id + }; + removed = config.FilterWordsChannelIds.FirstOrDefault(fc => fc.Equals(match)); + if (removed is null) + config.FilterWordsChannelIds.Add(match); + else + uow.Remove(removed); + await uow.SaveChangesAsync(); + } + + if (removed is null) + { + _service.WordFilteringChannels.Add(channel.Id); + await Response().Confirm(strs.word_filter_channel_on).SendAsync(); + } + else + { + _service.WordFilteringChannels.TryRemove(channel.Id); + await Response().Confirm(strs.word_filter_channel_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task FilterWord([Leftover] string word) + { + var channel = (ITextChannel)ctx.Channel; + + word = word?.Trim().ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(word)) + return; + + FilteredWord removed; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.FilteredWords)); + + removed = config.FilteredWords.FirstOrDefault(fw => fw.Word.Trim().ToLowerInvariant() == word); + + if (removed is null) + { + config.FilteredWords.Add(new() + { + Word = word + }); + } + else + uow.Remove(removed); + + await uow.SaveChangesAsync(); + } + + var filteredWords = + _service.ServerFilteredWords.GetOrAdd(channel.Guild.Id, new ConcurrentHashSet()); + + if (removed is null) + { + filteredWords.Add(word); + await Response().Confirm(strs.filter_word_add(Format.Code(word))).SendAsync(); + } + else + { + filteredWords.TryRemove(word); + await Response().Confirm(strs.filter_word_remove(Format.Code(word))).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task LstFilterWords(int page = 1) + { + page--; + if (page < 0) + return; + + var channel = (ITextChannel)ctx.Channel; + + _service.ServerFilteredWords.TryGetValue(channel.Guild.Id, out var fwHash); + + var fws = fwHash.ToArray(); + + await Response() + .Paginated() + .Items(fws) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => _sender.CreateEmbed() + .WithTitle(GetText(strs.filter_word_list)) + .WithDescription(string.Join("\n", items)) + .WithOkColor()) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Filter/FilterService.cs b/src/EllieBot/Modules/Permissions/Filter/FilterService.cs new file mode 100644 index 0000000..dd5fbc0 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Filter/FilterService.cs @@ -0,0 +1,249 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Services; + +public sealed class FilterService : IExecOnMessage +{ + public ConcurrentHashSet InviteFilteringChannels { get; } + public ConcurrentHashSet InviteFilteringServers { get; } + + //serverid, filteredwords + public ConcurrentDictionary> ServerFilteredWords { get; } + + public ConcurrentHashSet WordFilteringChannels { get; } + public ConcurrentHashSet WordFilteringServers { get; } + + public ConcurrentHashSet LinkFilteringChannels { get; } + public ConcurrentHashSet LinkFilteringServers { get; } + + public int Priority + => int.MaxValue - 1; + + private readonly DbService _db; + + public FilterService(DiscordSocketClient client, DbService db) + { + _db = db; + + using (var uow = db.GetDbContext()) + { + var ids = client.GetGuildIds(); + var configs = uow.Set() + .AsQueryable() + .Include(x => x.FilteredWords) + .Include(x => x.FilterLinksChannelIds) + .Include(x => x.FilterWordsChannelIds) + .Include(x => x.FilterInvitesChannelIds) + .Where(gc => ids.Contains(gc.GuildId)) + .ToList(); + + InviteFilteringServers = new(configs.Where(gc => gc.FilterInvites).Select(gc => gc.GuildId)); + InviteFilteringChannels = + new(configs.SelectMany(gc => gc.FilterInvitesChannelIds.Select(fci => fci.ChannelId))); + + LinkFilteringServers = new(configs.Where(gc => gc.FilterLinks).Select(gc => gc.GuildId)); + LinkFilteringChannels = + new(configs.SelectMany(gc => gc.FilterLinksChannelIds.Select(fci => fci.ChannelId))); + + var dict = configs.ToDictionary(gc => gc.GuildId, + gc => new ConcurrentHashSet(gc.FilteredWords.Select(fw => fw.Word).Distinct())); + + ServerFilteredWords = new(dict); + + var serverFiltering = configs.Where(gc => gc.FilterWords); + WordFilteringServers = new(serverFiltering.Select(gc => gc.GuildId)); + WordFilteringChannels = + new(configs.SelectMany(gc => gc.FilterWordsChannelIds.Select(fwci => fwci.ChannelId))); + } + + client.MessageUpdated += (oldData, newMsg, channel) => + { + _ = Task.Run(() => + { + var guild = (channel as ITextChannel)?.Guild; + + if (guild is null || newMsg is not IUserMessage usrMsg) + return Task.CompletedTask; + + return ExecOnMessageAsync(guild, usrMsg); + }); + return Task.CompletedTask; + }; + } + + public ConcurrentHashSet FilteredWordsForChannel(ulong channelId, ulong guildId) + { + var words = new ConcurrentHashSet(); + if (WordFilteringChannels.Contains(channelId)) + ServerFilteredWords.TryGetValue(guildId, out words); + return words; + } + + public void ClearFilteredWords(ulong guildId) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.FilteredWords).Include(x => x.FilterWordsChannelIds)); + + WordFilteringServers.TryRemove(guildId); + ServerFilteredWords.TryRemove(guildId, out _); + + foreach (var c in gc.FilterWordsChannelIds) + WordFilteringChannels.TryRemove(c.ChannelId); + + gc.FilterWords = false; + gc.FilteredWords.Clear(); + gc.FilterWordsChannelIds.Clear(); + + uow.SaveChanges(); + } + + public ConcurrentHashSet FilteredWordsForServer(ulong guildId) + { + var words = new ConcurrentHashSet(); + if (WordFilteringServers.Contains(guildId)) + ServerFilteredWords.TryGetValue(guildId, out words); + return words; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + if (msg.Author is not IGuildUser gu || gu.GuildPermissions.Administrator) + return false; + + var results = await Task.WhenAll(FilterInvites(guild, msg), FilterWords(guild, msg), FilterLinks(guild, msg)); + + return results.Any(x => x); + } + + private async Task FilterWords(IGuild guild, IUserMessage usrMsg) + { + if (guild is null) + return false; + if (usrMsg is null) + return false; + + var filteredChannelWords = + FilteredWordsForChannel(usrMsg.Channel.Id, guild.Id) ?? new ConcurrentHashSet(); + var filteredServerWords = FilteredWordsForServer(guild.Id) ?? new ConcurrentHashSet(); + var wordsInMessage = usrMsg.Content.ToLowerInvariant().Split(' '); + if (filteredChannelWords.Count != 0 || filteredServerWords.Count != 0) + { + foreach (var word in wordsInMessage) + { + if (filteredChannelWords.Contains(word) || filteredServerWords.Contains(word)) + { + Log.Information("User {UserName} [{UserId}] used a filtered word in {ChannelId} channel", + usrMsg.Author.ToString(), + usrMsg.Author.Id, + usrMsg.Channel.Id); + + try + { + await usrMsg.DeleteAsync(); + } + catch (HttpException ex) + { + Log.Warning(ex, + "I do not have permission to filter words in channel with id {Id}", + usrMsg.Channel.Id); + } + + return true; + } + } + } + + return false; + } + + private async Task FilterInvites(IGuild guild, IUserMessage usrMsg) + { + if (guild is null) + return false; + if (usrMsg is null) + return false; + + // if user has manage messages perm, don't filter + if (usrMsg.Channel is ITextChannel ch && usrMsg.Author is IGuildUser gu && gu.GetPermissions(ch).ManageMessages) + return false; + + if ((InviteFilteringChannels.Contains(usrMsg.Channel.Id) || InviteFilteringServers.Contains(guild.Id)) + && usrMsg.Content.IsDiscordInvite()) + { + Log.Information("User {UserName} [{UserId}] sent a filtered invite to {ChannelId} channel", + usrMsg.Author.ToString(), + usrMsg.Author.Id, + usrMsg.Channel.Id); + + try + { + await usrMsg.DeleteAsync(); + return true; + } + catch (HttpException ex) + { + Log.Warning(ex, + "I do not have permission to filter invites in channel with id {Id}", + usrMsg.Channel.Id); + return true; + } + } + + return false; + } + + private async Task FilterLinks(IGuild guild, IUserMessage usrMsg) + { + if (guild is null) + return false; + if (usrMsg is null) + return false; + + // if user has manage messages perm, don't filter + if (usrMsg.Channel is ITextChannel ch && usrMsg.Author is IGuildUser gu && gu.GetPermissions(ch).ManageMessages) + return false; + + if ((LinkFilteringChannels.Contains(usrMsg.Channel.Id) || LinkFilteringServers.Contains(guild.Id)) + && usrMsg.Content.TryGetUrlPath(out _)) + { + Log.Information("User {UserName} [{UserId}] sent a filtered link to {ChannelId} channel", + usrMsg.Author.ToString(), + usrMsg.Author.Id, + usrMsg.Channel.Id); + + try + { + await usrMsg.DeleteAsync(); + return true; + } + catch (HttpException ex) + { + Log.Warning(ex, "I do not have permission to filter links in channel with id {Id}", usrMsg.Channel.Id); + return true; + } + } + + return false; + } + + public async Task GetFilterSettings(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set + .Include(x => x.FilterInvitesChannelIds) + .Include(x => x.FilterLinksChannelIds)); + + return new() + { + FilterInvitesChannels = gc.FilterInvitesChannelIds.Map(x => x.ChannelId), + FilterLinksChannels = gc.FilterLinksChannelIds.Map(x => x.ChannelId), + FilterInvitesEnabled = gc.FilterInvites, + FilterLinksEnabled = gc.FilterLinks, + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs b/src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs new file mode 100644 index 0000000..3c60a9f --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Permissions.Services; + +public readonly struct ServerFilterSettings +{ + public bool FilterInvitesEnabled { get; init; } + public bool FilterLinksEnabled { get; init; } + public IReadOnlyCollection FilterInvitesChannels { get; init; } + public IReadOnlyCollection FilterLinksChannels { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs new file mode 100644 index 0000000..d3abefd --- /dev/null +++ b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs @@ -0,0 +1,77 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Permissions.Services; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class GlobalPermissionCommands : EllieModule + { + private readonly GlobalPermissionService _service; + private readonly DbService _db; + + public GlobalPermissionCommands(GlobalPermissionService service, DbService db) + { + _service = service; + _db = db; + } + + [Cmd] + [OwnerOnly] + public async Task GlobalPermList() + { + var blockedModule = _service.BlockedModules; + var blockedCommands = _service.BlockedCommands; + if (!blockedModule.Any() && !blockedCommands.Any()) + { + await Response().Error(strs.lgp_none).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor(); + + if (blockedModule.Any()) + embed.AddField(GetText(strs.blocked_modules), string.Join("\n", _service.BlockedModules)); + + if (blockedCommands.Any()) + embed.AddField(GetText(strs.blocked_commands), string.Join("\n", _service.BlockedCommands)); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task GlobalModule(ModuleOrExpr module) + { + var moduleName = module.Name.ToLowerInvariant(); + + var added = _service.ToggleModule(moduleName); + + if (added) + { + await Response().Confirm(strs.gmod_add(Format.Bold(module.Name))).SendAsync(); + return; + } + + await Response().Confirm(strs.gmod_remove(Format.Bold(module.Name))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task GlobalCommand(CommandOrExprInfo cmd) + { + var commandName = cmd.Name.ToLowerInvariant(); + var added = _service.ToggleCommand(commandName); + + if (added) + { + await Response().Confirm(strs.gcmd_add(Format.Bold(cmd.Name))).SendAsync(); + return; + } + + await Response().Confirm(strs.gcmd_remove(Format.Bold(cmd.Name))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs new file mode 100644 index 0000000..42ad5d7 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs @@ -0,0 +1,92 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Permissions.Services; + +public class GlobalPermissionService : IExecPreCommand, IEService +{ + public int Priority { get; } = 0; + + public HashSet BlockedCommands + => _bss.Data.Blocked.Commands; + + public HashSet BlockedModules + => _bss.Data.Blocked.Modules; + + private readonly BotConfigService _bss; + + public GlobalPermissionService(BotConfigService bss) + => _bss = bss; + + + public Task ExecPreCommandAsync(ICommandContext ctx, string moduleName, CommandInfo command) + { + var settings = _bss.Data; + var commandName = command.Name.ToLowerInvariant(); + + if (commandName != "resetglobalperms" + && (settings.Blocked.Commands.Contains(commandName) + || settings.Blocked.Modules.Contains(moduleName.ToLowerInvariant()))) + return Task.FromResult(true); + + return Task.FromResult(false); + } + + /// + /// Toggles module blacklist + /// + /// Lowercase module name + /// Whether the module is added + public bool ToggleModule(string moduleName) + { + var added = false; + _bss.ModifyConfig(bs => + { + if (bs.Blocked.Modules.Add(moduleName)) + added = true; + else + { + bs.Blocked.Modules.Remove(moduleName); + added = false; + } + }); + + return added; + } + + /// + /// Toggles command blacklist + /// + /// Lowercase command name + /// Whether the command is added + public bool ToggleCommand(string commandName) + { + var added = false; + _bss.ModifyConfig(bs => + { + if (bs.Blocked.Commands.Add(commandName)) + added = true; + else + { + bs.Blocked.Commands.Remove(commandName); + added = false; + } + }); + + return added; + } + + /// + /// Resets all global permissions + /// + public Task Reset() + { + _bss.ModifyConfig(bs => + { + bs.Blocked.Commands.Clear(); + bs.Blocked.Modules.Clear(); + }); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionCache.cs b/src/EllieBot/Modules/Permissions/PermissionCache.cs new file mode 100644 index 0000000..75bd501 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionCache.cs @@ -0,0 +1,11 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Common; + +public class PermissionCache +{ + public string PermRole { get; set; } + public bool Verbose { get; set; } = true; + public PermissionsCollection Permissions { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionExtensions.cs b/src/EllieBot/Modules/Permissions/PermissionExtensions.cs new file mode 100644 index 0000000..776664e --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionExtensions.cs @@ -0,0 +1,132 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Common; + +public static class PermissionExtensions +{ + public static bool CheckPermissions( + this IEnumerable permsEnumerable, + IUser user, + IMessageChannel message, + string commandName, + string moduleName, + out int permIndex) + { + var perms = permsEnumerable as List ?? permsEnumerable.ToList(); + + for (var i = perms.Count - 1; i >= 0; i--) + { + var perm = perms[i]; + + var result = perm.CheckPermission(user, message, commandName, moduleName); + + if (result is null) + continue; + permIndex = i; + return result.Value; + } + + permIndex = -1; //defaut behaviour + return true; + } + + //null = not applicable + //true = applicable, allowed + //false = applicable, not allowed + public static bool? CheckPermission( + this Permissionv2 perm, + IUser user, + IMessageChannel channel, + string commandName, + string moduleName) + { + if (!((perm.SecondaryTarget == SecondaryPermissionType.Command + && string.Equals(perm.SecondaryTargetName, commandName, StringComparison.InvariantCultureIgnoreCase)) + || (perm.SecondaryTarget == SecondaryPermissionType.Module + && string.Equals(perm.SecondaryTargetName, moduleName, StringComparison.InvariantCultureIgnoreCase)) + || perm.SecondaryTarget == SecondaryPermissionType.AllModules)) + return null; + + var guildUser = user as IGuildUser; + + switch (perm.PrimaryTarget) + { + case PrimaryPermissionType.User: + if (perm.PrimaryTargetId == user.Id) + return perm.State; + break; + case PrimaryPermissionType.Channel: + if (perm.PrimaryTargetId == channel.Id) + return perm.State; + break; + case PrimaryPermissionType.Role: + if (guildUser is null) + break; + if (guildUser.RoleIds.Contains(perm.PrimaryTargetId)) + return perm.State; + break; + case PrimaryPermissionType.Server: + if (guildUser is null) + break; + return perm.State; + } + + return null; + } + + public static string GetCommand(this Permissionv2 perm, string prefix, SocketGuild guild = null) + { + var com = string.Empty; + switch (perm.PrimaryTarget) + { + case PrimaryPermissionType.User: + com += "u"; + break; + case PrimaryPermissionType.Channel: + com += "c"; + break; + case PrimaryPermissionType.Role: + com += "r"; + break; + case PrimaryPermissionType.Server: + com += "s"; + break; + } + + switch (perm.SecondaryTarget) + { + case SecondaryPermissionType.Module: + com += "m"; + break; + case SecondaryPermissionType.Command: + com += "c"; + break; + case SecondaryPermissionType.AllModules: + com = "a" + com + "m"; + break; + } + + var secName = perm.SecondaryTarget == SecondaryPermissionType.Command && !perm.IsCustomCommand + ? prefix + perm.SecondaryTargetName + : perm.SecondaryTargetName; + com += " " + (perm.SecondaryTargetName != "*" ? secName + " " : "") + (perm.State ? "enable" : "disable") + " "; + + switch (perm.PrimaryTarget) + { + case PrimaryPermissionType.User: + com += guild?.GetUser(perm.PrimaryTargetId)?.ToString() ?? $"<@{perm.PrimaryTargetId}>"; + break; + case PrimaryPermissionType.Channel: + com += $"<#{perm.PrimaryTargetId}>"; + break; + case PrimaryPermissionType.Role: + com += guild?.GetRole(perm.PrimaryTargetId)?.ToString() ?? $"<@&{perm.PrimaryTargetId}>"; + break; + case PrimaryPermissionType.Server: + break; + } + + return prefix + com; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Permissions.cs b/src/EllieBot/Modules/Permissions/Permissions.cs new file mode 100644 index 0000000..5fb6fb2 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Permissions.cs @@ -0,0 +1,543 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Permissions.Common; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions : EllieModule +{ + public enum Reset { Reset } + + private readonly DbService _db; + + public Permissions(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Verbose(PermissionAction action = null) + { + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + if (action is null) + action = new(!config.VerbosePermissions); // New behaviour, can toggle. + config.VerbosePermissions = action.Value; + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + if (action.Value) + await Response().Confirm(strs.verbose_true).SendAsync(); + else + await Response().Confirm(strs.verbose_false).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public async Task PermRole([Leftover] IRole role = null) + { + if (role is not null && role == role.Guild.EveryoneRole) + return; + + if (role is null) + { + var cache = _service.GetCacheFor(ctx.Guild.Id); + if (!ulong.TryParse(cache.PermRole, out var roleId) + || (role = ((SocketGuild)ctx.Guild).GetRole(roleId)) is null) + await Response().Confirm(strs.permrole_not_set).SendAsync(); + else + await Response().Confirm(strs.permrole(Format.Bold(role.ToString()))).SendAsync(); + return; + } + + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + config.PermissionRole = role.Id.ToString(); + uow.SaveChanges(); + _service.UpdateCache(config); + } + + await Response().Confirm(strs.permrole_changed(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public async Task PermRole(Reset _) + { + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + config.PermissionRole = null; + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + await Response().Confirm(strs.permrole_reset).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ListPerms(int page = 1) + { + if (page < 1) + return; + + IList perms; + + if (_service.Cache.TryGetValue(ctx.Guild.Id, out var permCache)) + perms = permCache.Permissions.Source.ToList(); + else + perms = Permissionv2.GetDefaultPermlist; + + var startPos = 20 * (page - 1); + var toSend = Format.Bold(GetText(strs.page(page))) + + "\n\n" + + string.Join("\n", + perms.Reverse() + .Skip(startPos) + .Take(20) + .Select(p => + { + var str = + $"`{p.Index + 1}.` {Format.Bold(p.GetCommand(prefix, (SocketGuild)ctx.Guild))}"; + if (p.Index == 0) + str += $" [{GetText(strs.uneditable)}]"; + return str; + })); + + await Response().Confirm(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RemovePerm(int index) + { + index -= 1; + if (index < 0) + return; + try + { + Permissionv2 p; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + var permsCol = new PermissionsCollection(config.Permissions); + p = permsCol[index]; + permsCol.RemoveAt(index); + uow.Remove(p); + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + await Response() + .Confirm(strs.removed(index + 1, + Format.Code(p.GetCommand(prefix, (SocketGuild)ctx.Guild)))) + .SendAsync(); + } + catch (IndexOutOfRangeException) + { + await Response().Error(strs.perm_out_of_range).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task MovePerm(int from, int to) + { + from -= 1; + to -= 1; + if (!(from == to || from < 0 || to < 0)) + { + try + { + Permissionv2 fromPerm; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + var permsCol = new PermissionsCollection(config.Permissions); + + var fromFound = from < permsCol.Count; + var toFound = to < permsCol.Count; + + if (!fromFound) + { + await Response().Error(strs.perm_not_found(++from)).SendAsync(); + return; + } + + if (!toFound) + { + await Response().Error(strs.perm_not_found(++to)).SendAsync(); + return; + } + + fromPerm = permsCol[from]; + + permsCol.RemoveAt(from); + permsCol.Insert(to, fromPerm); + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + await Response() + .Confirm(strs.moved_permission( + Format.Code(fromPerm.GetCommand(prefix, (SocketGuild)ctx.Guild)), + ++from, + ++to)) + .SendAsync(); + + return; + } + catch (Exception e) when (e is ArgumentOutOfRangeException or IndexOutOfRangeException) + { + } + } + + await Response().Confirm(strs.perm_out_of_range).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrCmd(CommandOrExprInfo command, PermissionAction action) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + await Response().Confirm(strs.sx_enable(Format.Code(command.Name), GetText(strs.of_command))).SendAsync(); + else + await Response().Confirm(strs.sx_disable(Format.Code(command.Name), GetText(strs.of_command))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrMdl(ModuleOrExpr module, PermissionAction action) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.sx_enable(Format.Code(module.Name), GetText(strs.of_module))).SendAsync(); + else + await Response().Confirm(strs.sx_disable(Format.Code(module.Name), GetText(strs.of_module))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task UsrCmd(CommandOrExprInfo command, PermissionAction action, [Leftover] IGuildUser user) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = user.Id, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + { + await Response() + .Confirm(strs.ux_enable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(user.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.ux_disable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(user.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task UsrMdl(ModuleOrExpr module, PermissionAction action, [Leftover] IGuildUser user) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = user.Id, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + if (action.Value) + { + await Response() + .Confirm(strs.ux_enable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(user.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.ux_disable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(user.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RoleCmd(CommandOrExprInfo command, PermissionAction action, [Leftover] IRole role) + { + if (role == role.Guild.EveryoneRole) + return; + + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Role, + PrimaryTargetId = role.Id, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + { + await Response() + .Confirm(strs.rx_enable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(role.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.rx_disable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(role.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RoleMdl(ModuleOrExpr module, PermissionAction action, [Leftover] IRole role) + { + if (role == role.Guild.EveryoneRole) + return; + + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Role, + PrimaryTargetId = role.Id, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + + if (action.Value) + { + await Response() + .Confirm(strs.rx_enable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(role.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.rx_disable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(role.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlCmd(CommandOrExprInfo command, PermissionAction action, [Leftover] ITextChannel chnl) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Channel, + PrimaryTargetId = chnl.Id, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + { + await Response() + .Confirm(strs.cx_enable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(chnl.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.cx_disable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(chnl.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlMdl(ModuleOrExpr module, PermissionAction action, [Leftover] ITextChannel chnl) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Channel, + PrimaryTargetId = chnl.Id, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + if (action.Value) + { + await Response() + .Confirm(strs.cx_enable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(chnl.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.cx_disable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(chnl.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllChnlMdls(PermissionAction action, [Leftover] ITextChannel chnl) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Channel, + PrimaryTargetId = chnl.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.acm_enable(Format.Code(chnl.Name))).SendAsync(); + else + await Response().Confirm(strs.acm_disable(Format.Code(chnl.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllRoleMdls(PermissionAction action, [Leftover] IRole role) + { + if (role == role.Guild.EveryoneRole) + return; + + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Role, + PrimaryTargetId = role.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.arm_enable(Format.Code(role.Name))).SendAsync(); + else + await Response().Confirm(strs.arm_disable(Format.Code(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllUsrMdls(PermissionAction action, [Leftover] IUser user) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = user.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.aum_enable(Format.Code(user.ToString()))).SendAsync(); + else + await Response().Confirm(strs.aum_disable(Format.Code(user.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllSrvrMdls(PermissionAction action) + { + var newPerm = new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }; + + var allowUser = new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = ctx.User.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = true + }; + + await _service.AddPermissions(ctx.Guild.Id, newPerm, allowUser); + + if (action.Value) + await Response().Confirm(strs.asm_enable).SendAsync(); + else + await Response().Confirm(strs.asm_disable).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionsCollection.cs b/src/EllieBot/Modules/Permissions/PermissionsCollection.cs new file mode 100644 index 0000000..e869bc7 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionsCollection.cs @@ -0,0 +1,74 @@ +#nullable disable +namespace EllieBot.Modules.Permissions.Common; + +public class PermissionsCollection : IndexedCollection + where T : class, IIndexed +{ + public override T this[int index] + { + get => Source[index]; + set + { + lock (_localLocker) + { + if (index == 0) // can't set first element. It's always allow all + throw new IndexOutOfRangeException(nameof(index)); + base[index] = value; + } + } + } + + private readonly object _localLocker = new(); + + public PermissionsCollection(IEnumerable source) + : base(source) + { + } + + public static implicit operator List(PermissionsCollection x) + => x.Source; + + public override void Clear() + { + lock (_localLocker) + { + var first = Source[0]; + base.Clear(); + Source[0] = first; + } + } + + public override bool Remove(T item) + { + bool removed; + lock (_localLocker) + { + if (Source.IndexOf(item) == 0) + throw new ArgumentException("You can't remove first permsission (allow all)"); + removed = base.Remove(item); + } + + return removed; + } + + public override void Insert(int index, T item) + { + lock (_localLocker) + { + if (index == 0) // can't insert on first place. Last item is always allow all. + throw new IndexOutOfRangeException(nameof(index)); + base.Insert(index, item); + } + } + + public override void RemoveAt(int index) + { + lock (_localLocker) + { + if (index == 0) // you can't remove first permission (allow all) + throw new IndexOutOfRangeException(nameof(index)); + + base.RemoveAt(index); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionsService.cs b/src/EllieBot/Modules/Permissions/PermissionsService.cs new file mode 100644 index 0000000..e0ac658 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionsService.cs @@ -0,0 +1,184 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Permissions.Common; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Services; + +public class PermissionService : IExecPreCommand, IEService +{ + public int Priority { get; } = 0; + + //guildid, root permission + public ConcurrentDictionary Cache { get; } = new(); + + private readonly DbService _db; + private readonly CommandHandler _cmd; + private readonly IBotStrings _strings; + private readonly IMessageSenderService _sender; + + public PermissionService( + DiscordSocketClient client, + DbService db, + CommandHandler cmd, + IBotStrings strings, + IMessageSenderService sender) + { + _db = db; + _cmd = cmd; + _strings = strings; + _sender = sender; + + using var uow = _db.GetDbContext(); + foreach (var x in uow.Set().PermissionsForAll(client.Guilds.ToArray().Select(x => x.Id).ToList())) + { + Cache.TryAdd(x.GuildId, + new() + { + Verbose = x.VerbosePermissions, + PermRole = x.PermissionRole, + Permissions = new(x.Permissions) + }); + } + } + + public PermissionCache GetCacheFor(ulong guildId) + { + if (!Cache.TryGetValue(guildId, out var pc)) + { + using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.Permissions)); + UpdateCache(config); + } + + Cache.TryGetValue(guildId, out pc); + if (pc is null) + throw new("Cache is null."); + } + + return pc; + } + + public async Task AddPermissions(ulong guildId, params Permissionv2[] perms) + { + await using var uow = _db.GetDbContext(); + var config = uow.GcWithPermissionsFor(guildId); + //var orderedPerms = new PermissionsCollection(config.Permissions); + var max = config.Permissions.Max(x => x.Index); //have to set its index to be the highest + foreach (var perm in perms) + { + perm.Index = ++max; + config.Permissions.Add(perm); + } + + await uow.SaveChangesAsync(); + UpdateCache(config); + } + + public void UpdateCache(GuildConfig config) + => Cache.AddOrUpdate(config.GuildId, + new PermissionCache + { + Permissions = new(config.Permissions), + PermRole = config.PermissionRole, + Verbose = config.VerbosePermissions + }, + (_, old) => + { + old.Permissions = new(config.Permissions); + old.PermRole = config.PermissionRole; + old.Verbose = config.VerbosePermissions; + return old; + }); + + public async Task ExecPreCommandAsync(ICommandContext ctx, string moduleName, CommandInfo command) + { + var guild = ctx.Guild; + var msg = ctx.Message; + var user = ctx.User; + var channel = ctx.Channel; + var commandName = command.Name.ToLowerInvariant(); + + if (guild is null) + return false; + + var resetCommand = commandName == "resetperms"; + + var pc = GetCacheFor(guild.Id); + if (!resetCommand + && !pc.Permissions.CheckPermissions(msg.Author, msg.Channel, commandName, moduleName, out var index)) + { + if (pc.Verbose) + { + try + { + await _sender.Response(channel) + .Error(_strings.GetText(strs.perm_prevent(index + 1, + Format.Bold(pc.Permissions[index] + .GetCommand(_cmd.GetPrefix(guild), (SocketGuild)guild))), + guild.Id)) + .SendAsync(); + } + catch + { + } + } + + return true; + } + + + if (moduleName == nameof(Permissions)) + { + if (user is not IGuildUser guildUser) + return true; + + if (guildUser.GuildPermissions.Administrator) + return false; + + var permRole = pc.PermRole; + if (!ulong.TryParse(permRole, out var rid)) + rid = 0; + string returnMsg; + IRole role; + if (string.IsNullOrWhiteSpace(permRole) || (role = guild.GetRole(rid)) is null) + { + returnMsg = "You need Admin permissions in order to use permission commands."; + if (pc.Verbose) + { + try { await _sender.Response(channel).Error(returnMsg).SendAsync(); } + catch { } + } + + return true; + } + + if (!guildUser.RoleIds.Contains(rid)) + { + returnMsg = $"You need the {Format.Bold(role.Name)} role in order to use permission commands."; + if (pc.Verbose) + { + try { await _sender.Response(channel).Error(returnMsg).SendAsync(); } + catch { } + } + + return true; + } + + return false; + } + + return false; + } + + public async Task Reset(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var config = uow.GcWithPermissionsFor(guildId); + config.Permissions = Permissionv2.GetDefaultPermlist; + await uow.SaveChangesAsync(); + UpdateCache(config); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs b/src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs new file mode 100644 index 0000000..4193337 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs @@ -0,0 +1,37 @@ +#nullable disable +using EllieBot.Modules.Permissions.Services; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class ResetPermissionsCommands : EllieModule + { + private readonly GlobalPermissionService _gps; + private readonly PermissionService _perms; + + public ResetPermissionsCommands(GlobalPermissionService gps, PermissionService perms) + { + _gps = gps; + _perms = perms; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ResetPerms() + { + await _perms.Reset(ctx.Guild.Id); + await Response().Confirm(strs.perms_reset).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ResetGlobalPerms() + { + await _gps.Reset(); + await Response().Confirm(strs.global_perms_reset).SendAsync(); + } + } +} \ No newline at end of file