Compare commits

...

2 commits

Author SHA1 Message Date
5505052af4
Added permissions module 2024-09-20 23:24:21 +12:00
d9b644d50e
Added services/impl files 2024-09-20 23:23:55 +12:00
27 changed files with 3470 additions and 0 deletions

View file

@ -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<BlacklistService>
{
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();
}
}
}
}

View file

@ -0,0 +1,15 @@
#nullable disable
using EllieBot.Common.TypeReaders;
using static EllieBot.Common.TypeReaders.TypeReaderResult;
namespace EllieBot.Modules.Permissions;
public class CleverbotResponseCmdCdTypeReader : EllieTypeReader<CleverBotResponseStr>
{
public override ValueTask<TypeReaderResult<CleverBotResponseStr>> ReadAsync(
ICommandContext ctx,
string input)
=> input.ToLowerInvariant() == CleverBotResponseStr.CLEVERBOT_RESPONSE
? new(FromSuccess(new CleverBotResponseStr()))
: new(FromError<CleverBotResponseStr>(CommandError.ParseFailed, "Not a valid cleverbot"));
}

View file

@ -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<ulong, ConcurrentDictionary<string, int>> _settings = new();
private readonly ConcurrentDictionary<(ulong, string), ConcurrentDictionary<ulong, DateTime>> _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<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command)
=> TryBlock(context.Guild, context.User, command.Name.ToLowerInvariant());
public Task<bool> 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<ulong, DateTime> 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();
}
}

View file

@ -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<CommandCooldown>().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();
}
}
}
}

View file

@ -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<FilterService>
{
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<string> GetChannelListAsync(IReadOnlyCollection<ulong> 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<string>());
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();
}
}
}

View file

@ -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<ulong> InviteFilteringChannels { get; }
public ConcurrentHashSet<ulong> InviteFilteringServers { get; }
//serverid, filteredwords
public ConcurrentDictionary<ulong, ConcurrentHashSet<string>> ServerFilteredWords { get; }
public ConcurrentHashSet<ulong> WordFilteringChannels { get; }
public ConcurrentHashSet<ulong> WordFilteringServers { get; }
public ConcurrentHashSet<ulong> LinkFilteringChannels { get; }
public ConcurrentHashSet<ulong> 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<GuildConfig>()
.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<string>(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<string> FilteredWordsForChannel(ulong channelId, ulong guildId)
{
var words = new ConcurrentHashSet<string>();
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<string> FilteredWordsForServer(ulong guildId)
{
var words = new ConcurrentHashSet<string>();
if (WordFilteringServers.Contains(guildId))
ServerFilteredWords.TryGetValue(guildId, out words);
return words;
}
public async Task<bool> 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<bool> 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<string>();
var filteredServerWords = FilteredWordsForServer(guild.Id) ?? new ConcurrentHashSet<string>();
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<bool> 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<bool> 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<ServerFilterSettings> 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,
};
}
}

View file

@ -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<ulong> FilterInvitesChannels { get; init; }
public IReadOnlyCollection<ulong> FilterLinksChannels { get; init; }
}

View file

@ -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();
}
}
}

View file

@ -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<string> BlockedCommands
=> _bss.Data.Blocked.Commands;
public HashSet<string> BlockedModules
=> _bss.Data.Blocked.Modules;
private readonly BotConfigService _bss;
public GlobalPermissionService(BotConfigService bss)
=> _bss = bss;
public Task<bool> 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);
}
/// <summary>
/// Toggles module blacklist
/// </summary>
/// <param name="moduleName">Lowercase module name</param>
/// <returns>Whether the module is added</returns>
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;
}
/// <summary>
/// Toggles command blacklist
/// </summary>
/// <param name="commandName">Lowercase command name</param>
/// <returns>Whether the command is added</returns>
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;
}
/// <summary>
/// Resets all global permissions
/// </summary>
public Task Reset()
{
_bss.ModifyConfig(bs =>
{
bs.Blocked.Commands.Clear();
bs.Blocked.Modules.Clear();
});
return Task.CompletedTask;
}
}

View file

@ -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<Permissionv2> Permissions { get; set; }
}

View file

@ -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<Permissionv2> permsEnumerable,
IUser user,
IMessageChannel message,
string commandName,
string moduleName,
out int permIndex)
{
var perms = permsEnumerable as List<Permissionv2> ?? 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;
}
}

View file

@ -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<PermissionService>
{
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<Permissionv2> 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<Permissionv2>(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<Permissionv2>(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();
}
}

View file

@ -0,0 +1,74 @@
#nullable disable
namespace EllieBot.Modules.Permissions.Common;
public class PermissionsCollection<T> : IndexedCollection<T>
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<T> source)
: base(source)
{
}
public static implicit operator List<T>(PermissionsCollection<T> 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);
}
}
}

View file

@ -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<ulong, PermissionCache> 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<GuildConfig>().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<Permissionv2>(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<bool> 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);
}
}

View file

@ -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();
}
}
}

View file

@ -0,0 +1,164 @@
#nullable disable
using Microsoft.Extensions.Configuration;
using EllieBot.Common.Yml;
namespace EllieBot.Services;
public sealed class BotCredsProvider : IBotCredsProvider
{
private const string CREDS_FILE_NAME = "creds.yml";
private const string CREDS_EXAMPLE_FILE_NAME = "creds_example.yml";
private string CredsPath { get; }
private string CredsExamplePath { get; }
private readonly int? _totalShards;
private readonly Creds _creds = new();
private readonly IConfigurationRoot _config;
private readonly object _reloadLock = new();
public BotCredsProvider(int? totalShards = null, string credPath = null)
{
_totalShards = totalShards;
if (!string.IsNullOrWhiteSpace(credPath))
{
CredsPath = credPath;
CredsExamplePath = Path.Combine(Path.GetDirectoryName(credPath), CREDS_EXAMPLE_FILE_NAME);
}
else
{
CredsPath = Path.Combine(Directory.GetCurrentDirectory(), CREDS_FILE_NAME);
CredsExamplePath = Path.Combine(Directory.GetCurrentDirectory(), CREDS_EXAMPLE_FILE_NAME);
}
try
{
if (!File.Exists(CredsExamplePath))
File.WriteAllText(CredsExamplePath, Yaml.Serializer.Serialize(_creds));
}
catch
{
// this can fail in docker containers
}
try
{
MigrateCredentials();
if (!File.Exists(CredsPath))
{
Log.Warning(
"{CredsPath} is missing. Attempting to load creds from environment variables prefixed with 'EllieBot_'. Example is in {CredsExamplePath}",
CredsPath,
CredsExamplePath);
}
_config = new ConfigurationBuilder().AddYamlFile(CredsPath, false, true)
.AddEnvironmentVariables("EllieBot_")
.Build();
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
Reload();
}
public void Reload()
{
lock (_reloadLock)
{
_creds.OwnerIds.Clear();
_config.Bind(_creds);
if (string.IsNullOrWhiteSpace(_creds.Token))
{
Log.Error("Token is missing from creds.yml or Environment variables.\nAdd it and restart the program");
Helpers.ReadErrorAndExit(5);
return;
}
if (string.IsNullOrWhiteSpace(_creds.RestartCommand?.Cmd)
|| string.IsNullOrWhiteSpace(_creds.RestartCommand?.Args))
{
if (Environment.OSVersion.Platform == PlatformID.Unix)
{
_creds.RestartCommand = new RestartConfig()
{
Args = "dotnet",
Cmd = "EllieBot.dll -- {0}"
};
}
else
{
_creds.RestartCommand = new RestartConfig()
{
Args = "EllieBot.exe",
Cmd = "{0}"
};
}
}
if (string.IsNullOrWhiteSpace(_creds.RedisOptions))
_creds.RedisOptions = "127.0.0.1,syncTimeout=3000";
// replace the old generated key with the shared key
if (string.IsNullOrWhiteSpace(_creds.CoinmarketcapApiKey)
|| _creds.CoinmarketcapApiKey.StartsWith("e79ec505-0913"))
_creds.CoinmarketcapApiKey = "3077537c-7dfb-4d97-9a60-56fc9a9f5035";
_creds.TotalShards = _totalShards ?? _creds.TotalShards;
}
}
public void ModifyCredsFile(Action<IBotCredentials> func)
{
var ymlData = File.ReadAllText(CREDS_FILE_NAME);
var creds = Yaml.Deserializer.Deserialize<Creds>(ymlData);
func(creds);
ymlData = Yaml.Serializer.Serialize(creds);
File.WriteAllText(CREDS_FILE_NAME, ymlData);
}
private void MigrateCredentials()
{
if (File.Exists(CREDS_FILE_NAME))
{
var creds = Yaml.Deserializer.Deserialize<Creds>(File.ReadAllText(CREDS_FILE_NAME));
if (creds.Version <= 5)
{
creds.BotCache = BotCacheImplemenation.Redis;
}
if (creds.Version <= 6)
{
creds.Version = 7;
File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds));
}
if (creds.Version <= 8)
{
creds.Version = 9;
File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds));
}
}
}
public IBotCredentials GetCreds()
{
lock (_reloadLock)
{
return _creds;
}
}
}

View file

@ -0,0 +1,229 @@
#nullable disable
using Google;
using Google.Apis.Services;
using Google.Apis.Urlshortener.v1;
using Google.Apis.YouTube.v3;
using Newtonsoft.Json.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Xml;
namespace EllieBot.Services;
public sealed partial class GoogleApiService : IGoogleApiService, IEService
{
private static readonly Regex
_plRegex = new(@"(?:youtu\.be\/|list=)(?<id>[\da-zA-Z\-_]*)", RegexOptions.Compiled);
private readonly YouTubeService _yt;
private readonly UrlshortenerService _sh;
//private readonly Regex YtVideoIdRegex = new Regex(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{6,11})", RegexOptions.Compiled);
private readonly IBotCredsProvider _creds;
private readonly IHttpClientFactory _httpFactory;
public GoogleApiService(IBotCredsProvider creds, IHttpClientFactory factory) : this()
{
_creds = creds;
_httpFactory = factory;
var bcs = new BaseClientService.Initializer
{
ApplicationName = "Ellie Bot",
ApiKey = _creds.GetCreds().GoogleApiKey
};
_yt = new(bcs);
_sh = new(bcs);
}
public async Task<IEnumerable<string>> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1)
{
if (string.IsNullOrWhiteSpace(keywords))
throw new ArgumentNullException(nameof(keywords));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
var match = _plRegex.Match(keywords);
if (match.Length > 1)
return new[] { match.Groups["id"].Value };
var query = _yt.Search.List("snippet");
query.MaxResults = count;
query.Type = "playlist";
query.Q = keywords;
return (await query.ExecuteAsync()).Items.Select(i => i.Id.PlaylistId);
}
public async Task<IEnumerable<string>> GetRelatedVideosAsync(string id, int count = 2, string user = null)
{
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentNullException(nameof(id));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
var query = _yt.Search.List("snippet");
query.MaxResults = count;
query.Q = id;
// query.RelatedToVideoId = id;
query.Type = "video";
query.QuotaUser = user;
// bad workaround as there's no replacement for related video querying right now.
// Query youtube with the id of the video, take a second video in the results
// skip the first one as that's probably the same video.
return (await query.ExecuteAsync()).Items.Select(i => "https://www.youtube.com/watch?v=" + i.Id.VideoId).Skip(1);
}
public async Task<IEnumerable<string>> GetVideoLinksByKeywordAsync(string keywords, int count = 1)
{
if (string.IsNullOrWhiteSpace(keywords))
throw new ArgumentNullException(nameof(keywords));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
var query = _yt.Search.List("snippet");
query.MaxResults = count;
query.Q = keywords;
query.Type = "video";
query.SafeSearch = SearchResource.ListRequest.SafeSearchEnum.Strict;
return (await query.ExecuteAsync()).Items.Select(i => "https://www.youtube.com/watch?v=" + i.Id.VideoId);
}
public async Task<IEnumerable<(string Name, string Id, string Url, string Thumbnail)>> GetVideoInfosByKeywordAsync(
string keywords,
int count = 1)
{
if (string.IsNullOrWhiteSpace(keywords))
throw new ArgumentNullException(nameof(keywords));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
var query = _yt.Search.List("snippet");
query.MaxResults = count;
query.Q = keywords;
query.Type = "video";
return (await query.ExecuteAsync()).Items.Select(i
=> (i.Snippet.Title.TrimTo(50),
i.Id.VideoId,
"https://www.youtube.com/watch?v=" + i.Id.VideoId,
i.Snippet.Thumbnails.High.Url));
}
public Task<string> ShortenUrl(Uri url)
=> ShortenUrl(url.ToString());
public async Task<string> ShortenUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
throw new ArgumentNullException(nameof(url));
if (string.IsNullOrWhiteSpace(_creds.GetCreds().GoogleApiKey))
return url;
try
{
var response = await _sh.Url.Insert(new()
{
LongUrl = url
})
.ExecuteAsync();
return response.Id;
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.Forbidden)
{
return url;
}
catch (Exception ex)
{
Log.Warning(ex, "Error shortening URL");
return url;
}
}
public async Task<IEnumerable<string>> GetPlaylistTracksAsync(string playlistId, int count = 50)
{
if (string.IsNullOrWhiteSpace(playlistId))
throw new ArgumentNullException(nameof(playlistId));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
string nextPageToken = null;
var toReturn = new List<string>(count);
do
{
var toGet = count > 50 ? 50 : count;
count -= toGet;
var query = _yt.PlaylistItems.List("contentDetails");
query.MaxResults = toGet;
query.PlaylistId = playlistId;
query.PageToken = nextPageToken;
var data = await query.ExecuteAsync();
toReturn.AddRange(data.Items.Select(i => i.ContentDetails.VideoId));
nextPageToken = data.NextPageToken;
} while (count > 0 && !string.IsNullOrWhiteSpace(nextPageToken));
return toReturn;
}
public async Task<IReadOnlyDictionary<string, TimeSpan>> GetVideoDurationsAsync(IEnumerable<string> videoIds)
{
var videoIdsList = videoIds as List<string> ?? videoIds.ToList();
var toReturn = new Dictionary<string, TimeSpan>();
if (!videoIdsList.Any())
return toReturn;
var remaining = videoIdsList.Count;
do
{
var toGet = remaining > 50 ? 50 : remaining;
remaining -= toGet;
var q = _yt.Videos.List("contentDetails");
q.Id = string.Join(",", videoIdsList.Take(toGet));
videoIdsList = videoIdsList.Skip(toGet).ToList();
var items = (await q.ExecuteAsync()).Items;
foreach (var i in items)
toReturn.Add(i.Id, XmlConvert.ToTimeSpan(i.ContentDetails.Duration));
} while (remaining > 0);
return toReturn;
}
public async Task<string> Translate(string sourceText, string sourceLanguage, string targetLanguage)
{
string text;
if (!Languages.ContainsKey(sourceLanguage) || !Languages.ContainsKey(targetLanguage))
throw new ArgumentException(nameof(sourceLanguage) + "/" + nameof(targetLanguage));
var url = new Uri(string.Format(
"https://translate.googleapis.com/translate_a/single?client=gtx&sl={0}&tl={1}&dt=t&q={2}",
ConvertToLanguageCode(sourceLanguage),
ConvertToLanguageCode(targetLanguage),
WebUtility.UrlEncode(sourceText)));
using (var http = _httpFactory.CreateClient())
{
http.DefaultRequestHeaders.Add("user-agent",
"Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36");
text = await http.GetStringAsync(url);
}
return string.Concat(JArray.Parse(text)[0].Select(x => x[0]));
}
private string ConvertToLanguageCode(string language)
{
Languages.TryGetValue(language, out var mode);
return mode;
}
}

View file

@ -0,0 +1,160 @@
namespace EllieBot.Services;
public sealed partial class GoogleApiService
{
private const string SUPPORTED = """
afrikaans af
albanian sq
amharic am
arabic ar
armenian hy
assamese as
aymara ay
azerbaijani az
bambara bm
basque eu
belarusian be
bengali bn
bhojpuri bho
bosnian bs
bulgarian bg
catalan ca
cebuano ceb
chinese zh-CN
chinese-trad zh-TW
corsican co
croatian hr
czech cs
danish da
dhivehi dv
dogri doi
dutch nl
english en
esperanto eo
estonian et
ewe ee
filipino fil
finnish fi
french fr
frisian fy
galician gl
georgian ka
german de
greek el
guarani gn
gujarati gu
haitian ht
hausa ha
hawaiian haw
hebrew he
hindi hi
hmong hmn
hungarian hu
icelandic is
igbo ig
ilocano ilo
indonesian id
irish ga
italian it
japanese ja
javanese jv
kannada kn
kazakh kk
khmer km
kinyarwanda rw
konkani gom
korean ko
krio kri
kurdish ku
kurdish-sor ckb
kyrgyz ky
lao lo
latin la
latvian lv
lingala ln
lithuanian lt
luganda lg
luxembourgish lb
macedonian mk
maithili mai
malagasy mg
malay ms
malayalam ml
maltese mt
maori mi
marathi mr
meiteilon mni-Mtei
mizo lus
mongolian mn
myanmar my
nepali ne
norwegian no
nyanja ny
odia or
oromo om
pashto ps
persian fa
polish pl
portuguese pt
punjabi pa
quechua qu
romanian ro
russian ru
samoan sm
sanskrit sa
scots gd
sepedi nso
serbian sr
sesotho st
shona sn
sindhi sd
sinhala si
slovak sk
slovenian sl
somali so
spanish es
sundanese su
swahili sw
swedish sv
tagalog tl
tajik tg
tamil ta
tatar tt
telugu te
thai th
tigrinya ti
tsonga ts
turkish tr
turkmen tk
twi ak
ukrainian uk
urdu ur
uyghur ug
uzbek uz
vietnamese vi
welsh cy
xhosa xh
yiddish yi
yoruba yo
zulu zu
""";
public IReadOnlyDictionary<string, string> Languages { get; }
private GoogleApiService()
{
var langs = SUPPORTED.Split("\n")
.Select(x => x.Split(' '))
.ToDictionary(x => x[0].Trim(), x => x[1].Trim());
foreach (var (_, v) in langs.ToArray())
{
langs.Add(v, v);
}
Languages = langs;
}
}

View file

@ -0,0 +1,71 @@
namespace EllieBot.Services;
public sealed class ImageCache : IImageCache, IEService
{
private readonly IBotCache _cache;
private readonly ImagesConfig _ic;
private readonly Random _rng;
private readonly IHttpClientFactory _httpFactory;
public ImageCache(
IBotCache cache,
ImagesConfig ic,
IHttpClientFactory httpFactory)
{
_cache = cache;
_ic = ic;
_httpFactory = httpFactory;
_rng = new EllieRandom();
}
private static TypedKey<byte[]> GetImageKey(Uri url)
=> new($"image:{url}");
public async Task<byte[]?> GetImageDataAsync(Uri url)
=> await _cache.GetOrAddAsync(
GetImageKey(url),
async () =>
{
if (url.IsFile)
{
return await File.ReadAllBytesAsync(url.LocalPath);
}
using var http = _httpFactory.CreateClient();
var bytes = await http.GetByteArrayAsync(url);
return bytes;
},
expiry: TimeSpan.FromHours(48));
private async Task<byte[]?> GetRandomImageDataAsync(Uri[] urls)
{
if (urls.Length == 0)
return null;
var url = urls[_rng.Next(0, urls.Length)];
var data = await GetImageDataAsync(url);
return data;
}
public Task<byte[]?> GetHeadsImageAsync()
=> GetRandomImageDataAsync(_ic.Data.Coins.Heads);
public Task<byte[]?> GetTailsImageAsync()
=> GetRandomImageDataAsync(_ic.Data.Coins.Tails);
public Task<byte[]?> GetCurrencyImageAsync()
=> GetRandomImageDataAsync(_ic.Data.Currency);
public Task<byte[]?> GetXpBackgroundImageAsync()
=> GetImageDataAsync(_ic.Data.Xp.Bg);
public Task<byte[]?> GetDiceAsync(int num)
=> GetImageDataAsync(_ic.Data.Dice[num]);
public Task<byte[]?> GetSlotEmojiAsync(int number)
=> GetImageDataAsync(_ic.Data.Slots.Emojis[number]);
public Task<byte[]?> GetSlotBgAsync()
=> GetImageDataAsync(_ic.Data.Slots.Bg);
}

View file

@ -0,0 +1,108 @@
using EllieBot.Common.Pokemon;
using EllieBot.Modules.Games.Common.Trivia;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace EllieBot.Services;
public sealed class LocalDataCache : ILocalDataCache, IEService
{
private const string POKEMON_ABILITIES_FILE = "data/pokemon/pokemon_abilities.json";
private const string POKEMON_LIST_FILE = "data/pokemon/pokemon_list.json";
private const string POKEMON_MAP_PATH = "data/pokemon/name-id_map.json";
private const string QUESTIONS_FILE = "data/trivia_questions.json";
private readonly IBotCache _cache;
private readonly JsonSerializerOptions _opts = new JsonSerializerOptions()
{
AllowTrailingCommas = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
PropertyNameCaseInsensitive = true
};
public LocalDataCache(IBotCache cache)
=> _cache = cache;
private async Task<T?> GetOrCreateCachedDataAsync<T>(
TypedKey<T> key,
string fileName)
=> await _cache.GetOrAddAsync(key,
async () =>
{
if (!File.Exists(fileName))
{
Log.Warning($"{fileName} is missing. Relevant data can't be loaded");
return default;
}
try
{
await using var stream = File.OpenRead(fileName);
return await JsonSerializer.DeserializeAsync<T>(stream, _opts);
}
catch (Exception ex)
{
Log.Error(ex,
"Error reading {FileName} file: {ErrorMessage}",
fileName,
ex.Message);
return default;
}
});
private static TypedKey<IReadOnlyDictionary<string, SearchPokemon>> _pokemonListKey
= new("pokemon:list");
public async Task<IReadOnlyDictionary<string, SearchPokemon>?> GetPokemonsAsync()
=> await GetOrCreateCachedDataAsync(_pokemonListKey, POKEMON_LIST_FILE);
private static TypedKey<IReadOnlyDictionary<string, SearchPokemonAbility>> _pokemonAbilitiesKey
= new("pokemon:abilities");
public async Task<IReadOnlyDictionary<string, SearchPokemonAbility>?> GetPokemonAbilitiesAsync()
=> await GetOrCreateCachedDataAsync(_pokemonAbilitiesKey, POKEMON_ABILITIES_FILE);
private static TypedKey<IReadOnlyDictionary<int, string>> _pokeMapKey
= new("pokemon:ab_map2"); // 2 because ab_map was storing arrays
public async Task<IReadOnlyDictionary<int, string>?> GetPokemonMapAsync()
=> await _cache.GetOrAddAsync(_pokeMapKey,
async () =>
{
var fileName = POKEMON_MAP_PATH;
if (!File.Exists(fileName))
{
Log.Warning($"{fileName} is missing. Relevant data can't be loaded");
return default;
}
try
{
await using var stream = File.OpenRead(fileName);
var arr = await JsonSerializer.DeserializeAsync<PokemonNameId[]>(stream, _opts);
return (IReadOnlyDictionary<int, string>?)arr?.ToDictionary(x => x.Id, x => x.Name);
}
catch (Exception ex)
{
Log.Error(ex,
"Error reading {FileName} file: {ErrorMessage}",
fileName,
ex.Message);
return default;
}
});
private static TypedKey<TriviaQuestionModel[]> _triviaKey
= new("trivia:questions");
public async Task<TriviaQuestionModel[]?> GetTriviaQuestionsAsync()
=> await GetOrCreateCachedDataAsync(_triviaKey, QUESTIONS_FILE);
}

View file

@ -0,0 +1,120 @@
#nullable disable
using Newtonsoft.Json;
using System.Globalization;
namespace EllieBot.Services;
public class Localization : ILocalization
{
private static readonly Dictionary<string, CommandData> _commandData =
JsonConvert.DeserializeObject<Dictionary<string, CommandData>>(
File.ReadAllText("./data/strings/commands/commands.en-US.json"));
private readonly ConcurrentDictionary<ulong, CultureInfo> _guildCultureInfos;
public IDictionary<ulong, CultureInfo> GuildCultureInfos
=> _guildCultureInfos;
public CultureInfo DefaultCultureInfo
=> _bss.Data.DefaultLocale;
private readonly BotConfigService _bss;
private readonly DbService _db;
public Localization(BotConfigService bss, Bot bot, DbService db)
{
_bss = bss;
_db = db;
var cultureInfoNames = bot.AllGuildConfigs.ToDictionary(x => x.GuildId, x => x.Locale);
_guildCultureInfos = new(cultureInfoNames
.ToDictionary(x => x.Key,
x =>
{
CultureInfo cultureInfo = null;
try
{
if (x.Value is null)
return null;
cultureInfo = new(x.Value);
}
catch { }
return cultureInfo;
})
.Where(x => x.Value is not null));
}
public void SetGuildCulture(IGuild guild, CultureInfo ci)
=> SetGuildCulture(guild.Id, ci);
public void SetGuildCulture(ulong guildId, CultureInfo ci)
{
if (ci.Name == _bss.Data.DefaultLocale.Name)
{
RemoveGuildCulture(guildId);
return;
}
using (var uow = _db.GetDbContext())
{
var gc = uow.GuildConfigsForId(guildId, set => set);
gc.Locale = ci.Name;
uow.SaveChanges();
}
_guildCultureInfos.AddOrUpdate(guildId, ci, (_, _) => ci);
}
public void RemoveGuildCulture(IGuild guild)
=> RemoveGuildCulture(guild.Id);
public void RemoveGuildCulture(ulong guildId)
{
if (_guildCultureInfos.TryRemove(guildId, out _))
{
using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set);
gc.Locale = null;
uow.SaveChanges();
}
}
public void SetDefaultCulture(CultureInfo ci)
=> _bss.ModifyConfig(bs =>
{
bs.DefaultLocale = ci;
});
public void ResetDefaultCulture()
=> SetDefaultCulture(CultureInfo.CurrentCulture);
public CultureInfo GetCultureInfo(IGuild guild)
=> GetCultureInfo(guild?.Id);
public CultureInfo GetCultureInfo(ulong? guildId)
{
if (guildId is null || !GuildCultureInfos.TryGetValue(guildId.Value, out var info) || info is null)
return _bss.Data.DefaultLocale;
return info;
}
public static CommandData LoadCommand(string key)
{
_commandData.TryGetValue(key, out var toReturn);
if (toReturn is null)
{
return new()
{
Cmd = key,
Desc = key,
Usage = [key]
};
}
return toReturn;
}
}

View file

@ -0,0 +1,28 @@
using EllieBot.Common.JsonConverters;
using System.Text.Json;
namespace EllieBot.Common;
public class JsonSeria : ISeria
{
private readonly JsonSerializerOptions _serializerOptions = new()
{
IncludeFields = true,
Converters =
{
new Rgba32Converter(),
new CultureInfoConverter()
}
};
public byte[] Serialize<T>(T data)
=> JsonSerializer.SerializeToUtf8Bytes(data, _serializerOptions);
public T? Deserialize<T>(byte[]? data)
{
if (data is null)
return default;
return JsonSerializer.Deserialize<T>(data, _serializerOptions);
}
}

View file

@ -0,0 +1,57 @@
using StackExchange.Redis;
namespace EllieBot.Common;
public sealed class RedisPubSub : IPubSub
{
private readonly IBotCredentials _creds;
private readonly ConnectionMultiplexer _multi;
private readonly ISeria _serializer;
public RedisPubSub(ConnectionMultiplexer multi, ISeria serializer, IBotCredentials creds)
{
_multi = multi;
_serializer = serializer;
_creds = creds;
}
public Task Pub<TData>(in TypedKey<TData> key, TData data)
where TData : notnull
{
var serialized = _serializer.Serialize(data);
return _multi.GetSubscriber()
.PublishAsync(new RedisChannel($"{_creds.RedisKey()}:{key.Key}", RedisChannel.PatternMode.Literal),
serialized,
CommandFlags.FireAndForget);
}
public Task Sub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
where TData : notnull
{
var eventName = key.Key;
async void OnSubscribeHandler(RedisChannel _, RedisValue data)
{
try
{
var dataObj = _serializer.Deserialize<TData>(data);
if (dataObj is not null)
await action(dataObj);
else
{
Log.Warning("Publishing event {EventName} with a null value. This is not allowed",
eventName);
}
}
catch (Exception ex)
{
Log.Error("Error handling the event {EventName}: {ErrorMessage}", eventName, ex.Message);
}
}
return _multi.GetSubscriber()
.SubscribeAsync(
new RedisChannel($"{_creds.RedisKey()}:{eventName}", RedisChannel.PatternMode.Literal),
OnSubscribeHandler);
}
}

View file

@ -0,0 +1,39 @@
using EllieBot.Common.Configs;
using EllieBot.Common.Yml;
using System.Text.RegularExpressions;
using YamlDotNet.Serialization;
namespace EllieBot.Common;
public class YamlSeria : IConfigSeria
{
private static readonly Regex _codePointRegex =
new(@"(\\U(?<code>[a-zA-Z0-9]{8})|\\u(?<code>[a-zA-Z0-9]{4})|\\x(?<code>[a-zA-Z0-9]{2}))",
RegexOptions.Compiled);
private readonly IDeserializer _deserializer;
private readonly ISerializer _serializer;
public YamlSeria()
{
_serializer = Yaml.Serializer;
_deserializer = Yaml.Deserializer;
}
public string Serialize<T>(T obj)
where T : notnull
{
var escapedOutput = _serializer.Serialize(obj);
var output = _codePointRegex.Replace(escapedOutput,
me =>
{
var str = me.Groups["code"].Value;
var newString = str.UnescapeUnicodeCodePoint();
return newString;
});
return output;
}
public T Deserialize<T>(string data)
=> _deserializer.Deserialize<T>(data);
}

View file

@ -0,0 +1,120 @@
using OneOf;
using OneOf.Types;
using StackExchange.Redis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace EllieBot.Common;
public sealed class RedisBotCache : IBotCache
{
private static readonly Type[] _supportedTypes =
[
typeof(bool), typeof(int), typeof(uint), typeof(long),
typeof(ulong), typeof(float), typeof(double),
typeof(string), typeof(byte[]), typeof(ReadOnlyMemory<byte>), typeof(Memory<byte>),
typeof(RedisValue)
];
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
AllowTrailingCommas = true,
IgnoreReadOnlyProperties = false,
};
private readonly ConnectionMultiplexer _conn;
public RedisBotCache(ConnectionMultiplexer conn)
{
_conn = conn;
}
public async ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true)
{
// if a null value is passed, remove the key
if (value is null)
{
await RemoveAsync(key);
return false;
}
var db = _conn.GetDatabase();
RedisValue val = IsSupportedType(typeof(T))
? RedisValue.Unbox(value)
: JsonSerializer.Serialize(value, _opts);
var success = await db.StringSetAsync(key.Key,
val,
expiry: expiry,
keepTtl: true,
when: overwrite ? When.Always : When.NotExists);
return success;
}
public bool IsSupportedType(Type type)
{
if (type.IsGenericType)
{
var typeDef = type.GetGenericTypeDefinition();
if (typeDef == typeof(Nullable<>))
return IsSupportedType(type.GenericTypeArguments[0]);
}
foreach (var t in _supportedTypes)
{
if (type == t)
return true;
}
return false;
}
public async ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key)
{
var db = _conn.GetDatabase();
var val = await db.StringGetAsync(key.Key);
if (val == default)
return new None();
if (IsSupportedType(typeof(T)))
return (T)((IConvertible)val).ToType(typeof(T), null);
return JsonSerializer.Deserialize<T>(val.ToString(), _opts)!;
}
public async ValueTask<bool> RemoveAsync<T>(TypedKey<T> key)
{
var db = _conn.GetDatabase();
return await db.KeyDeleteAsync(key.Key);
}
public async ValueTask<T?> GetOrAddAsync<T>(TypedKey<T> key, Func<Task<T?>> createFactory, TimeSpan? expiry = null)
{
var result = await GetAsync(key);
return await result.Match<Task<T?>>(
v => Task.FromResult<T?>(v),
async _ =>
{
var factoryValue = await createFactory();
if (factoryValue is null)
return default;
await AddAsync(key, factoryValue, expiry);
// get again to make sure it's the cached value
// and not the late factory value, in case there's a race condition
var newResult = await GetAsync(key);
// it's fine to do this, it should blow up if something went wrong.
return newResult.Match<T?>(
v => v,
_ => default);
});
}
}

View file

@ -0,0 +1,91 @@
#nullable disable
using StackExchange.Redis;
using System.Text.Json;
using System.Web;
namespace EllieBot.Services;
/// <summary>
/// Uses <see cref="IStringsSource" /> to load strings into redis hash (only on Shard 0)
/// and retrieves them from redis via <see cref="GetText" />
/// </summary>
public class RedisBotStringsProvider : IBotStringsProvider
{
private const string COMMANDS_KEY = "commands_v5";
private readonly ConnectionMultiplexer _redis;
private readonly IStringsSource _source;
private readonly IBotCredentials _creds;
public RedisBotStringsProvider(
ConnectionMultiplexer redis,
DiscordSocketClient discordClient,
IStringsSource source,
IBotCredentials creds)
{
_redis = redis;
_source = source;
_creds = creds;
if (discordClient.ShardId == 0)
Reload();
}
public string GetText(string localeName, string key)
{
var value = _redis.GetDatabase().HashGet($"{_creds.RedisKey()}:responses:{localeName}", key);
return value;
}
public CommandStrings GetCommandStrings(string localeName, string commandName)
{
string examplesStr = _redis.GetDatabase()
.HashGet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}",
$"{commandName}::examples");
if (examplesStr == default)
return null;
var descStr = _redis.GetDatabase()
.HashGet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", $"{commandName}::desc");
if (descStr == default)
return null;
var ex = examplesStr.Split('&').Map(HttpUtility.UrlDecode);
var paramsStr = _redis.GetDatabase()
.HashGet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", $"{commandName}::params");
if (paramsStr == default)
return null;
return new()
{
Examples = ex,
Params = JsonSerializer.Deserialize<Dictionary<string, CommandStringParam>[]>(paramsStr),
Desc = descStr
};
}
public void Reload()
{
var redisDb = _redis.GetDatabase();
foreach (var (localeName, localeStrings) in _source.GetResponseStrings())
{
var hashFields = localeStrings.Select(x => new HashEntry(x.Key, x.Value)).ToArray();
redisDb.HashSet($"{_creds.RedisKey()}:responses:{localeName}", hashFields);
}
foreach (var (localeName, localeStrings) in _source.GetCommandStrings())
{
var hashFields = localeStrings
.Select(x => new HashEntry($"{x.Key}::examples",
string.Join('&', x.Value.Examples.Map(HttpUtility.UrlEncode))))
.Concat(localeStrings.Select(x => new HashEntry($"{x.Key}::desc", x.Value.Desc)))
.Concat(localeStrings.Select(x
=> new HashEntry($"{x.Key}::params", JsonSerializer.Serialize(x.Value.Params))))
.ToArray();
redisDb.HashSet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", hashFields);
}
}
}

View file

@ -0,0 +1,132 @@
#nullable disable
using Grpc.Core;
using Grpc.Net.Client;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Coordinator;
namespace EllieBot.Services;
public class RemoteGrpcCoordinator : ICoordinator, IReadyExecutor
{
private readonly Coordinator.Coordinator.CoordinatorClient _coordClient;
private readonly DiscordSocketClient _client;
public RemoteGrpcCoordinator(IBotCredentials creds, DiscordSocketClient client)
{
var coordUrl = string.IsNullOrWhiteSpace(creds.CoordinatorUrl) ? "http://localhost:3442" : creds.CoordinatorUrl;
var channel = GrpcChannel.ForAddress(coordUrl);
_coordClient = new(channel);
_client = client;
}
public bool RestartBot()
{
_coordClient.RestartAllShards(new());
return true;
}
public void Die(bool graceful)
=> _coordClient.Die(new()
{
Graceful = graceful
});
public bool RestartShard(int shardId)
{
_coordClient.RestartShard(new()
{
ShardId = shardId
});
return true;
}
public IList<ShardStatus> GetAllShardStatuses()
{
var res = _coordClient.GetAllStatuses(new());
return res.Statuses.ToArray()
.Map(s => new ShardStatus
{
ConnectionState = FromCoordConnState(s.State),
GuildCount = s.GuildCount,
ShardId = s.ShardId,
LastUpdate = s.LastUpdate.ToDateTime()
});
}
public int GetGuildCount()
{
var res = _coordClient.GetAllStatuses(new());
return res.Statuses.Sum(x => x.GuildCount);
}
public async Task Reload()
=> await _coordClient.ReloadAsync(new());
public Task OnReadyAsync()
{
Task.Run(async () =>
{
var gracefulImminent = false;
while (true)
{
try
{
var reply = await _coordClient.HeartbeatAsync(new()
{
State = ToCoordConnState(_client.ConnectionState),
GuildCount =
_client.ConnectionState == ConnectionState.Connected ? _client.Guilds.Count : 0,
ShardId = _client.ShardId
},
deadline: DateTime.UtcNow + TimeSpan.FromSeconds(10));
gracefulImminent = reply.GracefulImminent;
}
catch (RpcException ex)
{
if (!gracefulImminent)
{
Log.Warning(ex,
"Hearbeat failed and graceful shutdown was not expected: {Message}",
ex.Message);
break;
}
Log.Information("Coordinator is restarting gracefully. Waiting...");
await Task.Delay(30_000);
}
catch (Exception ex)
{
Log.Error(ex, "Unexpected heartbeat exception: {Message}", ex.Message);
break;
}
await Task.Delay(7500);
}
Environment.Exit(5);
});
return Task.CompletedTask;
}
private ConnState ToCoordConnState(ConnectionState state)
=> state switch
{
ConnectionState.Connecting => ConnState.Connecting,
ConnectionState.Connected => ConnState.Connected,
_ => ConnState.Disconnected
};
private ConnectionState FromCoordConnState(ConnState state)
=> state switch
{
ConnState.Connecting => ConnectionState.Connecting,
ConnState.Connected => ConnectionState.Connected,
_ => ConnectionState.Disconnected
};
}