From 98ae35064b871a4d8130a37cc4637373cd6c3938 Mon Sep 17 00:00:00 2001 From: Emotion Date: Wed, 23 Aug 2023 23:14:06 +1200 Subject: [PATCH] Added Ellie.Bot.Modules.Utility Signed-off-by: Emotion --- .../Alias/AliasCommands.cs | 137 ++++ .../Alias/AliasService.cs | 95 +++ .../Calc/CalcCommands.cs | 48 ++ .../ConfigCommands.cs | 154 +++++ .../Ellie.Bot.Modules.Utility.csproj | 31 + src/Ellie.Bot.Modules.Utility/GlobalUsings.cs | 32 + .../Info/InviteCommands.cs | 93 +++ .../Invite/InviteService.cs | 48 ++ .../Quote/IQuoteService.cs | 6 + .../Quote/QuoteCommands.cs | 416 ++++++++++++ .../Quote/QuoteService.cs | 32 + .../Remind/RemindCommands.cs | 225 ++++++ .../Remind/RemindService.cs | 254 +++++++ .../Repeater/RepeatCommands.cs | 204 ++++++ .../Repeater/RepeaterService.cs | 422 ++++++++++++ .../Repeater/RunningRepeater.cs | 92 +++ .../StreamRole/StreamRoleCommands.cs | 97 +++ .../StreamRole/StreamRoleService.cs | 339 ++++++++++ .../UnitConversion/ConverterService.cs | 99 +++ .../UnitConversion/UnitConversionCommands.cs | 96 +++ src/Ellie.Bot.Modules.Utility/Utility.cs | 640 ++++++++++++++++++ .../VerboseErrors/EvalCommands.cs | 76 +++ .../VerboseErrors/EvalGlobals.cs | 13 + .../VerboseErrorsService.cs | 69 ++ .../_common/ConvertUnit.cs | 12 + .../Exceptions/StreamRoleNotFoundException.cs | 20 + .../StreamRolePermissionException.cs | 20 + .../_common/StreamRoleListType.cs | 8 + 28 files changed, 3778 insertions(+) create mode 100644 src/Ellie.Bot.Modules.Utility/Alias/AliasCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Alias/AliasService.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Calc/CalcCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/ConfigCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Ellie.Bot.Modules.Utility.csproj create mode 100644 src/Ellie.Bot.Modules.Utility/GlobalUsings.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Info/InviteCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Invite/InviteService.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Quote/IQuoteService.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Quote/QuoteCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Quote/QuoteService.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Remind/RemindCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Remind/RemindService.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Repeater/RepeatCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Repeater/RepeaterService.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Repeater/RunningRepeater.cs create mode 100644 src/Ellie.Bot.Modules.Utility/StreamRole/StreamRoleCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/StreamRole/StreamRoleService.cs create mode 100644 src/Ellie.Bot.Modules.Utility/UnitConversion/ConverterService.cs create mode 100644 src/Ellie.Bot.Modules.Utility/UnitConversion/UnitConversionCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/Utility.cs create mode 100644 src/Ellie.Bot.Modules.Utility/VerboseErrors/EvalCommands.cs create mode 100644 src/Ellie.Bot.Modules.Utility/VerboseErrors/EvalGlobals.cs create mode 100644 src/Ellie.Bot.Modules.Utility/VerboseErrorsService.cs create mode 100644 src/Ellie.Bot.Modules.Utility/_common/ConvertUnit.cs create mode 100644 src/Ellie.Bot.Modules.Utility/_common/Exceptions/StreamRoleNotFoundException.cs create mode 100644 src/Ellie.Bot.Modules.Utility/_common/Exceptions/StreamRolePermissionException.cs create mode 100644 src/Ellie.Bot.Modules.Utility/_common/StreamRoleListType.cs diff --git a/src/Ellie.Bot.Modules.Utility/Alias/AliasCommands.cs b/src/Ellie.Bot.Modules.Utility/Alias/AliasCommands.cs new file mode 100644 index 0000000..c5920c8 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Alias/AliasCommands.cs @@ -0,0 +1,137 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Modules.Utility.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class CommandMapCommands : EllieModule + { + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public CommandMapCommands(DbService db, DiscordSocketClient client) + { + _db = db; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AliasesClear() + { + var count = _service.ClearAliases(ctx.Guild.Id); + await ReplyConfirmLocalizedAsync(strs.aliases_cleared(count)); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + [RequireContext(ContextType.Guild)] + public async Task Alias(string trigger, [Leftover] string mapping = null) + { + if (string.IsNullOrWhiteSpace(trigger)) + return; + + trigger = trigger.Trim().ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(mapping)) + { + if (!_service.AliasMaps.TryGetValue(ctx.Guild.Id, out var maps) || !maps.TryRemove(trigger, out _)) + { + await ReplyErrorLocalizedAsync(strs.alias_remove_fail(Format.Code(trigger))); + return; + } + + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, set => set.Include(x => x.CommandAliases)); + var tr = config.CommandAliases.FirstOrDefault(x => x.Trigger == trigger); + if (tr is not null) + uow.Set().Remove(tr); + uow.SaveChanges(); + } + + await ReplyConfirmLocalizedAsync(strs.alias_removed(Format.Code(trigger))); + return; + } + + _service.AliasMaps.AddOrUpdate(ctx.Guild.Id, + _ => + { + using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, set => set.Include(x => x.CommandAliases)); + config.CommandAliases.Add(new() + { + Mapping = mapping, + Trigger = trigger + }); + uow.SaveChanges(); + } + + return new(new Dictionary + { + { trigger.Trim().ToLowerInvariant(), mapping.ToLowerInvariant() } + }); + }, + (_, map) => + { + using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, set => set.Include(x => x.CommandAliases)); + var toAdd = new CommandAlias + { + Mapping = mapping, + Trigger = trigger + }; + var toRemove = config.CommandAliases.Where(x => x.Trigger == trigger).ToArray(); + if (toRemove.Any()) + uow.RemoveRange(toRemove); + config.CommandAliases.Add(toAdd); + uow.SaveChanges(); + } + + map.AddOrUpdate(trigger, mapping, (_, _) => mapping); + return map; + }); + + await ReplyConfirmLocalizedAsync(strs.alias_added(Format.Code(trigger), Format.Code(mapping))); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AliasList(int page = 1) + { + page -= 1; + + if (page < 0) + return; + + if (!_service.AliasMaps.TryGetValue(ctx.Guild.Id, out var maps) || !maps.Any()) + { + await ReplyErrorLocalizedAsync(strs.aliases_none); + return; + } + + var arr = maps.ToArray(); + + await ctx.SendPaginatedConfirmAsync(page, + curPage => + { + return _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.alias_list)) + .WithDescription(string.Join("\n", + arr.Skip(curPage * 10).Take(10).Select(x => $"`{x.Key}` => `{x.Value}`"))); + }, + arr.Length, + 10); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Alias/AliasService.cs b/src/Ellie.Bot.Modules.Utility/Alias/AliasService.cs new file mode 100644 index 0000000..69aef1d --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Alias/AliasService.cs @@ -0,0 +1,95 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Utility.Services; + +public class AliasService : IInputTransformer, IEService +{ + public ConcurrentDictionary> AliasMaps { get; } = new(); + private readonly IEmbedBuilderService _eb; + + private readonly DbService _db; + + public AliasService(DiscordSocketClient client, DbService db, IEmbedBuilderService eb) + { + _eb = eb; + + using var uow = db.GetDbContext(); + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set() + .Include(gc => gc.CommandAliases) + .Where(x => guildIds.Contains(x.GuildId)) + .ToList(); + + AliasMaps = new(configs.ToDictionary(x => x.GuildId, + x => new ConcurrentDictionary(x.CommandAliases.DistinctBy(ca => ca.Trigger) + .ToDictionary(ca => ca.Trigger, ca => ca.Mapping), + StringComparer.OrdinalIgnoreCase))); + + _db = db; + } + + public int ClearAliases(ulong guildId) + { + AliasMaps.TryRemove(guildId, out _); + + int count; + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.CommandAliases)); + count = gc.CommandAliases.Count; + gc.CommandAliases.Clear(); + uow.SaveChanges(); + return count; + } + + public async Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input) + { + if (guild is null || string.IsNullOrWhiteSpace(input)) + return null; + + if (AliasMaps.TryGetValue(guild.Id, out var maps)) + { + string newInput = null; + foreach (var (k ,v) in maps) + { + if (string.Equals(input, k, StringComparison.OrdinalIgnoreCase)) + { + newInput = v; + } + else if (input.StartsWith(k + ' ', StringComparison.OrdinalIgnoreCase)) + { + if (v.Contains("%target%")) + newInput = v.Replace("%target%", input[k.Length..]); + else + newInput = v + ' ' + input[k.Length..]; + } + + if (newInput is not null) + { + try + { + var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}"); + toDelete.DeleteAfter(1.5f); + } + catch + { + // ignored + } + + return newInput; + } + } + + return null; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Calc/CalcCommands.cs b/src/Ellie.Bot.Modules.Utility/Calc/CalcCommands.cs new file mode 100644 index 0000000..f0fdbbb --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Calc/CalcCommands.cs @@ -0,0 +1,48 @@ +#nullable disable +using NCalc; +using System.Reflection; + +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class CalcCommands : EllieModule + { + [Cmd] + public async Task Calculate([Leftover] string expression) + { + var expr = new Expression(expression, EvaluateOptions.IgnoreCase | EvaluateOptions.NoCache); + expr.EvaluateParameter += Expr_EvaluateParameter; + var result = expr.Evaluate(); + if (!expr.HasErrors()) + await SendConfirmAsync("⚙ " + GetText(strs.result), result.ToString()); + else + await SendErrorAsync("⚙ " + GetText(strs.error), expr.Error); + } + + private static void Expr_EvaluateParameter(string name, ParameterArgs args) + { + switch (name.ToLowerInvariant()) + { + case "pi": + args.Result = Math.PI; + break; + case "e": + args.Result = Math.E; + break; + } + } + + [Cmd] + public async Task CalcOps() + { + var selection = typeof(Math).GetTypeInfo() + .GetMethods() + .DistinctBy(x => x.Name) + .Select(x => x.Name) + .Except(new[] { "ToString", "Equals", "GetHashCode", "GetType" }); + await SendConfirmAsync(GetText(strs.calcops(prefix)), string.Join(", ", selection)); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/ConfigCommands.cs b/src/Ellie.Bot.Modules.Utility/ConfigCommands.cs new file mode 100644 index 0000000..1cd9691 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/ConfigCommands.cs @@ -0,0 +1,154 @@ +#nullable disable +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + public partial class ConfigCommands : EllieModule + { + private readonly IEnumerable _settingServices; + + public ConfigCommands(IEnumerable settingServices) + => _settingServices = settingServices.Where(x => x.Name != "medusa"); + + [Cmd] + [OwnerOnly] + public async Task ConfigReload(string name) + { + var setting = _settingServices.FirstOrDefault(x + => x.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase)); + + if (setting is null) + { + var configNames = _settingServices.Select(x => x.Name); + var embed = _eb.Create() + .WithErrorColor() + .WithDescription(GetText(strs.config_not_found(Format.Code(name)))) + .AddField(GetText(strs.config_list), string.Join("\n", configNames)); + + await ctx.Channel.EmbedAsync(embed); + return; + } + + setting.Reload(); + await ctx.OkAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task Config(string name = null, string prop = null, [Leftover] string value = null) + { + var configNames = _settingServices.Select(x => x.Name); + + // if name is not provided, print available configs + name = name?.ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(name)) + { + var embed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.config_list)) + .WithDescription(string.Join("\n", configNames)); + + await ctx.Channel.EmbedAsync(embed); + return; + } + + var setting = _settingServices.FirstOrDefault(x + => x.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase)); + + // if config name is not found, print error and the list of configs + if (setting is null) + { + var embed = _eb.Create() + .WithErrorColor() + .WithDescription(GetText(strs.config_not_found(Format.Code(name)))) + .AddField(GetText(strs.config_list), string.Join("\n", configNames)); + + await ctx.Channel.EmbedAsync(embed); + return; + } + + name = setting.Name; + + // if prop is not sent, then print the list of all props and values in that config + prop = prop?.ToLowerInvariant(); + var propNames = setting.GetSettableProps(); + if (string.IsNullOrWhiteSpace(prop)) + { + var propStrings = GetPropsAndValuesString(setting, propNames); + var embed = _eb.Create().WithOkColor().WithTitle($"⚙️ {setting.Name}").WithDescription(propStrings); + + + await ctx.Channel.EmbedAsync(embed); + return; + } + // if the prop is invalid -> print error and list of + + var exists = propNames.Any(x => x == prop); + + if (!exists) + { + var propStrings = GetPropsAndValuesString(setting, propNames); + var propErrorEmbed = _eb.Create() + .WithErrorColor() + .WithDescription(GetText( + strs.config_prop_not_found(Format.Code(prop), Format.Code(name)))) + .AddField($"⚙️ {setting.Name}", propStrings); + + await ctx.Channel.EmbedAsync(propErrorEmbed); + return; + } + + // if prop is sent, but value is not, then we have to check + // if prop is valid -> + if (string.IsNullOrWhiteSpace(value)) + { + value = setting.GetSetting(prop); + + if (string.IsNullOrWhiteSpace(value)) + value = "-"; + + if (prop != "currency.sign") + value = Format.Code(Format.Sanitize(value.TrimTo(1000)), "json"); + + var embed = _eb.Create() + .WithOkColor() + .AddField("Config", Format.Code(setting.Name), true) + .AddField("Prop", Format.Code(prop), true) + .AddField("Value", value); + + var comment = setting.GetComment(prop); + if (!string.IsNullOrWhiteSpace(comment)) + embed.AddField("Comment", comment); + + await ctx.Channel.EmbedAsync(embed); + return; + } + + var success = setting.SetSetting(prop, value); + + if (!success) + { + await ReplyErrorLocalizedAsync(strs.config_edit_fail(Format.Code(prop), Format.Code(value))); + return; + } + + await ctx.OkAsync(); + } + + private string GetPropsAndValuesString(IConfigService config, IReadOnlyCollection names) + { + var propValues = names.Select(pr => + { + var val = config.GetSetting(pr); + if (pr != "currency.sign") + val = val?.TrimTo(28); + return val?.Replace("\n", "") ?? "-"; + }) + .ToList(); + + var strings = names.Zip(propValues, (name, value) => $"{name,-25} = {value}\n"); + + return Format.Code(string.Concat(strings), "hs"); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Ellie.Bot.Modules.Utility.csproj b/src/Ellie.Bot.Modules.Utility/Ellie.Bot.Modules.Utility.csproj new file mode 100644 index 0000000..7756170 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Ellie.Bot.Modules.Utility.csproj @@ -0,0 +1,31 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ellie.Bot.Modules.Utility/GlobalUsings.cs b/src/Ellie.Bot.Modules.Utility/GlobalUsings.cs new file mode 100644 index 0000000..a90f5a7 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/GlobalUsings.cs @@ -0,0 +1,32 @@ +// // global using System.Collections.Concurrent; +global using NonBlocking; +// +// // packages +global using Serilog; +global using Humanizer; +global using Newtonsoft; +// +// // ellie +// global using Ellie; +global using Ellie.Services; +global using Ellise.Common; // new project +global using Ellie.Common; // old + ellie specific things +global using Ellie.Common.Attributes; +global using Ellie.Extensions; +// global using Ellie.Marmalade; + +// discord +global using Discord; +global using Discord.Commands; +global using Discord.Net; +global using Discord.WebSocket; + +// aliases +global using GuildPerm = Discord.GuildPermission; +global using ChannelPerm = Discord.ChannelPermission; +global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute; +global using LeftoverAttribute = Discord.Commands.RemainderAttribute; +global using TypeReaderResult = Ellie.Common.TypeReaders.TypeReaderResult; + +// non-essential +// global using JetBrains.Annotations; \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Info/InviteCommands.cs b/src/Ellie.Bot.Modules.Utility/Info/InviteCommands.cs new file mode 100644 index 0000000..eeb7ebb --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Info/InviteCommands.cs @@ -0,0 +1,93 @@ +#nullable disable +using Ellie.Modules.Utility.Services; + +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class InviteCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(ChannelPerm.CreateInstantInvite)] + [UserPerm(ChannelPerm.CreateInstantInvite)] + [EllieOptions] + public async Task InviteCreate(params string[] args) + { + var (opts, success) = OptionsParser.ParseFrom(new InviteService.Options(), args); + if (!success) + return; + + var ch = (ITextChannel)ctx.Channel; + var invite = await ch.CreateInviteAsync(opts.Expire, opts.MaxUses, opts.Temporary, opts.Unique); + + await SendConfirmAsync($"{ctx.User.Mention} https://discord.gg/{invite.Code}"); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(ChannelPerm.ManageChannels)] + [UserPerm(ChannelPerm.ManageChannels)] + public async Task InviteList(int page = 1, [Leftover] ITextChannel ch = null) + { + if (--page < 0) + return; + var channel = ch ?? (ITextChannel)ctx.Channel; + + var invites = await channel.GetInvitesAsync(); + + await ctx.SendPaginatedConfirmAsync(page, + cur => + { + var i = 1; + var invs = invites.Skip(cur * 9).Take(9).ToList(); + + if (!invs.Any()) + return _eb.Create().WithErrorColor().WithDescription(GetText(strs.no_invites)); + + var embed = _eb.Create().WithOkColor(); + foreach (var inv in invites) + { + var expiryString = inv.MaxAge is null or 0 || inv.CreatedAt is null + ? "∞" + : (inv.CreatedAt.Value.AddSeconds(inv.MaxAge.Value).UtcDateTime - DateTime.UtcNow).ToString( + """d\.hh\:mm\:ss"""); + var creator = inv.Inviter.ToString().TrimTo(25); + var usesString = $"{inv.Uses} / {(inv.MaxUses == 0 ? "∞" : inv.MaxUses?.ToString())}"; + + var desc = $@"`{GetText(strs.inv_uses)}` **{usesString}** +`{GetText(strs.inv_expire)}` **{expiryString}** + +{inv.Url} "; + embed.AddField($"#{i++} {creator}", desc); + } + + return embed; + }, + invites.Count, + 9); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(ChannelPerm.ManageChannels)] + [UserPerm(ChannelPerm.ManageChannels)] + public async Task InviteDelete(int index) + { + if (--index < 0) + return; + + var ch = (ITextChannel)ctx.Channel; + + var invites = await ch.GetInvitesAsync(); + + if (invites.Count <= index) + return; + var inv = invites.ElementAt(index); + await inv.DeleteAsync(); + + await ReplyAsync(GetText(strs.invite_deleted(Format.Bold(inv.Code)))); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Invite/InviteService.cs b/src/Ellie.Bot.Modules.Utility/Invite/InviteService.cs new file mode 100644 index 0000000..04d6150 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Invite/InviteService.cs @@ -0,0 +1,48 @@ +#nullable disable +using CommandLine; + +namespace Ellie.Modules.Utility.Services; + +public class InviteService : IEService +{ + public class Options : IEllieCommandOptions + { + [Option('m', + "max-uses", + Required = false, + Default = 0, + HelpText = "Maximum number of times the invite can be used. Default 0 (never).")] + public int MaxUses { get; set; } + + [Option('u', + "unique", + Required = false, + Default = false, + HelpText = + "Not setting this flag will result in bot getting the existing invite with the same settings if it exists, instead of creating a new one.")] + public bool Unique { get; set; } = false; + + [Option('t', + "temporary", + Required = false, + Default = false, + HelpText = "If this flag is set, the user will be kicked from the guild once they close their client.")] + public bool Temporary { get; set; } = false; + + [Option('e', + "expire", + Required = false, + Default = 0, + HelpText = "Time in seconds to expire the invite. Default 0 (no expiry).")] + public int Expire { get; set; } + + public void NormalizeOptions() + { + if (MaxUses < 0) + MaxUses = 0; + + if (Expire < 0) + Expire = 0; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Quote/IQuoteService.cs b/src/Ellie.Bot.Modules.Utility/Quote/IQuoteService.cs new file mode 100644 index 0000000..9c29b49 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Quote/IQuoteService.cs @@ -0,0 +1,6 @@ +namespace Ellie.Modules.Utility; + +public interface IQuoteService +{ + Task DeleteAllAuthorQuotesAsync(ulong guildId, ulong userId); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Quote/QuoteCommands.cs b/src/Ellie.Bot.Modules.Utility/Quote/QuoteCommands.cs new file mode 100644 index 0000000..2d94b6f --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Quote/QuoteCommands.cs @@ -0,0 +1,416 @@ +#nullable disable warnings +using Ellie.Common.Yml; +using Ellie.Db; +using Ellie.Services.Database.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class QuoteCommands : EllieModule + { + private const string PREPEND_EXPORT = + """ + # Keys are keywords, Each key has a LIST of quotes in the following format: + # - id: Alphanumeric id used for commands related to the quote. (Note, when using .quotesimport, a new id will be generated.) + # an: Author name + # aid: Author id + # txt: Quote text + + """; + + private static readonly ISerializer _exportSerializer = new SerializerBuilder() + .WithEventEmitter(args + => new MultilineScalarFlowStyleEmitter(args)) + .WithNamingConvention( + CamelCaseNamingConvention.Instance) + .WithIndentedSequences() + .ConfigureDefaultValuesHandling(DefaultValuesHandling + .OmitDefaults) + .DisableAliases() + .Build(); + + private readonly DbService _db; + private readonly IHttpClientFactory _http; + private readonly IQuoteService _qs; + + public QuoteCommands(DbService db, IQuoteService qs, IHttpClientFactory http) + { + _db = db; + _http = http; + _qs = qs; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task ListQuotes(OrderType order = OrderType.Keyword) + => ListQuotes(1, order); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task ListQuotes(int page = 1, OrderType order = OrderType.Keyword) + { + page -= 1; + if (page < 0) + return; + + IEnumerable quotes; + await using (var uow = _db.GetDbContext()) + { + quotes = uow.Set().GetGroup(ctx.Guild.Id, page, order); + } + + if (quotes.Any()) + { + await SendConfirmAsync(GetText(strs.quotes_page(page + 1)), + string.Join("\n", + quotes.Select(q + => $"`#{q.Id}` {Format.Bold(q.Keyword.SanitizeAllMentions()),-20} by {q.AuthorName.SanitizeAllMentions()}"))); + } + else + await ReplyErrorLocalizedAsync(strs.quotes_page_none); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuotePrint([Leftover] string keyword) + { + if (string.IsNullOrWhiteSpace(keyword)) + return; + + keyword = keyword.ToUpperInvariant(); + + Quote quote; + await using (var uow = _db.GetDbContext()) + { + quote = await uow.Set().GetRandomQuoteByKeywordAsync(ctx.Guild.Id, keyword); + //if (quote is not null) + //{ + // quote.UseCount += 1; + // uow.Complete(); + //} + } + + if (quote is null) + return; + + var rep = new ReplacementBuilder().WithDefault(Context).Build(); + + var text = SmartText.CreateFrom(quote.Text); + text = rep.Replace(text); + + await ctx.Channel.SendAsync($"`#{quote.Id}` 📣 " + text, true); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteShow(int id) + { + Quote? quote; + await using (var uow = _db.GetDbContext()) + { + quote = uow.Set().GetById(id); + if (quote?.GuildId != ctx.Guild.Id) + quote = null; + } + + if (quote is null) + { + await ReplyErrorLocalizedAsync(strs.quotes_notfound); + return; + } + + await ShowQuoteData(quote); + } + + private async Task ShowQuoteData(Quote data) + => await ctx.Channel.EmbedAsync(_eb.Create(ctx) + .WithOkColor() + .WithTitle(GetText(strs.quote_id($"#{data.Id}"))) + .AddField(GetText(strs.trigger), data.Keyword) + .AddField(GetText(strs.response), + Format.Sanitize(data.Text).Replace("](", "]\\(")) + .WithFooter( + GetText(strs.created_by($"{data.AuthorName} ({data.AuthorId})")))); + + private async Task QuoteSearchinternalAsync(string? keyword, string textOrAuthor) + { + if (string.IsNullOrWhiteSpace(textOrAuthor)) + return; + + keyword = keyword?.ToUpperInvariant(); + + Quote quote; + await using (var uow = _db.GetDbContext()) + { + quote = await uow.Set().SearchQuoteKeywordTextAsync(ctx.Guild.Id, keyword, textOrAuthor); + } + + if (quote is null) + return; + + await ctx.Channel.SendMessageAsync($"`#{quote.Id}` 💬 " + + quote.Keyword.ToLowerInvariant() + + ": " + + quote.Text.SanitizeAllMentions()); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task QuoteSearch(string textOrAuthor) + => QuoteSearchinternalAsync(null, textOrAuthor); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task QuoteSearch(string keyword, [Leftover] string textOrAuthor) + => QuoteSearchinternalAsync(keyword, textOrAuthor); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteId(int id) + { + if (id < 0) + return; + + Quote quote; + + var rep = new ReplacementBuilder().WithDefault(Context).Build(); + + await using (var uow = _db.GetDbContext()) + { + quote = uow.Set().GetById(id); + } + + if (quote is null || quote.GuildId != ctx.Guild.Id) + { + await SendErrorAsync(GetText(strs.quotes_notfound)); + return; + } + + var infoText = $"`#{quote.Id} added by {quote.AuthorName.SanitizeAllMentions()}` 🗯️ " + + quote.Keyword.ToLowerInvariant().SanitizeAllMentions() + + ":\n"; + + + var text = SmartText.CreateFrom(quote.Text); + text = rep.Replace(text); + await ctx.Channel.SendAsync(infoText + text, true); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteAdd(string keyword, [Leftover] string text) + { + if (string.IsNullOrWhiteSpace(keyword) || string.IsNullOrWhiteSpace(text)) + return; + + keyword = keyword.ToUpperInvariant(); + + Quote q; + await using (var uow = _db.GetDbContext()) + { + uow.Set().Add(q = new() + { + AuthorId = ctx.Message.Author.Id, + AuthorName = ctx.Message.Author.Username, + GuildId = ctx.Guild.Id, + Keyword = keyword, + Text = text + }); + await uow.SaveChangesAsync(); + } + + await ReplyConfirmLocalizedAsync(strs.quote_added_new(Format.Code(q.Id.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteDelete(int id) + { + var hasManageMessages = ((IGuildUser)ctx.Message.Author).GuildPermissions.ManageMessages; + + var success = false; + string response; + await using (var uow = _db.GetDbContext()) + { + var q = uow.Set().GetById(id); + + if (q?.GuildId != ctx.Guild.Id || (!hasManageMessages && q.AuthorId != ctx.Message.Author.Id)) + response = GetText(strs.quotes_remove_none); + else + { + uow.Set().Remove(q); + await uow.SaveChangesAsync(); + success = true; + response = GetText(strs.quote_deleted(id)); + } + } + + if (success) + await SendConfirmAsync(response); + else + await SendErrorAsync(response); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task QuoteDeleteAuthor(IUser user) + => QuoteDeleteAuthor(user.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteDeleteAuthor(ulong userId) + { + var hasManageMessages = ((IGuildUser)ctx.Message.Author).GuildPermissions.ManageMessages; + + if (userId == ctx.User.Id || hasManageMessages) + { + var deleted = await _qs.DeleteAllAuthorQuotesAsync(ctx.Guild.Id, userId); + await ReplyConfirmLocalizedAsync(strs.quotes_deleted_count(deleted)); + } + else + { + await ReplyErrorLocalizedAsync(strs.insuf_perms_u); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task DelAllQuotes([Leftover] string keyword) + { + if (string.IsNullOrWhiteSpace(keyword)) + return; + + keyword = keyword.ToUpperInvariant(); + + await using (var uow = _db.GetDbContext()) + { + uow.Set().RemoveAllByKeyword(ctx.Guild.Id, keyword.ToUpperInvariant()); + + await uow.SaveChangesAsync(); + } + + await ReplyConfirmLocalizedAsync(strs.quotes_deleted(Format.Bold(keyword.SanitizeAllMentions()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task QuotesExport() + { + IEnumerable quotes; + await using (var uow = _db.GetDbContext()) + { + quotes = uow.Set().GetForGuild(ctx.Guild.Id).ToList(); + } + + var exprsDict = quotes.GroupBy(x => x.Keyword) + .ToDictionary(x => x.Key, x => x.Select(ExportedQuote.FromModel)); + + var text = PREPEND_EXPORT + _exportSerializer.Serialize(exprsDict).UnescapeUnicodeCodePoints(); + + await using var stream = await text.ToStream(); + await ctx.Channel.SendFileAsync(stream, "quote-export.yml"); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Ratelimit(300)] +#if GLOBAL_NADEKO + [OwnerOnly] +#endif + public async Task QuotesImport([Leftover] string? input = null) + { + input = input?.Trim(); + + _ = ctx.Channel.TriggerTypingAsync(); + + if (input is null) + { + var attachment = ctx.Message.Attachments.FirstOrDefault(); + if (attachment is null) + { + await ReplyErrorLocalizedAsync(strs.expr_import_no_input); + return; + } + + using var client = _http.CreateClient(); + input = await client.GetStringAsync(attachment.Url); + + if (string.IsNullOrWhiteSpace(input)) + { + await ReplyErrorLocalizedAsync(strs.expr_import_no_input); + return; + } + } + + var succ = await ImportExprsAsync(ctx.Guild.Id, input); + if (!succ) + { + await ReplyErrorLocalizedAsync(strs.expr_import_invalid_data); + return; + } + + await ctx.OkAsync(); + } + + private async Task ImportExprsAsync(ulong guildId, string input) + { + Dictionary> data; + try + { + data = Yaml.Deserializer.Deserialize>>(input); + if (data.Sum(x => x.Value.Count) == 0) + return false; + } + catch + { + return false; + } + + await using var uow = _db.GetDbContext(); + foreach (var entry in data) + { + var keyword = entry.Key; + await uow.Set().AddRangeAsync(entry.Value.Where(quote => !string.IsNullOrWhiteSpace(quote.Txt)) + .Select(quote => new Quote + { + GuildId = guildId, + Keyword = keyword, + Text = quote.Txt, + AuthorId = quote.Aid, + AuthorName = quote.An + })); + } + + await uow.SaveChangesAsync(); + return true; + } + + public class ExportedQuote + { + public string Id { get; set; } + public string An { get; set; } + public ulong Aid { get; set; } + public string Txt { get; set; } + + public static ExportedQuote FromModel(Quote quote) + => new() + { + Id = ((kwum)quote.Id).ToString(), + An = quote.AuthorName, + Aid = quote.AuthorId, + Txt = quote.Text + }; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Quote/QuoteService.cs b/src/Ellie.Bot.Modules.Utility/Quote/QuoteService.cs new file mode 100644 index 0000000..bbbd9ab --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Quote/QuoteService.cs @@ -0,0 +1,32 @@ +#nullable disable warnings +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Utility; + +public sealed class QuoteService : IQuoteService, IEService +{ + private readonly DbService _db; + + public QuoteService(DbService db) + { + _db = db; + } + + /// + /// Delete all quotes created by the author in a guild + /// + /// ID of the guild + /// ID of the user + /// Number of deleted qutoes + public async Task DeleteAllAuthorQuotesAsync(ulong guildId, ulong userId) + { + await using var ctx = _db.GetDbContext(); + var deleted = await ctx.GetTable() + .Where(x => x.GuildId == guildId && x.AuthorId == userId) + .DeleteAsync(); + + return deleted; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Remind/RemindCommands.cs b/src/Ellie.Bot.Modules.Utility/Remind/RemindCommands.cs new file mode 100644 index 0000000..c1a4101 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Remind/RemindCommands.cs @@ -0,0 +1,225 @@ +#nullable disable +using Humanizer.Localisation; +using Ellie.Db; +using Ellie.Modules.Utility.Services; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class RemindCommands : EllieModule + { + public enum MeOrHere + { + Me, + Here + } + + public enum Server + { + Server = int.MinValue, + Srvr = int.MinValue, + Serv = int.MinValue, + S = int.MinValue + } + + private readonly DbService _db; + private readonly ITimezoneService _tz; + + public RemindCommands(DbService db, ITimezoneService tz) + { + _db = db; + _tz = tz; + } + + [Cmd] + [Priority(1)] + public async Task Remind(MeOrHere meorhere, [Leftover] string remindString) + { + if (!_service.TryParseRemindMessage(remindString, out var remindData)) + { + await ReplyErrorLocalizedAsync(strs.remind_invalid); + return; + } + + ulong target; + target = meorhere == MeOrHere.Me ? ctx.User.Id : ctx.Channel.Id; + if (!await RemindInternal(target, + meorhere == MeOrHere.Me || ctx.Guild is null, + remindData.Time, + remindData.What, + ReminderType.User)) + await ReplyErrorLocalizedAsync(strs.remind_too_long); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public async Task Remind(ITextChannel channel, [Leftover] string remindString) + { + var perms = ((IGuildUser)ctx.User).GetPermissions(channel); + if (!perms.SendMessages || !perms.ViewChannel) + { + await ReplyErrorLocalizedAsync(strs.cant_read_or_send); + return; + } + + if (!_service.TryParseRemindMessage(remindString, out var remindData)) + { + await ReplyErrorLocalizedAsync(strs.remind_invalid); + return; + } + + + if (!await RemindInternal(channel.Id, false, remindData.Time, remindData.What, ReminderType.User)) + await ReplyErrorLocalizedAsync(strs.remind_too_long); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task RemindList(Server _, int page = 1) + => RemindListInternal(page, true); + + [Cmd] + [Priority(1)] + public Task RemindList(int page = 1) + => RemindListInternal(page, false); + + private async Task RemindListInternal(int page, bool isServer) + { + if (--page < 0) + return; + + var embed = _eb.Create() + .WithOkColor() + .WithTitle(GetText(isServer ? strs.reminder_server_list : strs.reminder_list)); + + List rems; + await using (var uow = _db.GetDbContext()) + { + if (isServer) + rems = uow.Set().RemindersForServer(ctx.Guild.Id, page).ToList(); + else + rems = uow.Set().RemindersFor(ctx.User.Id, page).ToList(); + } + + if (rems.Any()) + { + var i = 0; + foreach (var rem in rems) + { + var when = rem.When; + var diff = when - DateTime.UtcNow; + embed.AddField( + $"#{++i + (page * 10)} {rem.When:HH:mm yyyy-MM-dd} UTC " + + $"(in {diff.Humanize(2, minUnit: TimeUnit.Minute, culture: Culture)})", + $@"`Target:` {(rem.IsPrivate ? "DM" : "Channel")} +`TargetId:` {rem.ChannelId} +`Message:` {rem.Message?.TrimTo(50)}"); + } + } + else + embed.WithDescription(GetText(strs.reminders_none)); + + embed.AddPaginatedFooter(page + 1, null); + await ctx.Channel.EmbedAsync(embed); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task RemindDelete(Server _, int index) + => RemindDelete(index, true); + + [Cmd] + [Priority(1)] + public Task RemindDelete(int index) + => RemindDelete(index, false); + + private async Task RemindDelete(int index, bool isServer) + { + if (--index < 0) + return; + + Reminder rem = null; + await using (var uow = _db.GetDbContext()) + { + var rems = isServer + ? uow.Set().RemindersForServer(ctx.Guild.Id, index / 10).ToList() + : uow.Set().RemindersFor(ctx.User.Id, index / 10).ToList(); + + var pageIndex = index % 10; + if (rems.Count > pageIndex) + { + rem = rems[pageIndex]; + uow.Set().Remove(rem); + uow.SaveChanges(); + } + } + + if (rem is null) + await ReplyErrorLocalizedAsync(strs.reminder_not_exist); + else + await ReplyConfirmLocalizedAsync(strs.reminder_deleted(index + 1)); + } + + private async Task RemindInternal( + ulong targetId, + bool isPrivate, + TimeSpan ts, + string message, + ReminderType reminderType) + { + var time = DateTime.UtcNow + ts; + + if (ts > TimeSpan.FromDays(60)) + return false; + + if (ctx.Guild is not null) + { + var perms = ((IGuildUser)ctx.User).GetPermissions((IGuildChannel)ctx.Channel); + if (!perms.MentionEveryone) + message = message.SanitizeAllMentions(); + } + + var rem = new Reminder + { + ChannelId = targetId, + IsPrivate = isPrivate, + When = time, + Message = message, + UserId = ctx.User.Id, + ServerId = ctx.Guild?.Id ?? 0 + }; + + await using (var uow = _db.GetDbContext()) + { + uow.Set().Add(rem); + await uow.SaveChangesAsync(); + } + + var gTime = ctx.Guild is null ? time : TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(ctx.Guild.Id)); + try + { + await SendConfirmAsync("⏰ " + + GetText(strs.remind( + Format.Bold(!isPrivate ? $"<#{targetId}>" : ctx.User.Username), + Format.Bold(message), + ts.Humanize(3, minUnit: TimeUnit.Second, culture: Culture), + gTime, + gTime))); + } + catch + { + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Remind/RemindService.cs b/src/Ellie.Bot.Modules.Utility/Remind/RemindService.cs new file mode 100644 index 0000000..1e6bbe8 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Remind/RemindService.cs @@ -0,0 +1,254 @@ +#nullable disable +using System.Globalization; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Services.Database.Models; +using System.Text.RegularExpressions; + +namespace Ellie.Modules.Utility.Services; + +public class RemindService : IEService, IReadyExecutor, IRemindService +{ + private readonly Regex _regex = + new(@"^(?:(?:at|on(?:\sthe)?)?\s*(?(?:\d{2}:\d{2}\s)?\d{1,2}\.\d{1,2}(?:\.\d{2,4})?)|(?:in\s?)?\s*(?:(?\d+)(?:\s?(?:months?|mos?),?))?(?:(?:\sand\s|\s*)?(?\d+)(?:\s?(?:weeks?|w),?))?(?:(?:\sand\s|\s*)?(?\d+)(?:\s?(?:days?|d),?))?(?:(?:\sand\s|\s*)?(?\d+)(?:\s?(?:hours?|h),?))?(?:(?:\sand\s|\s*)?(?\d+)(?:\s?(?:minutes?|mins?|m),?))?)\s+(?:to:?\s+)?(?(?:\r\n|[\r\n]|.)+)", + RegexOptions.Compiled | RegexOptions.Multiline); + + private readonly DiscordSocketClient _client; + private readonly DbService _db; + private readonly IBotCredentials _creds; + private readonly IEmbedBuilderService _eb; + private readonly CultureInfo _culture; + + public RemindService( + DiscordSocketClient client, + DbService db, + IBotCredentials creds, + IEmbedBuilderService eb) + { + _client = client; + _db = db; + _creds = creds; + _eb = eb; + + try + { + _culture = new CultureInfo("en-GB"); + } + catch + { + _culture = CultureInfo.InvariantCulture; + } + } + + public async Task OnReadyAsync() + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(15)); + while (await timer.WaitForNextTickAsync()) + { + await OnReminderLoopTickInternalAsync(); + } + } + + private async Task OnReminderLoopTickInternalAsync() + { + try + { + var now = DateTime.UtcNow; + var reminders = await GetRemindersBeforeAsync(now); + if (reminders.Count == 0) + return; + + Log.Information("Executing {ReminderCount} reminders", reminders.Count); + + // make groups of 5, with 1.5 second inbetween each one to ensure against ratelimits + foreach (var group in reminders.Chunk(5)) + { + var executedReminders = group.ToList(); + await executedReminders.Select(ReminderTimerAction).WhenAll(); + await RemoveReminders(executedReminders.Select(x => x.Id)); + await Task.Delay(1500); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error in reminder loop: {ErrorMessage}", ex.Message); + } + } + + private async Task RemoveReminders(IEnumerable reminders) + { + await using var uow = _db.GetDbContext(); + await uow.Set() + .ToLinqToDBTable() + .DeleteAsync(x => reminders.Contains(x.Id)); + + await uow.SaveChangesAsync(); + } + + private async Task> GetRemindersBeforeAsync(DateTime now) + { + await using var uow = _db.GetDbContext(); + return await uow.Set() + .ToLinqToDBTable() + .Where(x => Linq2DbExpressions.GuildOnShard(x.ServerId, _creds.TotalShards, _client.ShardId) + && x.When < now) + .ToListAsyncLinqToDB(); + } + + public bool TryParseRemindMessage(string input, out RemindObject obj) + { + var m = _regex.Match(input); + + obj = default; + if (m.Length == 0) + return false; + + var values = new Dictionary(); + + var what = m.Groups["what"].Value; + + if (string.IsNullOrWhiteSpace(what)) + { + Log.Warning("No message provided for the reminder"); + return false; + } + + TimeSpan ts; + + var dateString = m.Groups["date"].Value; + if (!string.IsNullOrWhiteSpace(dateString)) + { + var now = DateTime.UtcNow; + + if (!DateTime.TryParse(dateString, _culture, DateTimeStyles.None, out var dt)) + { + Log.Warning("Invalid remind datetime format"); + return false; + } + + if (now >= dt) + { + Log.Warning("That remind time has already passed"); + return false; + } + + ts = dt - now; + } + else + { + foreach (var groupName in _regex.GetGroupNames()) + { + if (groupName is "0" or "what") + continue; + + if (string.IsNullOrWhiteSpace(m.Groups[groupName].Value)) + { + values[groupName] = 0; + continue; + } + + if (!int.TryParse(m.Groups[groupName].Value, out var value)) + { + Log.Warning("Reminder regex group {GroupName} has invalid value", groupName); + return false; + } + + if (value < 1) + { + Log.Warning("Reminder time value has to be an integer greater than 0"); + return false; + } + + values[groupName] = value; + } + ts = new TimeSpan((30 * values["mo"]) + (7 * values["w"]) + values["d"], values["h"], values["m"], 0); + } + + + obj = new() + { + Time = ts, + What = what + }; + + return true; + } + + private async Task ReminderTimerAction(Reminder r) + { + try + { + IMessageChannel ch; + if (r.IsPrivate) + { + var user = _client.GetUser(r.ChannelId); + if (user is null) + return; + ch = await user.CreateDMChannelAsync(); + } + else + ch = _client.GetGuild(r.ServerId)?.GetTextChannel(r.ChannelId); + + if (ch is null) + return; + + var st = SmartText.CreateFrom(r.Message); + + if (st is SmartEmbedText set) + { + await ch.SendMessageAsync(null, embed: set.GetEmbed().Build()); + } + else if (st is SmartEmbedTextArray seta) + { + await ch.SendMessageAsync(null, embeds: seta.GetEmbedBuilders().Map(x => x.Build())); + } + else + { + await ch.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle("Reminder") + .AddField("Created At", + r.DateAdded.HasValue ? r.DateAdded.Value.ToLongDateString() : "?") + .AddField("By", + (await ch.GetUserAsync(r.UserId))?.ToString() ?? r.UserId.ToString()), + r.Message); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error executing reminder {ReminderId}: {ErrorMessage}", r.Id, ex.Message); + } + } + + public struct RemindObject + { + public string What { get; set; } + public TimeSpan Time { get; set; } + } + + public async Task AddReminderAsync(ulong userId, + ulong targetId, + ulong? guildId, + bool isPrivate, + DateTime time, + string message, + ReminderType reminderType) + { + var rem = new Reminder + { + UserId = userId, + ChannelId = targetId, + ServerId = guildId ?? 0, + IsPrivate = isPrivate, + When = time, + Message = message, + Type = reminderType + }; + + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .AddAsync(rem); + await ctx.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Repeater/RepeatCommands.cs b/src/Ellie.Bot.Modules.Utility/Repeater/RepeatCommands.cs new file mode 100644 index 0000000..137efc8 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Repeater/RepeatCommands.cs @@ -0,0 +1,204 @@ +using Ellie.Common.TypeReaders; +using Ellie.Common.TypeReaders.Models; +using Ellie.Modules.Utility.Services; + +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class RepeatCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatSkip(int index) + { + if (--index < 0) + return; + + var result = await _service.ToggleSkipNextAsync(ctx.Guild.Id, index); + + if (result is null) + { + await ReplyErrorLocalizedAsync(strs.index_out_of_range); + return; + } + + if (result is true) + { + await ReplyConfirmLocalizedAsync(strs.repeater_skip_next); + } + else + { + await ReplyConfirmLocalizedAsync(strs.repeater_dont_skip_next); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatInvoke(int index) + { + if (--index < 0) + return; + + var success = await _service.TriggerExternal(ctx.Guild.Id, index); + if (!success) + await ReplyErrorLocalizedAsync(strs.repeat_invoke_none); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatRemove(int index) + { + if (--index < 0) + return; + + var removed = await _service.RemoveByIndexAsync(ctx.Guild.Id, index); + if (removed is null) + { + await ReplyErrorLocalizedAsync(strs.repeater_remove_fail); + return; + } + + var description = GetRepeaterInfoString(removed); + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.repeater_removed(index + 1))) + .WithDescription(description)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatRedundant(int index) + { + if (--index < 0) + return; + + var result = await _service.ToggleRedundantAsync(ctx.Guild.Id, index); + + if (result is null) + { + await ReplyErrorLocalizedAsync(strs.index_out_of_range); + return; + } + + if (result.Value) + await ReplyErrorLocalizedAsync(strs.repeater_redundant_no(index + 1)); + else + await ReplyConfirmLocalizedAsync(strs.repeater_redundant_yes(index + 1)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(-1)] + public Task Repeat([Leftover] string message) + => Repeat(null, null, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Repeat(StoopidTime interval, [Leftover] string message) + => Repeat(null, interval, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public Task Repeat(GuildDateTime dt, [Leftover] string message) + => Repeat(dt, null, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(2)] + public async Task Repeat(GuildDateTime? dt, StoopidTime? interval, [Leftover] string message) + { + var startTimeOfDay = dt?.InputTimeUtc.TimeOfDay; + // if interval not null, that means user specified it (don't change it) + + // if interval is null set the default to: + // if time of day is specified: 1 day + // else 5 minutes + var realInterval = + interval?.Time ?? (startTimeOfDay is null ? TimeSpan.FromMinutes(5) : TimeSpan.FromDays(1)); + + if (string.IsNullOrWhiteSpace(message) + || (interval is not null + && (interval.Time > TimeSpan.FromMinutes(25000) || interval.Time < TimeSpan.FromMinutes(1)))) + return; + + message = ((IGuildUser)ctx.User).GuildPermissions.MentionEveryone + ? message + : message.SanitizeMentions(true); + + var runner = await _service.AddRepeaterAsync(ctx.Channel.Id, + ctx.Guild.Id, + realInterval, + message, + false, + startTimeOfDay); + + if (runner is null) + { + await ReplyErrorLocalizedAsync(strs.repeater_exceed_limit(5)); + return; + } + + var description = GetRepeaterInfoString(runner); + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.repeater_created)) + .WithDescription(description)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatList() + { + var repeaters = _service.GetRepeaters(ctx.Guild.Id); + if (repeaters.Count == 0) + { + await ReplyConfirmLocalizedAsync(strs.repeaters_none); + return; + } + + var embed = _eb.Create().WithTitle(GetText(strs.list_of_repeaters)).WithOkColor(); + + var i = 0; + foreach (var runner in repeaters.OrderBy(r => r.Repeater.Id)) + { + var description = GetRepeaterInfoString(runner); + var name = $"#`{++i}` {(_service.IsRepeaterSkipped(runner.Repeater.Id) ? "🦘" : "")}"; + embed.AddField(name, description); + } + + await ctx.Channel.EmbedAsync(embed); + } + + private string GetRepeaterInfoString(RunningRepeater runner) + { + var intervalString = Format.Bold(runner.Repeater.Interval.ToPrettyStringHm()); + var executesIn = runner.NextTime < DateTime.UtcNow ? TimeSpan.Zero : runner.NextTime - DateTime.UtcNow; + var executesInString = Format.Bold(executesIn.ToPrettyStringHm()); + var message = Format.Sanitize(runner.Repeater.Message.TrimTo(50)); + + var description = string.Empty; + if (_service.IsNoRedundant(runner.Repeater.Id)) + description = Format.Underline(Format.Bold(GetText(strs.no_redundant))) + "\n\n"; + + description += $"<#{runner.Repeater.ChannelId}>\n" + + $"`{GetText(strs.interval_colon)}` {intervalString}\n" + + $"`{GetText(strs.executes_in_colon)}` {executesInString}\n" + + $"`{GetText(strs.message_colon)}` {message}"; + + return description; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Repeater/RepeaterService.cs b/src/Ellie.Bot.Modules.Utility/Repeater/RepeaterService.cs new file mode 100644 index 0000000..4916a7e --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Repeater/RepeaterService.cs @@ -0,0 +1,422 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Utility.Services; + +public sealed class RepeaterService : IReadyExecutor, IEService +{ + private const int MAX_REPEATERS = 5; + + private readonly DbService _db; + private readonly IBotCredentials _creds; + private readonly DiscordSocketClient _client; + private readonly LinkedList _repeaterQueue; + private readonly ConcurrentHashSet _noRedundant; + private readonly ConcurrentHashSet _skipNext = new(); + + private readonly object _queueLocker = new(); + + public RepeaterService( + DiscordSocketClient client, + DbService db, + IBotCredentials creds) + { + _db = db; + _creds = creds; + _client = client; + + using var uow = _db.GetDbContext(); + var shardRepeaters = uow.Set() + .Where(x => (int)(x.GuildId / Math.Pow(2, 22)) % _creds.TotalShards + == _client.ShardId) + .AsNoTracking() + .ToList(); + + _noRedundant = new(shardRepeaters.Where(x => x.NoRedundant).Select(x => x.Id)); + + _repeaterQueue = new(shardRepeaters.Select(rep => new RunningRepeater(rep)).OrderBy(x => x.NextTime)); + } + + public Task OnReadyAsync() + { + _ = Task.Run(RunRepeatersLoop); + return Task.CompletedTask; + } + + private async Task RunRepeatersLoop() + { + while (true) + { + try + { + // calculate timeout for the first item + var timeout = GetNextTimeout(); + + // wait it out, and recalculate afterwords + // because repeaters might've been modified meanwhile + if (timeout > TimeSpan.Zero) + { + await Task.Delay(timeout > TimeSpan.FromMinutes(1) ? TimeSpan.FromMinutes(1) : timeout); + continue; + } + + // collect (remove) all repeaters which need to run (3 seconds tolerance) + var now = DateTime.UtcNow + TimeSpan.FromSeconds(3); + + var toExecute = new List(); + lock (_repeaterQueue) + { + var current = _repeaterQueue.First; + while (true) + { + if (current is null || current.Value.NextTime > now) + break; + + toExecute.Add(current.Value); + current = current.Next; + } + } + + // execute + foreach (var chunk in toExecute.Chunk(5)) + await chunk.Where(x => !_skipNext.TryRemove(x.Repeater.Id)).Select(Trigger).WhenAll(); + + // reinsert + foreach (var rep in toExecute) + await HandlePostExecute(rep); + } + catch (Exception ex) + { + Log.Error(ex, "Critical error in repeater queue: {ErrorMessage}", ex.Message); + await Task.Delay(5000); + } + } + } + + private async Task HandlePostExecute(RunningRepeater rep) + { + if (rep.ErrorCount >= 10) + { + RemoveFromQueue(rep.Repeater.Id); + await RemoveRepeaterInternal(rep.Repeater); + return; + } + + UpdatePosition(rep); + } + + public void UpdatePosition(RunningRepeater rep) + { + lock (_queueLocker) + { + rep.UpdateNextTime(); + _repeaterQueue.Remove(rep); + AddToQueue(rep); + } + } + + public async Task TriggerExternal(ulong guildId, int index) + { + await using var uow = _db.GetDbContext(); + + var toTrigger = await uow.Set().AsNoTracking() + .Where(x => x.GuildId == guildId) + .Skip(index) + .FirstOrDefaultAsyncEF(); + + if (toTrigger is null) + return false; + + LinkedListNode? node; + lock (_queueLocker) + { + node = _repeaterQueue.FindNode(x => x.Repeater.Id == toTrigger.Id); + if (node is null) + return false; + + _repeaterQueue.Remove(node); + } + + await Trigger(node.Value); + await HandlePostExecute(node.Value); + return true; + } + + private void AddToQueue(RunningRepeater rep) + { + lock (_queueLocker) + { + var current = _repeaterQueue.First; + if (current is null) + { + _repeaterQueue.AddFirst(rep); + return; + } + + while (current is not null && current.Value.NextTime < rep.NextTime) + current = current.Next; + + if (current is null) + _repeaterQueue.AddLast(rep); + else + _repeaterQueue.AddBefore(current, rep); + } + } + + private TimeSpan GetNextTimeout() + { + lock (_queueLocker) + { + var first = _repeaterQueue.First; + + // if there are no items in the queue, just wait out the minimum duration (1 minute) and try again + if (first is null) + return TimeSpan.FromMinutes(1); + + return first.Value.NextTime - DateTime.UtcNow; + } + } + + private async Task Trigger(RunningRepeater rr) + { + var repeater = rr.Repeater; + + void ChannelMissingError() + { + rr.ErrorCount = int.MaxValue; + Log.Warning("[Repeater] Channel [{Channelid}] for not found or insufficient permissions. " + + "Repeater will be removed. ", + repeater.ChannelId); + } + + var channel = _client.GetChannel(repeater.ChannelId) as ITextChannel; + if (channel is null) + { + try { channel = await _client.Rest.GetChannelAsync(repeater.ChannelId) as ITextChannel; } + catch { } + } + + if (channel is null) + { + ChannelMissingError(); + return; + } + + var guild = _client.GetGuild(channel.GuildId); + if (guild is null) + { + ChannelMissingError(); + return; + } + + if (_noRedundant.Contains(repeater.Id)) + { + try + { + var lastMsgInChannel = await channel.GetMessagesAsync(2).Flatten().FirstAsync(); + if (lastMsgInChannel is not null && lastMsgInChannel.Id == repeater.LastMessageId) + return; + } + catch (Exception ex) + { + Log.Warning(ex, + "[Repeater] Error while getting last channel message in {GuildId}/{ChannelId} " + + "Bot probably doesn't have the permission to read message history", + guild.Id, + channel.Id); + } + } + + if (repeater.LastMessageId is { } lastMessageId) + { + try + { + var oldMsg = await channel.GetMessageAsync(lastMessageId); + if (oldMsg is not null) + await oldMsg.DeleteAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, + "[Repeater] Error while deleting previous message in {GuildId}/{ChannelId}", + guild.Id, + channel.Id); + } + } + + var rep = new ReplacementBuilder().WithDefault(guild.CurrentUser, channel, guild, _client).Build(); + + try + { + var text = SmartText.CreateFrom(repeater.Message); + text = rep.Replace(text); + + var newMsg = await channel.SendAsync(text); + _ = newMsg.AddReactionAsync(new Emoji("🔄")); + + if (_noRedundant.Contains(repeater.Id)) + { + await SetRepeaterLastMessageInternal(repeater.Id, newMsg.Id); + repeater.LastMessageId = newMsg.Id; + } + + rr.ErrorCount = 0; + } + catch (Exception ex) + { + Log.Error(ex, "[Repeater] Error sending repeat message ({ErrorCount})", rr.ErrorCount++); + } + } + + private async Task RemoveRepeaterInternal(Repeater r) + { + _noRedundant.TryRemove(r.Id); + + await using var uow = _db.GetDbContext(); + await uow.Set().DeleteAsync(x => x.Id == r.Id); + + await uow.SaveChangesAsync(); + } + + private RunningRepeater? RemoveFromQueue(int id) + { + lock (_queueLocker) + { + var node = _repeaterQueue.FindNode(x => x.Repeater.Id == id); + if (node is null) + return null; + + _repeaterQueue.Remove(node); + return node.Value; + } + } + + private async Task SetRepeaterLastMessageInternal(int repeaterId, ulong lastMsgId) + { + await using var uow = _db.GetDbContext(); + await uow.Set().AsQueryable() + .Where(x => x.Id == repeaterId) + .UpdateAsync(rep => new() + { + LastMessageId = lastMsgId + }); + } + + public async Task AddRepeaterAsync( + ulong channelId, + ulong guildId, + TimeSpan interval, + string message, + bool isNoRedundant, + TimeSpan? startTimeOfDay) + { + var rep = new Repeater + { + ChannelId = channelId, + GuildId = guildId, + Interval = interval, + Message = message, + NoRedundant = isNoRedundant, + LastMessageId = null, + StartTimeOfDay = startTimeOfDay, + DateAdded = DateTime.UtcNow + }; + + await using var uow = _db.GetDbContext(); + + if (await uow.Set().CountAsyncEF(x => x.GuildId == guildId) < MAX_REPEATERS) + uow.Set().Add(rep); + else + return null; + + await uow.SaveChangesAsync(); + + if (isNoRedundant) + _noRedundant.Add(rep.Id); + var runner = new RunningRepeater(rep); + AddToQueue(runner); + return runner; + } + + public async Task RemoveByIndexAsync(ulong guildId, int index) + { + if (index > MAX_REPEATERS * 2) + throw new ArgumentOutOfRangeException(nameof(index)); + + await using var uow = _db.GetDbContext(); + var toRemove = await uow.Set().AsNoTracking() + .Where(x => x.GuildId == guildId) + .Skip(index) + .FirstOrDefaultAsyncEF(); + + if (toRemove is null) + return null; + + // first try removing from queue because it can fail + // while triggering. Instruct user to try again + var removed = RemoveFromQueue(toRemove.Id); + if (removed is null) + return null; + + _noRedundant.TryRemove(toRemove.Id); + uow.Set().Remove(toRemove); + await uow.SaveChangesAsync(); + return removed; + } + + public IReadOnlyCollection GetRepeaters(ulong guildId) + { + lock (_queueLocker) + { + return _repeaterQueue.Where(x => x.Repeater.GuildId == guildId).ToList(); + } + } + + public async Task ToggleRedundantAsync(ulong guildId, int index) + { + await using var uow = _db.GetDbContext(); + var toToggle = await uow.Set().AsQueryable() + .Where(x => x.GuildId == guildId) + .Skip(index) + .FirstOrDefaultAsyncEF(); + + if (toToggle is null) + return null; + + var newValue = toToggle.NoRedundant = !toToggle.NoRedundant; + if (newValue) + _noRedundant.Add(toToggle.Id); + else + _noRedundant.TryRemove(toToggle.Id); + + await uow.SaveChangesAsync(); + return newValue; + } + + public async Task ToggleSkipNextAsync(ulong guildId, int index) + { + await using var ctx = _db.GetDbContext(); + var toSkip = await ctx.Set() + .Where(x => x.GuildId == guildId) + .Skip(index) + .FirstOrDefaultAsyncEF(); + + if (toSkip is null) + return null; + + if (_skipNext.Add(toSkip.Id)) + return true; + + _skipNext.TryRemove(toSkip.Id); + return false; + } + + public bool IsNoRedundant(int repeaterId) + => _noRedundant.Contains(repeaterId); + + public bool IsRepeaterSkipped(int repeaterId) + => _skipNext.Contains(repeaterId); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Repeater/RunningRepeater.cs b/src/Ellie.Bot.Modules.Utility/Repeater/RunningRepeater.cs new file mode 100644 index 0000000..513ac33 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Repeater/RunningRepeater.cs @@ -0,0 +1,92 @@ +#nullable disable +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Utility.Services; + +public sealed class RunningRepeater +{ + public DateTime NextTime { get; private set; } + + public Repeater Repeater { get; } + public int ErrorCount { get; set; } + + public RunningRepeater(Repeater repeater) + { + Repeater = repeater; + NextTime = CalculateInitialExecution(); + } + + public void UpdateNextTime() + => NextTime = DateTime.UtcNow + Repeater.Interval; + + private DateTime CalculateInitialExecution() + { + if (Repeater.StartTimeOfDay is not null) + { + // if there was a start time of day + // calculate whats the next time of day repeat should trigger at + // based on teh dateadded + + // i know this is not null because of the check in the query + var added = Repeater.DateAdded; + + // initial trigger was the time of day specified by the command. + var initialTriggerTimeOfDay = Repeater.StartTimeOfDay.Value; + + DateTime initialDateTime; + + // if added timeofday is less than specified timeofday for initial trigger + // that means the repeater first ran that same day at that exact specified time + if (added.TimeOfDay <= initialTriggerTimeOfDay) + // in that case, just add the difference to make sure the timeofday is the same + initialDateTime = added + (initialTriggerTimeOfDay - added.TimeOfDay); + else + // if not, then it ran at that time the following day + // in other words; Add one day, and subtract how much time passed since that time of day + initialDateTime = added + TimeSpan.FromDays(1) - (added.TimeOfDay - initialTriggerTimeOfDay); + + return CalculateInitialInterval(initialDateTime); + } + + // if repeater is not running daily, its initial time is the time it was Added at, plus the interval + return CalculateInitialInterval(Repeater.DateAdded + Repeater.Interval); + } + + /// + /// Calculate when is the proper time to run the repeater again based on initial time repeater ran. + /// + /// Initial time repeater ran at (or should run at). + private DateTime CalculateInitialInterval(DateTime initialDateTime) + { + // if the initial time is greater than now, that means the repeater didn't still execute a single time. + // just schedule it + if (initialDateTime > DateTime.UtcNow) + return initialDateTime; + + // else calculate based on minutes difference + + // get the difference + var diff = DateTime.UtcNow - initialDateTime; + + // see how many times the repeater theoretically ran already + var triggerCount = diff / Repeater.Interval; + + // ok lets say repeater was scheduled to run 10h ago. + // we have an interval of 2.4h + // repeater should've ran 4 times- that's 9.6h + // next time should be in 2h from now exactly + // 10/2.4 is 4.166 + // 4.166 - Math.Truncate(4.166) is 0.166 + // initial interval multiplier is 1 - 0.166 = 0.834 + // interval (2.4h) * 0.834 is 2.0016 and that is the initial interval + + var initialIntervalMultiplier = 1 - (triggerCount - Math.Truncate(triggerCount)); + return DateTime.UtcNow + (Repeater.Interval * initialIntervalMultiplier); + } + + public override bool Equals(object obj) + => obj is RunningRepeater rr && rr.Repeater.Id == Repeater.Id; + + public override int GetHashCode() + => Repeater.Id; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/StreamRole/StreamRoleCommands.cs b/src/Ellie.Bot.Modules.Utility/StreamRole/StreamRoleCommands.cs new file mode 100644 index 0000000..d595048 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/StreamRole/StreamRoleCommands.cs @@ -0,0 +1,97 @@ +#nullable disable +using Ellie.Modules.Utility.Common; +using Ellie.Modules.Utility.Services; + +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + public partial class StreamRoleCommands : EllieModule + { + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRole(IRole fromRole, IRole addRole) + { + await _service.SetStreamRole(fromRole, addRole); + + await ReplyConfirmLocalizedAsync(strs.stream_role_enabled(Format.Bold(fromRole.ToString()), + Format.Bold(addRole.ToString()))); + } + + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRole() + { + await _service.StopStreamRole(ctx.Guild); + await ReplyConfirmLocalizedAsync(strs.stream_role_disabled); + } + + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRoleKeyword([Leftover] string keyword = null) + { + var kw = await _service.SetKeyword(ctx.Guild, keyword); + + if (string.IsNullOrWhiteSpace(keyword)) + await ReplyConfirmLocalizedAsync(strs.stream_role_kw_reset); + else + await ReplyConfirmLocalizedAsync(strs.stream_role_kw_set(Format.Bold(kw))); + } + + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRoleBlacklist(AddRemove action, [Leftover] IGuildUser user) + { + var success = await _service.ApplyListAction(StreamRoleListType.Blacklist, + ctx.Guild, + action, + user.Id, + user.ToString()); + + if (action == AddRemove.Add) + { + if (success) + await ReplyConfirmLocalizedAsync(strs.stream_role_bl_add(Format.Bold(user.ToString()))); + else + await ReplyConfirmLocalizedAsync(strs.stream_role_bl_add_fail(Format.Bold(user.ToString()))); + } + else if (success) + await ReplyConfirmLocalizedAsync(strs.stream_role_bl_rem(Format.Bold(user.ToString()))); + else + await ReplyErrorLocalizedAsync(strs.stream_role_bl_rem_fail(Format.Bold(user.ToString()))); + } + + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRoleWhitelist(AddRemove action, [Leftover] IGuildUser user) + { + var success = await _service.ApplyListAction(StreamRoleListType.Whitelist, + ctx.Guild, + action, + user.Id, + user.ToString()); + + if (action == AddRemove.Add) + { + if (success) + await ReplyConfirmLocalizedAsync(strs.stream_role_wl_add(Format.Bold(user.ToString()))); + else + await ReplyConfirmLocalizedAsync(strs.stream_role_wl_add_fail(Format.Bold(user.ToString()))); + } + else if (success) + await ReplyConfirmLocalizedAsync(strs.stream_role_wl_rem(Format.Bold(user.ToString()))); + else + await ReplyErrorLocalizedAsync(strs.stream_role_wl_rem_fail(Format.Bold(user.ToString()))); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/StreamRole/StreamRoleService.cs b/src/Ellie.Bot.Modules.Utility/StreamRole/StreamRoleService.cs new file mode 100644 index 0000000..089a244 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/StreamRole/StreamRoleService.cs @@ -0,0 +1,339 @@ +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Modules.Utility.Common; +using Ellie.Modules.Utility.Common.Exceptions; +using Ellie.Services.Database.Models; +using System.Net; + +namespace Ellie.Modules.Utility.Services; + +public class StreamRoleService : IReadyExecutor, IEService +{ + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly ConcurrentDictionary _guildSettings; + private readonly QueueRunner _queueRunner; + + public StreamRoleService(DiscordSocketClient client, DbService db, IBot bot) + { + _db = db; + _client = client; + + _guildSettings = bot.AllGuildConfigs.ToDictionary(x => x.GuildId, x => x.StreamRole) + .Where(x => x.Value is { Enabled: true }) + .ToConcurrent(); + + _client.PresenceUpdated += OnPresenceUpdate; + + _queueRunner = new QueueRunner(); + } + + private Task OnPresenceUpdate(SocketUser user, SocketPresence? oldPresence, SocketPresence? newPresence) + { + + _ = Task.Run(async () => + { + if (oldPresence?.Activities?.Count != newPresence?.Activities?.Count) + { + var guildUsers = _client.Guilds + .Select(x => x.GetUser(user.Id)) + .Where(x => x is not null); + + foreach (var guildUser in guildUsers) + { + if (_guildSettings.TryGetValue(guildUser.Guild.Id, out var s)) + await RescanUser(guildUser, s); + } + } + }); + + return Task.CompletedTask; + } + + public Task OnReadyAsync() + => Task.WhenAll(_client.Guilds.Select(RescanUsers).WhenAll(), _queueRunner.RunAsync()); + + /// + /// Adds or removes a user from a blacklist or a whitelist in the specified guild. + /// + /// List type + /// Guild + /// Add or rem action + /// User's Id + /// User's name#discrim + /// Whether the operation was successful + public async Task ApplyListAction( + StreamRoleListType listType, + IGuild guild, + AddRemove action, + ulong userId, + string userName) + { + ArgumentNullException.ThrowIfNull(userName, nameof(userName)); + + var success = false; + await using (var uow = _db.GetDbContext()) + { + var streamRoleSettings = uow.GetStreamRoleSettings(guild.Id); + + if (listType == StreamRoleListType.Whitelist) + { + var userObj = new StreamRoleWhitelistedUser + { + UserId = userId, + Username = userName + }; + + if (action == AddRemove.Rem) + { + var toDelete = streamRoleSettings.Whitelist.FirstOrDefault(x => x.Equals(userObj)); + if (toDelete is not null) + { + uow.Remove(toDelete); + success = true; + } + } + else + success = streamRoleSettings.Whitelist.Add(userObj); + } + else + { + var userObj = new StreamRoleBlacklistedUser + { + UserId = userId, + Username = userName + }; + + if (action == AddRemove.Rem) + { + var toRemove = streamRoleSettings.Blacklist.FirstOrDefault(x => x.Equals(userObj)); + if (toRemove is not null) + success = streamRoleSettings.Blacklist.Remove(toRemove); + } + else + success = streamRoleSettings.Blacklist.Add(userObj); + } + + await uow.SaveChangesAsync(); + UpdateCache(guild.Id, streamRoleSettings); + } + + if (success) + await RescanUsers(guild); + return success; + } + + /// + /// Sets keyword on a guild and updates the cache. + /// + /// Guild Id + /// Keyword to set + /// The keyword set + public async Task SetKeyword(IGuild guild, string? keyword) + { + keyword = keyword?.Trim().ToLowerInvariant(); + + await using (var uow = _db.GetDbContext()) + { + var streamRoleSettings = uow.GetStreamRoleSettings(guild.Id); + + streamRoleSettings.Keyword = keyword; + UpdateCache(guild.Id, streamRoleSettings); + await uow.SaveChangesAsync(); + } + + await RescanUsers(guild); + return keyword; + } + + /// + /// Gets the currently set keyword on a guild. + /// + /// Guild Id + /// The keyword set + public string GetKeyword(ulong guildId) + { + if (_guildSettings.TryGetValue(guildId, out var outSetting)) + return outSetting.Keyword; + + StreamRoleSettings setting; + using (var uow = _db.GetDbContext()) + { + setting = uow.GetStreamRoleSettings(guildId); + } + + UpdateCache(guildId, setting); + + return setting.Keyword; + } + + /// + /// Sets the role to monitor, and a role to which to add to + /// the user who starts streaming in the monitored role. + /// + /// Role to monitor + /// Role to add to the user + public async Task SetStreamRole(IRole fromRole, IRole addRole) + { + ArgumentNullException.ThrowIfNull(fromRole, nameof(fromRole)); + ArgumentNullException.ThrowIfNull(addRole, nameof(addRole)); + + StreamRoleSettings setting; + await using (var uow = _db.GetDbContext()) + { + var streamRoleSettings = uow.GetStreamRoleSettings(fromRole.Guild.Id); + + streamRoleSettings.Enabled = true; + streamRoleSettings.AddRoleId = addRole.Id; + streamRoleSettings.FromRoleId = fromRole.Id; + + setting = streamRoleSettings; + await uow.SaveChangesAsync(); + } + + UpdateCache(fromRole.Guild.Id, setting); + + foreach (var usr in await fromRole.GetMembersAsync()) + { + await RescanUser(usr, setting, addRole); + } + } + + /// + /// Stops the stream role feature on the specified guild. + /// + /// Guild + /// Whether to rescan users + public async Task StopStreamRole(IGuild guild, bool cleanup = false) + { + await using (var uow = _db.GetDbContext()) + { + var streamRoleSettings = uow.GetStreamRoleSettings(guild.Id); + streamRoleSettings.Enabled = false; + streamRoleSettings.AddRoleId = 0; + streamRoleSettings.FromRoleId = 0; + await uow.SaveChangesAsync(); + } + + if (_guildSettings.TryRemove(guild.Id, out _) && cleanup) + await RescanUsers(guild); + } + + private async ValueTask RescanUser(IGuildUser user, StreamRoleSettings setting, IRole? addRole = null) + => await _queueRunner.EnqueueAsync(() => RescanUserInternal(user, setting, addRole)); + + private async Task RescanUserInternal(IGuildUser user, StreamRoleSettings setting, IRole? addRole = null) + { + if (user.IsBot) + return; + + var g = (StreamingGame?)user.Activities.FirstOrDefault(a + => a is StreamingGame + && (string.IsNullOrWhiteSpace(setting.Keyword) + || a.Name.ToUpperInvariant().Contains(setting.Keyword.ToUpperInvariant()) + || setting.Whitelist.Any(x => x.UserId == user.Id))); + + if (g is not null + && setting.Enabled + && setting.Blacklist.All(x => x.UserId != user.Id) + && user.RoleIds.Contains(setting.FromRoleId)) + { + await _queueRunner.EnqueueAsync(async () => + { + try + { + addRole ??= user.Guild.GetRole(setting.AddRoleId); + if (addRole is null) + { + await StopStreamRole(user.Guild); + Log.Warning("Stream role in server {RoleId} no longer exists. Stopping", setting.AddRoleId); + return; + } + + //check if he doesn't have addrole already, to avoid errors + if (!user.RoleIds.Contains(addRole.Id)) + { + await user.AddRoleAsync(addRole); + Log.Information("Added stream role to user {User} in {Server} server", + user.ToString(), + user.Guild.ToString()); + } + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + await StopStreamRole(user.Guild); + Log.Warning(ex, "Error adding stream role(s). Forcibly disabling stream role feature"); + throw new StreamRolePermissionException(); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed adding stream role"); + } + }); + } + else + { + //check if user is in the addrole + if (user.RoleIds.Contains(setting.AddRoleId)) + { + await _queueRunner.EnqueueAsync(async () => + { + try + { + addRole ??= user.Guild.GetRole(setting.AddRoleId); + if (addRole is null) + { + await StopStreamRole(user.Guild); + Log.Warning( + "Addrole doesn't exist in {GuildId} server. Forcibly disabling stream role feature", + user.Guild.Id); + return; + } + + // need to check again in case queuer is taking too long to execute + if (user.RoleIds.Contains(setting.AddRoleId)) + { + await user.RemoveRoleAsync(addRole); + } + + Log.Information("Removed stream role from the user {User} in {Server} server", + user.ToString(), + user.Guild.ToString()); + } + catch (HttpException ex) + { + if (ex.HttpCode == HttpStatusCode.Forbidden) + { + await StopStreamRole(user.Guild); + Log.Warning(ex, "Error removing stream role(s). Forcibly disabling stream role feature"); + } + } + }); + } + } + } + + private async Task RescanUsers(IGuild guild) + { + if (!_guildSettings.TryGetValue(guild.Id, out var setting)) + return; + + var addRole = guild.GetRole(setting.AddRoleId); + if (addRole is null) + return; + + if (setting.Enabled) + { + var users = await guild.GetUsersAsync(CacheMode.CacheOnly); + foreach (var usr in users.Where(x + => x.RoleIds.Contains(setting.FromRoleId) || x.RoleIds.Contains(addRole.Id))) + { + if (usr is { } x) + await RescanUser(x, setting, addRole); + } + } + } + + private void UpdateCache(ulong guildId, StreamRoleSettings setting) + => _guildSettings.AddOrUpdate(guildId, _ => setting, (_, _) => setting); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/UnitConversion/ConverterService.cs b/src/Ellie.Bot.Modules.Utility/UnitConversion/ConverterService.cs new file mode 100644 index 0000000..cc9a153 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/UnitConversion/ConverterService.cs @@ -0,0 +1,99 @@ +#nullable disable +using Ellie.Common.ModuleBehaviors; +using Ellie.Modules.Utility.Common; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ellie.Modules.Utility.Services; + +public class ConverterService : IEService, IReadyExecutor +{ + private static readonly TypedKey> _convertKey = + new("convert:units"); + + private readonly TimeSpan _updateInterval = new(12, 0, 0); + private readonly DiscordSocketClient _client; + private readonly IBotCache _cache; + private readonly IHttpClientFactory _httpFactory; + + public ConverterService( + DiscordSocketClient client, + IBotCache cache, + IHttpClientFactory factory) + { + _client = client; + _cache = cache; + _httpFactory = factory; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(_updateInterval); + do + { + try + { + await UpdateCurrency(); + } + catch + { + // ignored + } + } while (await timer.WaitForNextTickAsync()); + } + + private async Task GetCurrencyRates() + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync("https://convertapi.nadeko.bot/latest"); + return JsonSerializer.Deserialize(res); + } + + private async Task UpdateCurrency() + { + var unitTypeString = "currency"; + var currencyRates = await GetCurrencyRates(); + var baseType = new ConvertUnit + { + Triggers = new[] { currencyRates.Base }, + Modifier = decimal.One, + UnitType = unitTypeString + }; + var units = currencyRates.ConversionRates.Select(u => new ConvertUnit + { + Triggers = new[] { u.Key }, + Modifier = u.Value, + UnitType = unitTypeString + }) + .ToList(); + + var stream = File.OpenRead("data/units.json"); + var defaultUnits = await JsonSerializer.DeserializeAsync(stream); + if(defaultUnits is not null) + units.AddRange(defaultUnits); + + units.Add(baseType); + + await _cache.AddAsync(_convertKey, units); + } + + public async Task> GetUnitsAsync() + => (await _cache.GetAsync(_convertKey)).TryGetValue(out var list) + ? list + : Array.Empty(); +} + +public class Rates +{ + [JsonPropertyName("base")] + public string Base { get; set; } + + [JsonPropertyName("date")] + public DateTime Date { get; set; } + + [JsonPropertyName("rates")] + public Dictionary ConversionRates { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/UnitConversion/UnitConversionCommands.cs b/src/Ellie.Bot.Modules.Utility/UnitConversion/UnitConversionCommands.cs new file mode 100644 index 0000000..87edf8e --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/UnitConversion/UnitConversionCommands.cs @@ -0,0 +1,96 @@ +#nullable disable +using Ellie.Modules.Utility.Services; + +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class UnitConverterCommands : EllieModule + { + [Cmd] + public async Task ConvertList() + { + var units = await _service.GetUnitsAsync(); + + var embed = _eb.Create().WithTitle(GetText(strs.convertlist)).WithOkColor(); + + + foreach (var g in units.GroupBy(x => x.UnitType)) + { + embed.AddField(g.Key.ToTitleCase(), + string.Join(", ", g.Select(x => x.Triggers.FirstOrDefault()).OrderBy(x => x))); + } + + await ctx.Channel.EmbedAsync(embed); + } + + [Cmd] + [Priority(0)] + public async Task Convert(string origin, string target, decimal value) + { + var units = await _service.GetUnitsAsync(); + var originUnit = units.FirstOrDefault(x + => x.Triggers.Select(y => y.ToUpperInvariant()).Contains(origin.ToUpperInvariant())); + var targetUnit = units.FirstOrDefault(x + => x.Triggers.Select(y => y.ToUpperInvariant()).Contains(target.ToUpperInvariant())); + if (originUnit is null || targetUnit is null) + { + await ReplyErrorLocalizedAsync(strs.convert_not_found(Format.Bold(origin), Format.Bold(target))); + return; + } + + if (originUnit.UnitType != targetUnit.UnitType) + { + await ReplyErrorLocalizedAsync(strs.convert_type_error(Format.Bold(originUnit.Triggers.First()), + Format.Bold(targetUnit.Triggers.First()))); + return; + } + + decimal res; + if (originUnit.Triggers == targetUnit.Triggers) + res = value; + else if (originUnit.UnitType == "temperature") + { + //don't really care too much about efficiency, so just convert to Kelvin, then to target + switch (originUnit.Triggers.First().ToUpperInvariant()) + { + case "C": + res = value + 273.15m; //celcius! + break; + case "F": + res = (value + 459.67m) * (5m / 9m); + break; + default: + res = value; + break; + } + + //from Kelvin to target + switch (targetUnit.Triggers.First().ToUpperInvariant()) + { + case "C": + res -= 273.15m; //celcius! + break; + case "F": + res = (res * (9m / 5m)) - 459.67m; + break; + } + } + else + { + if (originUnit.UnitType == "currency") + res = value * targetUnit.Modifier / originUnit.Modifier; + else + res = value * originUnit.Modifier / targetUnit.Modifier; + } + + res = Math.Round(res, 4); + + await SendConfirmAsync(GetText(strs.convert(value, + originUnit.Triggers.Last(), + res, + targetUnit.Triggers.Last()))); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/Utility.cs b/src/Ellie.Bot.Modules.Utility/Utility.cs new file mode 100644 index 0000000..acd74ef --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/Utility.cs @@ -0,0 +1,640 @@ +#nullable disable +using Ellie.Modules.Utility.Services; +using Newtonsoft.Json; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Ellie.Modules.Searches.Common; + +namespace Ellie.Modules.Utility; + +public partial class Utility : EllieModule +{ + public enum CreateInviteType + { + Any, + New + } + + public enum MeOrBot { Me, Bot } + + private static readonly JsonSerializerOptions _showEmbedSerializerOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = LowerCaseNamingPolicy.Default + }; + + private static SemaphoreSlim sem = new(1, 1); + private readonly DiscordSocketClient _client; + private readonly ICoordinator _coord; + private readonly IStatsService _stats; + private readonly IBotCredentials _creds; + private readonly DownloadTracker _tracker; + private readonly IHttpClientFactory _httpFactory; + private readonly VerboseErrorsService _veService; + + public Utility( + DiscordSocketClient client, + ICoordinator coord, + IStatsService stats, + IBotCredentials creds, + DownloadTracker tracker, + IHttpClientFactory httpFactory, + VerboseErrorsService veService) + { + _client = client; + _coord = coord; + _stats = stats; + _creds = creds; + _tracker = tracker; + _httpFactory = httpFactory; + _veService = veService; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Say(ITextChannel channel, [Leftover] SmartText message) + { + var rep = new ReplacementBuilder() + .WithDefault(ctx.User, channel, (SocketGuild)ctx.Guild, (DiscordSocketClient)ctx.Client) + .Build(); + + message = rep.Replace(message); + + await channel.SendAsync(message, !((IGuildUser)ctx.User).GuildPermissions.MentionEveryone); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Say([Leftover] SmartText message) + => Say((ITextChannel)ctx.Channel, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WhosPlaying([Leftover] string game) + { + game = game?.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(game)) + return; + + if (ctx.Guild is not SocketGuild socketGuild) + { + Log.Warning("Can't cast guild to socket guild"); + return; + } + + var rng = new EllieRandom(); + var arr = await Task.Run(() => socketGuild.Users + .Where(u => u.Activities.FirstOrDefault()?.Name?.Trim().ToUpperInvariant() + == game) + .Select(u => u.Username) + .OrderBy(_ => rng.Next()) + .Take(60) + .ToArray()); + + var i = 0; + if (arr.Length == 0) + await ReplyErrorLocalizedAsync(strs.nobody_playing_game); + else + { + await SendConfirmAsync("```css\n" + + string.Join("\n", + arr.GroupBy(_ => i++ / 2) + .Select(ig => string.Concat(ig.Select(el => $"• {el,-27}")))) + + "\n```"); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task InRole(int page, [Leftover] IRole role = null) + { + if (--page < 0) + return; + + await ctx.Channel.TriggerTypingAsync(); + await _tracker.EnsureUsersDownloadedAsync(ctx.Guild); + + var users = await ctx.Guild.GetUsersAsync( + CacheMode.CacheOnly + ); + + var roleUsers = users.Where(u => role is null ? u.RoleIds.Count == 1 : u.RoleIds.Contains(role.Id)) + .Select(u => $"`{u.Id,18}` {u}") + .ToArray(); + + await ctx.SendPaginatedConfirmAsync(page, + cur => + { + var pageUsers = roleUsers.Skip(cur * 20).Take(20).ToList(); + + if (pageUsers.Count == 0) + return _eb.Create().WithOkColor().WithDescription(GetText(strs.no_user_on_this_page)); + + return _eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.inrole_list(Format.Bold(role?.Name ?? "No Role"), roleUsers.Length))) + .WithDescription(string.Join("\n", pageUsers)); + }, + roleUsers.Length, + 20); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task InRole([Leftover] IRole role = null) + => InRole(1, role); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task CheckPerms(MeOrBot who = MeOrBot.Me) + { + var builder = new StringBuilder(); + var user = who == MeOrBot.Me ? (IGuildUser)ctx.User : ((SocketGuild)ctx.Guild).CurrentUser; + var perms = user.GetPermissions((ITextChannel)ctx.Channel); + foreach (var p in perms.GetType() + .GetProperties() + .Where(static p => + { + var method = p.GetGetMethod(); + if (method is null) + return false; + return !method.GetParameters().Any(); + })) + builder.AppendLine($"{p.Name} : {p.GetValue(perms, null)}"); + await SendConfirmAsync(builder.ToString()); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task UserId([Leftover] IGuildUser target = null) + { + var usr = target ?? ctx.User; + await ReplyConfirmLocalizedAsync(strs.userid("🆔", + Format.Bold(usr.ToString()), + Format.Code(usr.Id.ToString()))); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RoleId([Leftover] IRole role) + => await ReplyConfirmLocalizedAsync(strs.roleid("🆔", + Format.Bold(role.ToString()), + Format.Code(role.Id.ToString()))); + + [Cmd] + public async Task ChannelId() + => await ReplyConfirmLocalizedAsync(strs.channelid("🆔", Format.Code(ctx.Channel.Id.ToString()))); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ServerId() + => await ReplyConfirmLocalizedAsync(strs.serverid("🆔", Format.Code(ctx.Guild.Id.ToString()))); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Roles(IGuildUser target, int page = 1) + { + var guild = ctx.Guild; + + const int rolesPerPage = 20; + + if (page is < 1 or > 100) + return; + + if (target is not null) + { + var roles = target.GetRoles() + .Except(new[] { guild.EveryoneRole }) + .OrderBy(r => -r.Position) + .Skip((page - 1) * rolesPerPage) + .Take(rolesPerPage) + .ToArray(); + if (!roles.Any()) + await ReplyErrorLocalizedAsync(strs.no_roles_on_page); + else + { + await SendConfirmAsync(GetText(strs.roles_page(page, Format.Bold(target.ToString()))), + "\n• " + string.Join("\n• ", (IEnumerable)roles).SanitizeMentions(true)); + } + } + else + { + var roles = guild.Roles.Except(new[] { guild.EveryoneRole }) + .OrderBy(r => -r.Position) + .Skip((page - 1) * rolesPerPage) + .Take(rolesPerPage) + .ToArray(); + if (!roles.Any()) + await ReplyErrorLocalizedAsync(strs.no_roles_on_page); + else + { + await SendConfirmAsync(GetText(strs.roles_all_page(page)), + "\n• " + string.Join("\n• ", (IEnumerable)roles).SanitizeMentions(true)); + } + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Roles(int page = 1) + => Roles(null, page); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChannelTopic([Leftover] ITextChannel channel = null) + { + if (channel is null) + channel = (ITextChannel)ctx.Channel; + + var topic = channel.Topic; + if (string.IsNullOrWhiteSpace(topic)) + await ReplyErrorLocalizedAsync(strs.no_topic_set); + else + await SendConfirmAsync(GetText(strs.channel_topic), topic); + } + + [Cmd] + public async Task Stats() + { + var ownerIds = string.Join("\n", _creds.OwnerIds); + if (string.IsNullOrWhiteSpace(ownerIds)) + ownerIds = "-"; + + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithAuthor($"Ellie v{StatsService.BOT_VERSION}", + "https://ellie.nyc3.cdn.digitaloceanspaces.com/other/Ellie.png", + "https://docs.elliebot.net") + .AddField(GetText(strs.author), _stats.Author, true) + .AddField(GetText(strs.botid), _client.CurrentUser.Id.ToString(), true) + .AddField(GetText(strs.shard), + $"#{_client.ShardId} / {_creds.TotalShards}", + true) + .AddField(GetText(strs.commands_ran), _stats.CommandsRan.ToString(), true) + .AddField(GetText(strs.messages), + $"{_stats.MessageCounter} ({_stats.MessagesPerSecond:F2}/sec)", + true) + .AddField(GetText(strs.memory), + FormattableString.Invariant($"{_stats.GetPrivateMemoryMegabytes():F2} MB"), + true) + .AddField(GetText(strs.owner_ids), ownerIds, true) + .AddField(GetText(strs.uptime), _stats.GetUptimeString("\n"), true) + .AddField(GetText(strs.presence), + GetText(strs.presence_txt(_coord.GetGuildCount(), + _stats.TextChannels, + _stats.VoiceChannels)), + true)); + } + + [Cmd] + public async Task + Showemojis([Leftover] string _) // need to have the parameter so that the message.tags gets populated + { + var tags = ctx.Message.Tags.Where(t => t.Type == TagType.Emoji).Select(t => (Emote)t.Value); + + var result = string.Join("\n", tags.Select(m => GetText(strs.showemojis(m, m.Url)))); + + if (string.IsNullOrWhiteSpace(result)) + await ReplyErrorLocalizedAsync(strs.showemojis_none); + else + await ctx.Channel.SendMessageAsync(result.TrimTo(2000)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + [Priority(2)] + public Task EmojiAdd(string name, Emote emote) + => EmojiAdd(name, emote.Url); + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + [Priority(1)] + public Task EmojiAdd(Emote emote) + => EmojiAdd(emote.Name, emote.Url); + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + [Priority(0)] + public async Task EmojiAdd(string name, string url = null) + { + name = name.Trim(':'); + + url ??= ctx.Message.Attachments.FirstOrDefault()?.Url; + + if (url is null) + return; + + using var http = _httpFactory.CreateClient(); + using var res = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + if (!res.IsImage() || res.GetContentLength() > 262_144) + { + await ReplyErrorLocalizedAsync(strs.invalid_emoji_link); + return; + } + + await using var imgStream = await res.Content.ReadAsStreamAsync(); + Emote em; + try + { + em = await ctx.Guild.CreateEmoteAsync(name, new(imgStream)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error adding emoji on server {GuildId}", ctx.Guild.Id); + + await ReplyErrorLocalizedAsync(strs.emoji_add_error); + return; + } + + await ConfirmLocalizedAsync(strs.emoji_added(em.ToString())); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + [Priority(0)] + public async Task EmojiRemove(params Emote[] emotes) + { + if (emotes.Length == 0) + return; + + var g = (SocketGuild)ctx.Guild; + + var fails = new List(); + foreach (var emote in emotes) + { + var guildEmote = g.Emotes.FirstOrDefault(x => x.Id == emote.Id); + if (guildEmote is null) + { + fails.Add(emote); + } + else + { + await ctx.Guild.DeleteEmoteAsync(guildEmote); + } + } + + if (fails.Count > 0) + { + await ReplyPendingLocalizedAsync(strs.emoji_not_removed(fails.Select(x => x.ToString()).Join(" "))); + return; + } + + await ctx.OkAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + public async Task StickerAdd(string name = null, string description = null, params string[] tags) + { + string format; + Stream stream; + + if (ctx.Message.Stickers.Count is 1 && ctx.Message.Stickers.First() is SocketSticker ss) + { + name ??= ss.Name; + description = ss.Description; + tags = tags is null or { Length: 0 } ? ss.Tags.ToArray() : tags; + format = FormatToExtension(ss.Format); + + using var http = _httpFactory.CreateClient(); + stream = await http.GetStreamAsync(ss.GetStickerUrl()); + } + // else if (ctx.Message.Attachments.FirstOrDefault() is { } attachment) + // { + // var url = attachment?.Url; + // + // if (url is null) + // return; + // + // if (name is null) + // { + // await ReplyErrorLocalizedAsync(strs.sticker_missing_name); + // return; + // } + // + // format = Path.GetExtension(attachment.Filename); + // + // if (attachment is not { Width: 300, Height: 300 }) + // { + // await ReplyErrorLocalizedAsync(strs.sticker_invalid_size); + // return; + // } + // + // using var http = _httpFactory.CreateClient(); + // + // using var res = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + // if (res.GetContentLength() > 512.Kilobytes().Bytes) + // { + // await ReplyErrorLocalizedAsync(strs.invalid_emoji_link); + // return; + // } + // + // stream = await res.Content.ReadAsStreamAsync(); + // } + else + { + await ReplyErrorLocalizedAsync(strs.sticker_error); + return; + } + + try + { + if (tags.Length == 0) + tags = new[] { name }; + + await ctx.Guild.CreateStickerAsync(name, + stream, + $"{name}.{format}", + tags, + description: string.IsNullOrWhiteSpace(description) ? "Missing description" : description + ); + + await ctx.OkAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error occurred while adding a sticker: {Message}", ex.Message); + await ReplyErrorLocalizedAsync(strs.error_occured); + } + finally + { + await stream.DisposeAsync(); + } + } + + private static string FormatToExtension(StickerFormatType format) + { + switch (format) + { + case StickerFormatType.None: + case StickerFormatType.Png: + case StickerFormatType.Apng: + return "png"; + case StickerFormatType.Lottie: + return "lottie"; + default: + throw new ArgumentException(nameof (format)); + } + } + + [Cmd] + [OwnerOnly] + public async Task ListServers(int page = 1) + { + page -= 1; + + if (page < 0) + return; + + var guilds = _client.Guilds.OrderBy(g => g.Name) + .Skip(page * 15) + .Take(15) + .ToList(); + + if (!guilds.Any()) + { + await ReplyErrorLocalizedAsync(strs.listservers_none); + return; + } + + var embed = _eb.Create().WithOkColor(); + foreach (var guild in guilds) + embed.AddField(guild.Name, GetText(strs.listservers(guild.Id, guild.MemberCount, guild.OwnerId))); + + await ctx.Channel.EmbedAsync(embed); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task ShowEmbed(ulong messageId) + => ShowEmbed((ITextChannel)ctx.Channel, messageId); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ShowEmbed(ITextChannel ch, ulong messageId) + { + var user = (IGuildUser)ctx.User; + var perms = user.GetPermissions(ch); + if (!perms.ReadMessageHistory || !perms.ViewChannel) + { + await ReplyErrorLocalizedAsync(strs.insuf_perms_u); + return; + } + + var msg = await ch.GetMessageAsync(messageId); + if (msg is null) + { + await ReplyErrorLocalizedAsync(strs.msg_not_found); + return; + } + + if (!msg.Embeds.Any()) + { + await ReplyErrorLocalizedAsync(strs.not_found); + return; + } + + var json = new SmartEmbedTextArray() + { + Content = msg.Content, + Embeds = msg.Embeds + .Map(x => new SmartEmbedArrayElementText(x)) + }.ToJson(_showEmbedSerializerOptions); + + await SendConfirmAsync(Format.Code(json, "json").Replace("](", "]\\(")); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task SaveChat(int cnt) + { + var msgs = new List(cnt); + await ctx.Channel.GetMessagesAsync(cnt).ForEachAsync(dled => msgs.AddRange(dled)); + + var title = $"Chatlog-{ctx.Guild.Name}/#{ctx.Channel.Name}-{DateTime.Now}.txt"; + var grouping = msgs.GroupBy(x => $"{x.CreatedAt.Date:dd.MM.yyyy}") + .Select(g => new + { + date = g.Key, + messages = g.OrderBy(x => x.CreatedAt) + .Select(s => + { + var msg = $"【{s.Timestamp:HH:mm:ss}】{s.Author}:"; + if (string.IsNullOrWhiteSpace(s.ToString())) + { + if (s.Attachments.Any()) + { + msg += "FILES_UPLOADED: " + + string.Join("\n", s.Attachments.Select(x => x.Url)); + } + else if (s.Embeds.Any()) + { + msg += "EMBEDS: " + + string.Join("\n--------\n", + s.Embeds.Select(x + => $"Description: {x.Description}")); + } + } + else + msg += s.ToString(); + + return msg; + }) + }); + await using var stream = await JsonConvert.SerializeObject(grouping, Formatting.Indented).ToStream(); + await ctx.User.SendFileAsync(stream, title, title); + } + + [Cmd] + public async Task Ping() + { + await sem.WaitAsync(5000); + try + { + var sw = Stopwatch.StartNew(); + var msg = await ctx.Channel.SendMessageAsync("🏓"); + sw.Stop(); + msg.DeleteAfter(0); + + await SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} 🏓 {(int)sw.Elapsed.TotalMilliseconds}ms"); + } + finally + { + sem.Release(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task VerboseError(bool? newstate = null) + { + var state = _veService.ToggleVerboseErrors(ctx.Guild.Id, newstate); + + if (state) + await ReplyConfirmLocalizedAsync(strs.verbose_errors_enabled); + else + await ReplyConfirmLocalizedAsync(strs.verbose_errors_disabled); + } +} diff --git a/src/Ellie.Bot.Modules.Utility/VerboseErrors/EvalCommands.cs b/src/Ellie.Bot.Modules.Utility/VerboseErrors/EvalCommands.cs new file mode 100644 index 0000000..809df6a --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/VerboseErrors/EvalCommands.cs @@ -0,0 +1,76 @@ +#nullable disable +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + +namespace Ellie.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class EvalCommands : EllieModule + { + private readonly IServiceProvider _services; + + public EvalCommands(IServiceProvider services) + { + _services = services; + } + + [Cmd] + [NoPublicBot] + [OwnerOnly] + public async Task Eval([Leftover] string scriptText) + { + _ = ctx.Channel.TriggerTypingAsync(); + + if (scriptText.StartsWith("```cs")) + scriptText = scriptText[5..]; + else if (scriptText.StartsWith("```")) + scriptText = scriptText[3..]; + + if (scriptText.EndsWith("```")) + scriptText = scriptText[..^3]; + + var script = CSharpScript.Create(scriptText, + ScriptOptions.Default + .WithReferences(this.GetType().Assembly) + .WithImports( + "System", + "Ellie", + "Ellie.Extensions", + "Microsoft.Extensions.DependencyInjection", + "Ellie.Common", + "System.Text", + "System.Text.Json"), + globalsType: typeof(EvalGlobals)); + + try + { + var result = await script.RunAsync(new EvalGlobals() + { + ctx = this.ctx, + guild = this.ctx.Guild, + channel = this.ctx.Channel, + user = this.ctx.User, + self = this, + services = _services + }); + + var output = result.ReturnValue?.ToString(); + if (!string.IsNullOrWhiteSpace(output)) + { + var eb = _eb.Create(ctx) + .WithOkColor() + .AddField("Code", scriptText) + .AddField("Output", output.TrimTo(512)!); + + _ = ctx.Channel.EmbedAsync(eb); + } + } + catch (Exception ex) + { + await SendErrorAsync(ex.Message); + } + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/VerboseErrors/EvalGlobals.cs b/src/Ellie.Bot.Modules.Utility/VerboseErrors/EvalGlobals.cs new file mode 100644 index 0000000..d3169b6 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/VerboseErrors/EvalGlobals.cs @@ -0,0 +1,13 @@ +// ReSharper disable InconsistentNaming +#nullable disable +namespace Ellie.Modules.Utility; + +public class EvalGlobals +{ + public ICommandContext ctx; + public Utility.EvalCommands self; + public IUser user; + public IMessageChannel channel; + public IGuild guild; + public IServiceProvider services; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/VerboseErrorsService.cs b/src/Ellie.Bot.Modules.Utility/VerboseErrorsService.cs new file mode 100644 index 0000000..03acb63 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/VerboseErrorsService.cs @@ -0,0 +1,69 @@ +#nullable disable +using Ellie.Db; + +namespace Ellie.Modules.Utility.Services; + +public class VerboseErrorsService : IEService +{ + private readonly ConcurrentHashSet _guildsDisabled; + private readonly DbService _db; + private readonly CommandHandler _ch; + private readonly ICommandsUtilityService _hs; + + public VerboseErrorsService( + IBot bot, + DbService db, + CommandHandler ch, + ICommandsUtilityService hs) + { + _db = db; + _ch = ch; + _hs = hs; + + _ch.CommandErrored += LogVerboseError; + + _guildsDisabled = new(bot.AllGuildConfigs.Where(x => !x.VerboseErrors).Select(x => x.GuildId)); + } + + private async Task LogVerboseError(CommandInfo cmd, ITextChannel channel, string reason) + { + if (channel is null || _guildsDisabled.Contains(channel.GuildId)) + return; + + try + { + var embed = _hs.GetCommandHelp(cmd, channel.Guild) + .WithTitle("Command Error") + .WithDescription(reason) + .WithFooter("Admin may disable verbose errors via `.ve` command") + .WithErrorColor(); + + await channel.EmbedAsync(embed); + } + catch + { + Log.Information("Verbose error wasn't able to be sent to the server: {GuildId}", + channel.GuildId); + } + } + + public bool ToggleVerboseErrors(ulong guildId, bool? maybeEnabled = null) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + + if (maybeEnabled is bool isEnabled) // set it + gc.VerboseErrors = isEnabled; + else // toggle it + isEnabled = gc.VerboseErrors = !gc.VerboseErrors; + + uow.SaveChanges(); + + if (isEnabled) // This doesn't need to be duplicated inside the using block + _guildsDisabled.TryRemove(guildId); + else + _guildsDisabled.Add(guildId); + + return isEnabled; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/_common/ConvertUnit.cs b/src/Ellie.Bot.Modules.Utility/_common/ConvertUnit.cs new file mode 100644 index 0000000..ae65c4f --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/_common/ConvertUnit.cs @@ -0,0 +1,12 @@ +#nullable disable +using System.Diagnostics; + +namespace Ellie.Modules.Utility.Common; + +[DebuggerDisplay("Type: {UnitType} Trigger: {Triggers[0]} Mod: {Modifier}")] +public class ConvertUnit +{ + public string[] Triggers { get; set; } + public string UnitType { get; set; } + public decimal Modifier { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/_common/Exceptions/StreamRoleNotFoundException.cs b/src/Ellie.Bot.Modules.Utility/_common/Exceptions/StreamRoleNotFoundException.cs new file mode 100644 index 0000000..95874cb --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/_common/Exceptions/StreamRoleNotFoundException.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace Ellie.Modules.Utility.Common.Exceptions; + +public class StreamRoleNotFoundException : Exception +{ + public StreamRoleNotFoundException() + : base("Stream role wasn't found.") + { + } + + public StreamRoleNotFoundException(string message) + : base(message) + { + } + + public StreamRoleNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Utility/_common/Exceptions/StreamRolePermissionException.cs b/src/Ellie.Bot.Modules.Utility/_common/Exceptions/StreamRolePermissionException.cs new file mode 100644 index 0000000..687b990 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/_common/Exceptions/StreamRolePermissionException.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace Ellie.Modules.Utility.Common.Exceptions; + +public class StreamRolePermissionException : Exception +{ + public StreamRolePermissionException() + : base("Stream role was unable to be applied.") + { + } + + public StreamRolePermissionException(string message) + : base(message) + { + } + + public StreamRolePermissionException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Ellie.Bot.Modules.Utility/_common/StreamRoleListType.cs b/src/Ellie.Bot.Modules.Utility/_common/StreamRoleListType.cs new file mode 100644 index 0000000..3f7a826 --- /dev/null +++ b/src/Ellie.Bot.Modules.Utility/_common/StreamRoleListType.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace Ellie.Modules.Utility.Common; + +public enum StreamRoleListType +{ + Whitelist, + Blacklist +} \ No newline at end of file