diff --git a/src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs b/src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs new file mode 100644 index 0000000..1ed9504 --- /dev/null +++ b/src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs @@ -0,0 +1,91 @@ +#nullable disable +using EllieBot.Db.Models; +using System.Runtime.CompilerServices; + +namespace EllieBot.Modules.EllieExpressions; + +public static class EllieExpressionExtensions +{ + private static string ResolveTriggerString(this string str, DiscordSocketClient client) + => str.Replace("%bot.mention%", client.CurrentUser.Mention, StringComparison.Ordinal); + + public static async Task Send( + this EllieExpression cr, + IUserMessage ctx, + IReplacementService repSvc, + DiscordSocketClient client, + IMessageSenderService sender) + { + var channel = cr.DmResponse ? await ctx.Author.CreateDMChannelAsync() : ctx.Channel; + + var trigger = cr.Trigger.ResolveTriggerString(client); + var substringIndex = trigger.Length; + if (cr.ContainsAnywhere) + { + var pos = ctx.Content.AsSpan().GetWordPosition(trigger); + if (pos == WordPosition.Start) + substringIndex += 1; + else if (pos == WordPosition.End) + substringIndex = ctx.Content.Length; + else if (pos == WordPosition.Middle) + substringIndex += ctx.Content.IndexOf(trigger, StringComparison.InvariantCulture); + } + + var canMentionEveryone = (ctx.Author as IGuildUser)?.GuildPermissions.MentionEveryone ?? true; + + var repCtx = new ReplacementContext(client: client, + guild: (ctx.Channel as ITextChannel)?.Guild as SocketGuild, + channel: ctx.Channel, + user: ctx.Author + ) + .WithOverride("%target%", + () => canMentionEveryone + ? ctx.Content[substringIndex..].Trim() + : ctx.Content[substringIndex..].Trim().SanitizeMentions(true)); + + var text = SmartText.CreateFrom(cr.Response); + text = await repSvc.ReplaceAsync(text, repCtx); + + return await sender.Response(channel).Text(text).Sanitize(false).SendAsync(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static WordPosition GetWordPosition(this ReadOnlySpan str, in ReadOnlySpan word) + { + var wordIndex = str.IndexOf(word, StringComparison.OrdinalIgnoreCase); + if (wordIndex == -1) + return WordPosition.None; + + if (wordIndex == 0) + { + if (word.Length < str.Length && str.IsValidWordDivider(word.Length)) + return WordPosition.Start; + } + else if (wordIndex + word.Length == str.Length) + { + if (str.IsValidWordDivider(wordIndex - 1)) + return WordPosition.End; + } + else if (str.IsValidWordDivider(wordIndex - 1) && str.IsValidWordDivider(wordIndex + word.Length)) + return WordPosition.Middle; + + return WordPosition.None; + } + + private static bool IsValidWordDivider(this in ReadOnlySpan str, int index) + { + var ch = str[index]; + if (ch is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '1' and <= '9') + return false; + + return true; + } +} + +public enum WordPosition +{ + None, + Start, + Middle, + End +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/EllieExpressions.cs b/src/EllieBot/Modules/Expressions/EllieExpressions.cs new file mode 100644 index 0000000..ddb3624 --- /dev/null +++ b/src/EllieBot/Modules/Expressions/EllieExpressions.cs @@ -0,0 +1,445 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.EllieExpressions; + +[Name("Expressions")] +public partial class EllieExpressions : EllieModule +{ + public enum All + { + All + } + + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _clientFactory; + + public EllieExpressions(IBotCredentials creds, IHttpClientFactory clientFactory) + { + _creds = creds; + _clientFactory = clientFactory; + } + + private bool AdminInGuildOrOwnerInDm() + => (ctx.Guild is null && _creds.IsOwner(ctx.User)) + || (ctx.Guild is not null && ((IGuildUser)ctx.User).GuildPermissions.Administrator); + + private async Task ExprAddInternalAsync(string key, string message) + { + if (string.IsNullOrWhiteSpace(message) || string.IsNullOrWhiteSpace(key)) + { + return; + } + + var ex = await _service.AddAsync(ctx.Guild?.Id, key, message); + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.expr_new)) + .WithDescription($"#{new kwum(ex.Id)}") + .AddField(GetText(strs.trigger), key) + .AddField(GetText(strs.response), + message.Length > 1024 ? GetText(strs.redacted_too_long) : message)) + .SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprToggleGlobal() + { + var result = await _service.ToggleGlobalExpressionsAsync(ctx.Guild.Id); + if (result) + await Response().Confirm(strs.expr_global_disabled).SendAsync(); + else + await Response().Confirm(strs.expr_global_enabled).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprAddServer(string trigger, [Leftover] string response) + { + if (string.IsNullOrWhiteSpace(response) || string.IsNullOrWhiteSpace(trigger)) + { + return; + } + + await ExprAddInternalAsync(trigger, response); + } + + + [Cmd] + public async Task ExprAdd(string trigger, [Leftover] string response) + { + if (string.IsNullOrWhiteSpace(response) || string.IsNullOrWhiteSpace(trigger)) + { + return; + } + + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + await ExprAddInternalAsync(trigger, response); + } + + [Cmd] + public async Task ExprEdit(kwum id, [Leftover] string message) + { + var channel = ctx.Channel as ITextChannel; + if (string.IsNullOrWhiteSpace(message) || id < 0) + { + return; + } + + if (!IsValidExprEditor()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + var ex = await _service.EditAsync(ctx.Guild?.Id, id, message); + if (ex is not null) + { + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.expr_edited)) + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), ex.Trigger) + .AddField(GetText(strs.response), + message.Length > 1024 ? GetText(strs.redacted_too_long) : message)) + .SendAsync(); + } + else + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + } + } + + private bool IsValidExprEditor() + => (ctx.Guild is not null && ((IGuildUser)ctx.User).GuildPermissions.Administrator) + || (ctx.Guild is null && _creds.IsOwner(ctx.User)); + + [Cmd] + [Priority(1)] + public async Task ExprList(int page = 1) + { + if (--page < 0 || page > 999) + { + return; + } + + var allExpressions = _service.GetExpressionsFor(ctx.Guild?.Id) + .OrderBy(x => x.Trigger) + .ToArray(); + + if (!allExpressions.Any()) + { + await Response().Error(strs.expr_no_found).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(allExpressions) + .PageSize(20) + .CurrentPage(page) + .Page((exprs, _) => + { + var desc = exprs + .Select(ex => $"{(ex.ContainsAnywhere ? "🗯" : "◾")}" + + $"{(ex.DmResponse ? "✉" : "◾")}" + + $"{(ex.AutoDeleteTrigger ? "❌" : "◾")}" + + $"`{(kwum)ex.Id}` {ex.Trigger}" + + (string.IsNullOrWhiteSpace(ex.Reactions) + ? string.Empty + : " // " + string.Join(" ", ex.GetReactions()))) + .Join('\n'); + + return _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.expressions)).WithDescription(desc); + }) + .SendAsync(); + } + + [Cmd] + public async Task ExprShow(kwum id) + { + var found = _service.GetExpression(ctx.Guild?.Id, id); + + if (found is null) + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + return; + } + + var inter = CreateEditInteraction(id, found); + + await Response() + .Interaction(IsValidExprEditor() ? inter : null) + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), found.Trigger.TrimTo(1024)) + .AddField(GetText(strs.response), + found.Response.TrimTo(1000).Replace("](", "]\\("))) + .SendAsync(); + } + + private EllieInteractionBase CreateEditInteraction(kwum id, EllieExpression found) + { + var modal = new ModalBuilder() + .WithCustomId("expr:edit_modal") + .WithTitle($"Edit expression {id}") + .AddTextInput(new TextInputBuilder() + .WithLabel(GetText(strs.response)) + .WithValue(found.Response) + .WithMinLength(1) + .WithCustomId("expr:edit_modal:response") + .WithStyle(TextInputStyle.Paragraph)); + + var inter = _inter.Create(ctx.User.Id, + new ButtonBuilder() + .WithEmote(Emoji.Parse("📝")) + .WithLabel("Edit") + .WithStyle(ButtonStyle.Primary) + .WithCustomId("test"), + modal, + async (sm) => + { + var msg = sm.Data.Components.FirstOrDefault()?.Value; + + await ExprEdit(id, msg); + } + ); + return inter; + } + + public async Task ExprDeleteInternalAsync(kwum id) + { + var ex = await _service.DeleteAsync(ctx.Guild?.Id, id); + + if (ex is not null) + { + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.expr_deleted)) + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), ex.Trigger.TrimTo(1024)) + .AddField(GetText(strs.response), ex.Response.TrimTo(1024))) + .SendAsync(); + } + else + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + } + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + [RequireContext(ContextType.Guild)] + public async Task ExprDeleteServer(kwum id) + => await ExprDeleteInternalAsync(id); + + [Cmd] + public async Task ExprDelete(kwum id) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + await ExprDeleteInternalAsync(id); + } + + [Cmd] + public async Task ExprReact(kwum id, params string[] emojiStrs) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + var ex = _service.GetExpression(ctx.Guild?.Id, id); + if (ex is null) + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + return; + } + + if (emojiStrs.Length == 0) + { + await _service.ResetExprReactions(ctx.Guild?.Id, id); + await Response().Confirm(strs.expr_reset(Format.Bold(id.ToString()))).SendAsync(); + return; + } + + var succ = new List(); + foreach (var emojiStr in emojiStrs) + { + var emote = emojiStr.ToIEmote(); + + // i should try adding these emojis right away to the message, to make sure the bot can react with these emojis. If it fails, skip that emoji + try + { + await ctx.Message.AddReactionAsync(emote); + await Task.Delay(100); + succ.Add(emojiStr); + + if (succ.Count >= 3) + { + break; + } + } + catch { } + } + + if (succ.Count == 0) + { + await Response().Error(strs.invalid_emojis).SendAsync(); + return; + } + + await _service.SetExprReactions(ctx.Guild?.Id, id, succ); + + + await Response() + .Confirm(strs.expr_set(Format.Bold(id.ToString()), + succ.Select(static x => x.ToString()).Join(", "))) + .SendAsync(); + } + + [Cmd] + public Task ExprCa(kwum id) + => InternalExprEdit(id, ExprField.ContainsAnywhere); + + [Cmd] + public Task ExprDm(kwum id) + => InternalExprEdit(id, ExprField.DmResponse); + + [Cmd] + public Task ExprAd(kwum id) + => InternalExprEdit(id, ExprField.AutoDelete); + + [Cmd] + public Task ExprAt(kwum id) + => InternalExprEdit(id, ExprField.AllowTarget); + + [Cmd] + [OwnerOnly] + public async Task ExprsReload() + { + await _service.TriggerReloadExpressions(); + + await ctx.OkAsync(); + } + + private async Task InternalExprEdit(kwum id, ExprField option) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + var (success, newVal) = await _service.ToggleExprOptionAsync(ctx.Guild?.Id, id, option); + if (!success) + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + return; + } + + if (newVal) + { + await Response() + .Confirm(strs.option_enabled(Format.Code(option.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.option_disabled(Format.Code(option.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprClear() + { + if (await PromptUserConfirmAsync(_sender.CreateEmbed() + .WithTitle("Expression clear") + .WithDescription("This will delete all expressions on this server."))) + { + var count = _service.DeleteAllExpressions(ctx.Guild.Id); + await Response().Confirm(strs.exprs_cleared(count)).SendAsync(); + } + } + + [Cmd] + public async Task ExprsExport() + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var serialized = _service.ExportExpressions(ctx.Guild?.Id); + await using var stream = await serialized.ToStream(); + await ctx.User.SendFileAsync(stream, $"exprs-export_{DateTime.UtcNow:yyyy-MM-dd-HH-mm-ss}_{(ctx.Guild?.Id.ToString() ?? "global")}.yml"); + } + + [Cmd] + [Ratelimit(300)] + public async Task ExprsImport([Leftover] string input = null) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + input = input?.Trim(); + + _ = ctx.Channel.TriggerTypingAsync(); + + if (input is null) + { + var attachment = ctx.Message.Attachments.FirstOrDefault(); + if (attachment is null) + { + await Response().Error(strs.expr_import_no_input).SendAsync(); + return; + } + + using var client = _clientFactory.CreateClient(); + input = await client.GetStringAsync(attachment.Url); + + if (string.IsNullOrWhiteSpace(input)) + { + await Response().Error(strs.expr_import_no_input).SendAsync(); + return; + } + } + + var succ = await _service.ImportExpressionsAsync(ctx.Guild?.Id, input); + if (!succ) + { + await Response().Error(strs.expr_import_invalid_data).SendAsync(); + return; + } + + await ctx.OkAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/EllieExpressionsService.cs b/src/EllieBot/Modules/Expressions/EllieExpressionsService.cs new file mode 100644 index 0000000..a660b4d --- /dev/null +++ b/src/EllieBot/Modules/Expressions/EllieExpressionsService.cs @@ -0,0 +1,800 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Common.Yml; +using EllieBot.Db.Models; +using System.Runtime.CompilerServices; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Modules.Permissions.Services; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace EllieBot.Modules.EllieExpressions; + +public sealed class EllieExpressionsService : IExecOnMessage, IReadyExecutor +{ + private const string MENTION_PH = "%bot.mention%"; + + private const string PREPEND_EXPORT = + """ + # Keys are triggers, Each key has a LIST of expressions in the following format: + # - res: Response string + # id: Alphanumeric id used for commands related to the expression. (Note, when using .exprsimport, a new id will be generated.) + # react: + # - + # at: Whether expression allows targets (see .h .exprat) + # ca: Whether expression expects trigger anywhere (see .h .exprca) + # dm: Whether expression DMs the response (see .h .exprdm) + # ad: Whether expression automatically deletes triggering message (see .h .exprad) + + + """; + + private static readonly ISerializer _exportSerializer = new SerializerBuilder() + .WithEventEmitter(args + => new MultilineScalarFlowStyleEmitter(args)) + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithIndentedSequences() + .ConfigureDefaultValuesHandling(DefaultValuesHandling + .OmitDefaults) + .DisableAliases() + .Build(); + + public int Priority + => 0; + + private readonly object _gexprWriteLock = new(); + + private readonly TypedKey _gexprAddedKey = new("gexpr.added"); + private readonly TypedKey _gexprDeletedkey = new("gexpr.deleted"); + private readonly TypedKey _gexprEditedKey = new("gexpr.edited"); + private readonly TypedKey _exprsReloadedKey = new("exprs.reloaded"); + + // it is perfectly fine to have global expressions as an array + // 1. expressions are almost never added (compared to how many times they are being looped through) + // 2. only need write locks for this as we'll rebuild+replace the array on every edit + // 3. there's never many of them (at most a thousand, usually < 100) + private EllieExpression[] globalExpressions = Array.Empty(); + private ConcurrentDictionary newguildExpressions = new(); + + private readonly DbService _db; + + private readonly DiscordSocketClient _client; + + // private readonly PermissionService _perms; + // private readonly GlobalPermissionService _gperm; + // private readonly CmdCdService _cmdCds; + private readonly IPermissionChecker _permChecker; + private readonly ICommandHandler _cmd; + private readonly IBotStrings _strings; + private readonly IBot _bot; + private readonly IPubSub _pubSub; + private readonly IMessageSenderService _sender; + private readonly IReplacementService _repSvc; + private readonly Random _rng; + + private bool ready; + private ConcurrentHashSet _disabledGlobalExpressionGuilds; + private readonly PermissionService _pc; + + public EllieExpressionsService( + DbService db, + IBotStrings strings, + IBot bot, + DiscordSocketClient client, + ICommandHandler cmd, + IPubSub pubSub, + IMessageSenderService sender, + IReplacementService repSvc, + IPermissionChecker permChecker, + PermissionService pc) + { + _db = db; + _client = client; + _cmd = cmd; + _strings = strings; + _bot = bot; + _pubSub = pubSub; + _sender = sender; + _repSvc = repSvc; + _permChecker = permChecker; + _pc = pc; + _rng = new EllieRandom(); + + _pubSub.Sub(_exprsReloadedKey, OnExprsShouldReload); + pubSub.Sub(_gexprAddedKey, OnGexprAdded); + pubSub.Sub(_gexprDeletedkey, OnGexprDeleted); + pubSub.Sub(_gexprEditedKey, OnGexprEdited); + + bot.JoinedGuild += OnJoinedGuild; + _client.LeftGuild += OnLeftGuild; + } + + private async Task ReloadInternal(IReadOnlyList allGuildIds) + { + await using var uow = _db.GetDbContext(); + var guildItems = await uow.Set() + .AsNoTracking() + .Where(x => allGuildIds.Contains(x.GuildId.Value)) + .ToListAsync(); + + newguildExpressions = guildItems.GroupBy(k => k.GuildId!.Value) + .ToDictionary(g => g.Key, + g => g.Select(x => + { + x.Trigger = x.Trigger.Replace(MENTION_PH, + _client.CurrentUser.Mention); + return x; + }) + .ToArray()) + .ToConcurrent(); + + _disabledGlobalExpressionGuilds = new(await uow.Set() + .Where(x => x.DisableGlobalExpressions) + .Select(x => x.GuildId) + .ToListAsyncLinqToDB()); + + lock (_gexprWriteLock) + { + var globalItems = uow.Set() + .AsNoTracking() + .Where(x => x.GuildId == null || x.GuildId == 0) + .Where(x => x.Trigger != null) + .AsEnumerable() + .Select(x => + { + x.Trigger = x.Trigger.Replace(MENTION_PH, _client.CurrentUser.Mention); + return x; + }) + .ToArray(); + + globalExpressions = globalItems; + } + + ready = true; + } + + private EllieExpression TryGetExpression(IUserMessage umsg) + { + if (!ready) + return null; + + if (umsg.Channel is not SocketTextChannel channel) + return null; + + var content = umsg.Content.Trim().ToLowerInvariant(); + + if (newguildExpressions.TryGetValue(channel.Guild.Id, out var expressions) && expressions.Length > 0) + { + var expr = MatchExpressions(content, expressions); + if (expr is not null) + return expr; + } + + if (_disabledGlobalExpressionGuilds.Contains(channel.Guild.Id)) + return null; + + var localGrs = globalExpressions; + + return MatchExpressions(content, localGrs); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private EllieExpression MatchExpressions(in ReadOnlySpan content, EllieExpression[] exprs) + { + var result = new List(1); + for (var i = 0; i < exprs.Length; i++) + { + var expr = exprs[i]; + var trigger = expr.Trigger; + if (content.Length > trigger.Length) + { + // if input is greater than the trigger, it can only work if: + // it has CA enabled + if (expr.ContainsAnywhere) + { + // if ca is enabled, we have to check if it is a word within the content + var wp = content.GetWordPosition(trigger); + + // if it is, then that's valid + if (wp != WordPosition.None) + result.Add(expr); + + // if it's not, then it cant' work under any circumstance, + // because content is greater than the trigger length + // so it can't be equal, and it's not contained as a word + continue; + } + + // if CA is disabled, and expr has AllowTarget, then the + // content has to start with the trigger followed by a space + if (expr.AllowTarget + && content.StartsWith(trigger, StringComparison.OrdinalIgnoreCase) + && content[trigger.Length] == ' ') + result.Add(expr); + } + else if (content.Length < expr.Trigger.Length) + { + // if input length is less than trigger length, it means + // that the reaction can never be triggered + } + else + { + // if input length is the same as trigger length + // reaction can only trigger if the strings are equal + if (content.SequenceEqual(expr.Trigger)) + result.Add(expr); + } + } + + if (result.Count == 0) + return null; + + var cancelled = result.FirstOrDefault(x => x.Response == "-"); + if (cancelled is not null) + return cancelled; + + return result[_rng.Next(0, result.Count)]; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + // maybe this message is an expression + var expr = TryGetExpression(msg); + + if (expr is null || expr.Response == "-") + return false; + + try + { + if (guild is SocketGuild sg) + { + var result = await _permChecker.CheckPermsAsync( + guild, + msg.Channel, + msg.Author, + "ACTUALEXPRESSIONS", + expr.Trigger + ); + + if (!result.IsAllowed) + { + var cache = _pc.GetCacheFor(guild.Id); + if (cache.Verbose) + { + if (result.TryPickT3(out var disallowed, out _)) + { + var permissionMessage = _strings.GetText(strs.perm_prevent(disallowed.PermIndex + 1, + Format.Bold(disallowed.PermText)), + sg.Id); + + try + { + await _sender.Response(msg.Channel) + .Error(permissionMessage) + .SendAsync(); + } + catch + { + } + + Log.Information("{PermissionMessage}", permissionMessage); + } + } + + return true; + } + } + + var sentMsg = await expr.Send(msg, _repSvc, _client, _sender); + + var reactions = expr.GetReactions(); + foreach (var reaction in reactions) + { + try + { + await sentMsg.AddReactionAsync(reaction.ToIEmote()); + } + catch + { + Log.Warning("Unable to add reactions to message {Message} in server {GuildId}", + sentMsg.Id, + expr.GuildId); + break; + } + + await Task.Delay(1000); + } + + if (expr.AutoDeleteTrigger) + { + try + { + await msg.DeleteAsync(); + } + catch + { + } + } + + Log.Information("s: {GuildId} c: {ChannelId} u: {UserId} | {UserName} executed expression {Expr}", + guild.Id, + msg.Channel.Id, + msg.Author.Id, + msg.Author.ToString(), + expr.Trigger); + + return true; + } + catch (Exception ex) + { + Log.Warning(ex, "Error in Expression RunBehavior: {ErrorMessage}", ex.Message); + } + + return false; + } + + public async Task ResetExprReactions(ulong? maybeGuildId, int id) + { + EllieExpression expr; + await using var uow = _db.GetDbContext(); + expr = uow.Set().GetById(id); + if (expr is null) + return; + + expr.Reactions = string.Empty; + + await uow.SaveChangesAsync(); + } + + private Task UpdateInternalAsync(ulong? maybeGuildId, EllieExpression expr) + { + if (maybeGuildId is { } guildId) + UpdateInternal(guildId, expr); + else + return _pubSub.Pub(_gexprEditedKey, expr); + + return Task.CompletedTask; + } + + private void UpdateInternal(ulong? maybeGuildId, EllieExpression expr) + { + if (maybeGuildId is { } guildId) + { + newguildExpressions.AddOrUpdate(guildId, + [expr], + (_, old) => + { + var newArray = old.ToArray(); + for (var i = 0; i < newArray.Length; i++) + { + if (newArray[i].Id == expr.Id) + newArray[i] = expr; + } + + return newArray; + }); + } + else + { + lock (_gexprWriteLock) + { + var exprs = globalExpressions; + for (var i = 0; i < exprs.Length; i++) + { + if (exprs[i].Id == expr.Id) + exprs[i] = expr; + } + } + } + } + + private Task AddInternalAsync(ulong? maybeGuildId, EllieExpression expr) + { + // only do this for perf purposes + expr.Trigger = expr.Trigger.Replace(MENTION_PH, _client.CurrentUser.Mention); + + if (maybeGuildId is { } guildId) + newguildExpressions.AddOrUpdate(guildId, [expr], (_, old) => old.With(expr)); + else + return _pubSub.Pub(_gexprAddedKey, expr); + + return Task.CompletedTask; + } + + private Task DeleteInternalAsync(ulong? maybeGuildId, int id) + { + if (maybeGuildId is { } guildId) + { + newguildExpressions.AddOrUpdate(guildId, + Array.Empty(), + (key, old) => DeleteInternal(old, id, out _)); + + return Task.CompletedTask; + } + + lock (_gexprWriteLock) + { + var expr = Array.Find(globalExpressions, item => item.Id == id); + if (expr is not null) + return _pubSub.Pub(_gexprDeletedkey, expr.Id); + } + + return Task.CompletedTask; + } + + private EllieExpression[] DeleteInternal( + IReadOnlyList exprs, + int id, + out EllieExpression deleted) + { + deleted = null; + if (exprs is null || exprs.Count == 0) + return exprs as EllieExpression[] ?? exprs?.ToArray(); + + var newExprs = new EllieExpression[exprs.Count - 1]; + for (int i = 0, k = 0; i < exprs.Count; i++, k++) + { + if (exprs[i].Id == id) + { + deleted = exprs[i]; + k--; + continue; + } + + newExprs[k] = exprs[i]; + } + + return newExprs; + } + + public async Task SetExprReactions(ulong? guildId, int id, IEnumerable emojis) + { + EllieExpression expr; + await using (var uow = _db.GetDbContext()) + { + expr = uow.Set().GetById(id); + if (expr is null) + return; + + expr.Reactions = string.Join("@@@", emojis); + + await uow.SaveChangesAsync(); + } + + await UpdateInternalAsync(guildId, expr); + } + + public async Task<(bool Sucess, bool NewValue)> ToggleExprOptionAsync(ulong? guildId, int id, ExprField field) + { + var newVal = false; + EllieExpression expr; + await using (var uow = _db.GetDbContext()) + { + expr = uow.Set().GetById(id); + + if (expr is null || expr.GuildId != guildId) + return (false, false); + if (field == ExprField.AutoDelete) + newVal = expr.AutoDeleteTrigger = !expr.AutoDeleteTrigger; + else if (field == ExprField.ContainsAnywhere) + newVal = expr.ContainsAnywhere = !expr.ContainsAnywhere; + else if (field == ExprField.DmResponse) + newVal = expr.DmResponse = !expr.DmResponse; + else if (field == ExprField.AllowTarget) + newVal = expr.AllowTarget = !expr.AllowTarget; + + await uow.SaveChangesAsync(); + } + + await UpdateInternalAsync(guildId, expr); + + return (true, newVal); + } + + public EllieExpression GetExpression(ulong? guildId, int id) + { + using var uow = _db.GetDbContext(); + var expr = uow.Set().GetById(id); + if (expr is null || expr.GuildId != guildId) + return null; + + return expr; + } + + public int DeleteAllExpressions(ulong guildId) + { + using var uow = _db.GetDbContext(); + var count = uow.Set().ClearFromGuild(guildId); + uow.SaveChanges(); + + newguildExpressions.TryRemove(guildId, out _); + + return count; + } + + public bool ExpressionExists(ulong? guildId, string input) + { + input = input.ToLowerInvariant(); + + var gexprs = globalExpressions; + foreach (var t in gexprs) + { + if (t.Trigger == input) + return true; + } + + if (guildId is ulong gid && newguildExpressions.TryGetValue(gid, out var guildExprs)) + { + foreach (var t in guildExprs) + { + if (t.Trigger == input) + return true; + } + } + + return false; + } + + public string ExportExpressions(ulong? guildId) + { + var exprs = GetExpressionsFor(guildId); + + var exprsDict = exprs.GroupBy(x => x.Trigger).ToDictionary(x => x.Key, x => x.Select(ExportedExpr.FromModel)); + + return PREPEND_EXPORT + _exportSerializer.Serialize(exprsDict).UnescapeUnicodeCodePoints(); + } + + public async Task ImportExpressionsAsync(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 trigger = entry.Key; + await uow.Set() + .AddRangeAsync(entry.Value + .Where(expr => !string.IsNullOrWhiteSpace(expr.Res)) + .Select(expr => new EllieExpression + { + GuildId = guildId, + Response = expr.Res, + Reactions = expr.React?.Join("@@@"), + Trigger = trigger, + AllowTarget = expr.At, + ContainsAnywhere = expr.Ca, + DmResponse = expr.Dm, + AutoDeleteTrigger = expr.Ad + })); + } + + await uow.SaveChangesAsync(); + await TriggerReloadExpressions(); + return true; + } + + #region Event Handlers + + public async Task OnReadyAsync() + => await OnExprsShouldReload(true); + + private ValueTask OnExprsShouldReload(bool _) + => new(ReloadInternal(_bot.GetCurrentGuildIds())); + + private ValueTask OnGexprAdded(EllieExpression c) + { + lock (_gexprWriteLock) + { + var newGlobalReactions = new EllieExpression[globalExpressions.Length + 1]; + Array.Copy(globalExpressions, newGlobalReactions, globalExpressions.Length); + newGlobalReactions[globalExpressions.Length] = c; + globalExpressions = newGlobalReactions; + } + + return default; + } + + private ValueTask OnGexprEdited(EllieExpression c) + { + lock (_gexprWriteLock) + { + for (var i = 0; i < globalExpressions.Length; i++) + { + if (globalExpressions[i].Id == c.Id) + { + globalExpressions[i] = c; + return default; + } + } + + // if edited expr is not found?! + // add it + OnGexprAdded(c); + } + + return default; + } + + private ValueTask OnGexprDeleted(int id) + { + lock (_gexprWriteLock) + { + var newGlobalReactions = DeleteInternal(globalExpressions, id, out _); + globalExpressions = newGlobalReactions; + } + + return default; + } + + public Task TriggerReloadExpressions() + => _pubSub.Pub(_exprsReloadedKey, true); + + #endregion + + #region Client Event Handlers + + private Task OnLeftGuild(SocketGuild arg) + { + newguildExpressions.TryRemove(arg.Id, out _); + + return Task.CompletedTask; + } + + private async Task OnJoinedGuild(GuildConfig gc) + { + await using var uow = _db.GetDbContext(); + var exprs = await uow.Set().AsNoTracking().Where(x => x.GuildId == gc.GuildId).ToArrayAsync(); + + newguildExpressions[gc.GuildId] = exprs; + } + + #endregion + + #region Basic Operations + + public async Task AddAsync( + ulong? guildId, + string key, + string message, + bool ca = false, + bool ad = false, + bool dm = false) + { + key = key.ToLowerInvariant(); + var expr = new EllieExpression + { + GuildId = guildId, + Trigger = key, + Response = message, + ContainsAnywhere = ca, + AutoDeleteTrigger = ad, + DmResponse = dm + }; + + if (expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = true; + + await using (var uow = _db.GetDbContext()) + { + uow.Set().Add(expr); + await uow.SaveChangesAsync(); + } + + await AddInternalAsync(guildId, expr); + + return expr; + } + + public async Task EditAsync( + ulong? guildId, + int id, + string message, + bool? ca = null, + bool? ad = null, + bool? dm = null) + { + await using var uow = _db.GetDbContext(); + var expr = uow.Set().GetById(id); + + if (expr is null || expr.GuildId != guildId) + return null; + + // disable allowtarget if message had target, but it was removed from it + if (!message.Contains("%target%", StringComparison.OrdinalIgnoreCase) + && expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = false; + + expr.Response = message; + + // enable allow target if message is edited to contain target + if (expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = true; + + expr.ContainsAnywhere = ca ?? expr.ContainsAnywhere; + expr.AutoDeleteTrigger = ad ?? expr.AutoDeleteTrigger; + expr.DmResponse = dm ?? expr.DmResponse; + + await uow.SaveChangesAsync(); + await UpdateInternalAsync(guildId, expr); + + return expr; + } + + + public async Task DeleteAsync(ulong? guildId, int id) + { + await using var uow = _db.GetDbContext(); + var toDelete = uow.Set().GetById(id); + + if (toDelete is null) + return null; + + if ((toDelete.IsGlobal() && guildId is null) || guildId == toDelete.GuildId) + { + uow.Set().Remove(toDelete); + await uow.SaveChangesAsync(); + await DeleteInternalAsync(guildId, id); + return toDelete; + } + + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public EllieExpression[] GetExpressionsFor(ulong? maybeGuildId) + { + if (maybeGuildId is { } guildId) + return newguildExpressions.TryGetValue(guildId, out var exprs) ? exprs : Array.Empty(); + + return globalExpressions; + } + + #endregion + + public async Task ToggleGlobalExpressionsAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + var gc = ctx.GuildConfigsForId(guildId, set => set); + var toReturn = gc.DisableGlobalExpressions = !gc.DisableGlobalExpressions; + await ctx.SaveChangesAsync(); + + if (toReturn) + _disabledGlobalExpressionGuilds.Add(guildId); + else + _disabledGlobalExpressionGuilds.TryRemove(guildId); + + return toReturn; + } + + + public async Task<(IReadOnlyCollection Exprs, int TotalCount)> FindExpressionsAsync( + ulong guildId, + string query, + int page) + { + await using var ctx = _db.GetDbContext(); + + if (newguildExpressions.TryGetValue(guildId, out var exprs)) + { + return (exprs.Where(x => x.Trigger.Contains(query)) + .Skip(page * 9) + .Take(9) + .ToArray(), exprs.Length); + } + + return ([], 0); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/ExportedExpr.cs b/src/EllieBot/Modules/Expressions/ExportedExpr.cs new file mode 100644 index 0000000..581c884 --- /dev/null +++ b/src/EllieBot/Modules/Expressions/ExportedExpr.cs @@ -0,0 +1,27 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.EllieExpressions; + +public class ExportedExpr +{ + public string Res { get; set; } + public string Id { get; set; } + public bool Ad { get; set; } + public bool Dm { get; set; } + public bool At { get; set; } + public bool Ca { get; set; } + public string[] React; + + public static ExportedExpr FromModel(EllieExpression cr) + => new() + { + Res = cr.Response, + Id = ((kwum)cr.Id).ToString(), + Ad = cr.AutoDeleteTrigger, + At = cr.AllowTarget, + Ca = cr.ContainsAnywhere, + Dm = cr.DmResponse, + React = string.IsNullOrWhiteSpace(cr.Reactions) ? null : cr.GetReactions() + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/ExprField.cs b/src/EllieBot/Modules/Expressions/ExprField.cs new file mode 100644 index 0000000..9b9fa2f --- /dev/null +++ b/src/EllieBot/Modules/Expressions/ExprField.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Modules.EllieExpressions; + +public enum ExprField +{ + AutoDelete, + DmResponse, + AllowTarget, + ContainsAnywhere, + Message +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs b/src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs new file mode 100644 index 0000000..716735e --- /dev/null +++ b/src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs @@ -0,0 +1,33 @@ +#nullable disable +using EllieBot.Modules.EllieExpressions; + +namespace EllieBot.Common.TypeReaders; + +public sealed class CommandOrExprTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + private readonly ICommandHandler _commandHandler; + private readonly EllieExpressionsService _exprs; + + public CommandOrExprTypeReader(CommandService cmds, EllieExpressionsService exprs, ICommandHandler commandHandler) + { + _cmds = cmds; + _exprs = exprs; + _commandHandler = commandHandler; + } + + public override async ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (_exprs.ExpressionExists(ctx.Guild?.Id, input)) + return TypeReaderResult.FromSuccess(new CommandOrExprInfo(input, CommandOrExprInfo.Type.Custom)); + + var cmd = await new CommandTypeReader(_commandHandler, _cmds).ReadAsync(ctx, input); + if (cmd.IsSuccess) + { + return TypeReaderResult.FromSuccess(new CommandOrExprInfo(((CommandInfo)cmd.Values.First().Value).Name, + CommandOrExprInfo.Type.Normal)); + } + + return TypeReaderResult.FromError(CommandError.ParseFailed, "No such command or expression found."); + } +} \ No newline at end of file