diff --git a/Ellie.sln b/Ellie.sln index ccb63a8..b09dc66 100644 --- a/Ellie.sln +++ b/Ellie.sln @@ -36,10 +36,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Econ", "src\Ellie.Eco EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Modules.Expresssions", "src\Ellie.Bot.Modules.Expresssions\Ellie.Bot.Modules.Expresssions.csproj", "{F4D3A613-320A-40DD-975A-247186A20173}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Common", "src\Ellie.Bot.Common\Ellie.Bot.Common.csproj", "{3EC0F005-560F-4E90-88CF-199520133BBA}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Db", "src\Ellie.Bot.Db\Ellie.Bot.Db.csproj", "{D3411F6C-320C-456D-BA86-24481EB000EA}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Common", "src\Ellie.Bot.Common\Ellie.Bot.Common.csproj", "{3EC0F005-560F-4E90-88CF-199520133BBA}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Generators.Cloneable", "src\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj", "{AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}" EndProject Global diff --git a/src/Ellie.Bot.Modules.Expresssions/Ellie.Bot.Modules.Expresssions.csproj b/src/Ellie.Bot.Modules.Expresssions/Ellie.Bot.Modules.Expresssions.csproj new file mode 100644 index 0000000..5529529 --- /dev/null +++ b/src/Ellie.Bot.Modules.Expresssions/Ellie.Bot.Modules.Expresssions.csproj @@ -0,0 +1,21 @@ + + + + net7.0 + enable + enable + Ellie.Modules.Expresssions + + + + + + + + + + + + + + diff --git a/src/Ellie.Bot.Modules.Expresssions/EllieExpressions.cs b/src/Ellie.Bot.Modules.Expresssions/EllieExpressions.cs new file mode 100644 index 0000000..a372896 --- /dev/null +++ b/src/Ellie.Bot.Modules.Expresssions/EllieExpressions.cs @@ -0,0 +1,395 @@ +#nullable disable + +using Ellie.Common.Attributes; + +namespace Ellie.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 ctx.Channel.EmbedAsync(_eb.Create() + .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)); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprToggleGlobal() + { + var result = await _service.ToggleGlobalExpressionsAsync(ctx.Guild.Id); + if (result) + await ReplyConfirmLocalizedAsync(strs.expr_global_disabled); + else + await ReplyConfirmLocalizedAsync(strs.expr_global_enabled); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprAddServer(string key, [Leftover] string message) + { + if (string.IsNullOrWhiteSpace(message) || string.IsNullOrWhiteSpace(key)) + { + return; + } + + await ExprAddInternalAsync(key, message); + } + + [Cmd] + public async Task ExprAdd(string key, [Leftover] string message) + { + if (string.IsNullOrWhiteSpace(message) || string.IsNullOrWhiteSpace(key)) + { + return; + } + + if (!AdminInGuildOrOwnerInDm()) + { + await ReplyErrorLocalizedAsync(strs.expr_insuff_perms); + return; + } + + await ExprAddInternalAsync(key, message); + } + + [Cmd] + public async Task ExprEdit(kwum id, [Leftover] string message) + { + var channel = ctx.Channel as ITextChannel; + if (string.IsNullOrWhiteSpace(message) || id < 0) + { + return; + } + + if ((channel is null && !_creds.IsOwner(ctx.User)) + || (channel is not null && !((IGuildUser)ctx.User).GuildPermissions.Administrator)) + { + await ReplyErrorLocalizedAsync(strs.expr_insuff_perms); + return; + } + + var ex = await _service.EditAsync(ctx.Guild?.Id, id, message); + if (ex is not null) + { + await ctx.Channel.EmbedAsync(_eb.Create() + .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)); + } + else + { + await ReplyErrorLocalizedAsync(strs.expr_no_found_id); + } + } + + [Cmd] + [Priority(1)] + public async Task ExprList(int page = 1) + { + if (--page < 0 || page > 999) + { + return; + } + + var expressions = _service.GetExpressionsFor(ctx.Guild?.Id); + + if (expressions is null || !expressions.Any()) + { + await ReplyErrorLocalizedAsync(strs.expr_no_found); + return; + } + + await ctx.SendPaginatedConfirmAsync(page, + curPage => + { + var desc = expressions.OrderBy(ex => ex.Trigger) + .Skip(curPage * 20) + .Take(20) + .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 _eb.Create().WithOkColor().WithTitle(GetText(strs.expressions)).WithDescription(desc); + }, + expressions.Length, + 20); + } + + [Cmd] + public async Task ExprShow(kwum id) + { + var found = _service.GetExpression(ctx.Guild?.Id, id); + + if (found is null) + { + await ReplyErrorLocalizedAsync(strs.expr_no_found_id); + return; + } + + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), found.Trigger.TrimTo(1024)) + .AddField(GetText(strs.response), + found.Response.TrimTo(1000).Replace("](", "]\\("))); + } + + public async Task ExprDeleteInternalAsync(kwum id) + { + var ex = await _service.DeleteAsync(ctx.Guild?.Id, id); + + if (ex is not null) + { + await ctx.Channel.EmbedAsync(_eb.Create() + .WithOkColor() + .WithTitle(GetText(strs.expr_deleted)) + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), ex.Trigger.TrimTo(1024)) + .AddField(GetText(strs.response), ex.Response.TrimTo(1024))); + } + else + { + await ReplyErrorLocalizedAsync(strs.expr_no_found_id); + } + } + + [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 ReplyErrorLocalizedAsync(strs.expr_insuff_perms); + return; + } + + await ExprDeleteInternalAsync(id); + } + + [Cmd] + public async Task ExprReact(kwum id, params string[] emojiStrs) + { + if (!AdminInGuildOrOwnerInDm()) + { + await ReplyErrorLocalizedAsync(strs.expr_insuff_perms); + return; + } + + var ex = _service.GetExpression(ctx.Guild?.Id, id); + if (ex is null) + { + await ReplyErrorLocalizedAsync(strs.expr_no_found_id); + return; + } + + if (emojiStrs.Length == 0) + { + await _service.ResetExprReactions(ctx.Guild?.Id, id); + await ReplyConfirmLocalizedAsync(strs.expr_reset(Format.Bold(id.ToString()))); + 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 ReplyErrorLocalizedAsync(strs.invalid_emojis); + return; + } + + await _service.SetExprReactions(ctx.Guild?.Id, id, succ); + + + await ReplyConfirmLocalizedAsync(strs.expr_set(Format.Bold(id.ToString()), + succ.Select(static x => x.ToString()).Join(", "))); + } + + [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 ReplyErrorLocalizedAsync(strs.expr_insuff_perms); + return; + } + + var (success, newVal) = await _service.ToggleExprOptionAsync(ctx.Guild?.Id, id, option); + if (!success) + { + await ReplyErrorLocalizedAsync(strs.expr_no_found_id); + return; + } + + if (newVal) + { + await ReplyConfirmLocalizedAsync(strs.option_enabled(Format.Code(option.ToString()), + Format.Code(id.ToString()))); + } + else + { + await ReplyConfirmLocalizedAsync(strs.option_disabled(Format.Code(option.ToString()), + Format.Code(id.ToString()))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprClear() + { + if (await PromptUserConfirmAsync(_eb.Create() + .WithTitle("Expression clear") + .WithDescription("This will delete all expressions on this server."))) + { + var count = _service.DeleteAllExpressions(ctx.Guild.Id); + await ReplyConfirmLocalizedAsync(strs.exprs_cleared(count)); + } + } + + [Cmd] + public async Task ExprsExport() + { + if (!AdminInGuildOrOwnerInDm()) + { + await ReplyErrorLocalizedAsync(strs.expr_insuff_perms); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var serialized = _service.ExportExpressions(ctx.Guild?.Id); + await using var stream = await serialized.ToStream(); + await ctx.Channel.SendFileAsync(stream, "exprs-export.yml"); + } + + [Cmd] +#if GLOBAL_ELLIE + [OwnerOnly] +#endif + public async Task ExprsImport([Leftover] string input = null) + { + if (!AdminInGuildOrOwnerInDm()) + { + await ReplyErrorLocalizedAsync(strs.expr_insuff_perms); + return; + } + + 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 = _clientFactory.CreateClient(); + input = await client.GetStringAsync(attachment.Url); + + if (string.IsNullOrWhiteSpace(input)) + { + await ReplyErrorLocalizedAsync(strs.expr_import_no_input); + return; + } + } + + var succ = await _service.ImportExpressionsAsync(ctx.Guild?.Id, input); + if (!succ) + { + await ReplyErrorLocalizedAsync(strs.expr_import_invalid_data); + return; + } + + await ctx.OkAsync(); + } +} diff --git a/src/Ellie.Bot.Modules.Expresssions/EllieExpressionsExtensios.cs b/src/Ellie.Bot.Modules.Expresssions/EllieExpressionsExtensios.cs new file mode 100644 index 0000000..924ab13 --- /dev/null +++ b/src/Ellie.Bot.Modules.Expresssions/EllieExpressionsExtensios.cs @@ -0,0 +1,88 @@ +#nullable disable +using Ellie.Services.Database.Models; +using System.Runtime.CompilerServices; + +namespace Ellie.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, + DiscordSocketClient client, + bool sanitize) + { + 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 rep = new ReplacementBuilder() + .WithDefault(ctx.Author, ctx.Channel, (ctx.Channel as ITextChannel)?.Guild as SocketGuild, client) + .WithOverride("%target%", + () => canMentionEveryone + ? ctx.Content[substringIndex..].Trim() + : ctx.Content[substringIndex..].Trim().SanitizeMentions(true)) + .Build(); + + var text = SmartText.CreateFrom(cr.Response); + text = rep.Replace(text); + + return await channel.SendAsync(text, sanitize); + } + + [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 +} diff --git a/src/Ellie.Bot.Modules.Expresssions/EllieExpressionsService.cs b/src/Ellie.Bot.Modules.Expresssions/EllieExpressionsService.cs new file mode 100644 index 0000000..a0d1da5 --- /dev/null +++ b/src/Ellie.Bot.Modules.Expresssions/EllieExpressionsService.cs @@ -0,0 +1,765 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Common.Yml; +using Ellie.Db; +using Ellie.Services.Database.Models; +using System.Runtime.CompilerServices; +using LinqToDB.EntityFrameworkCore; +using Ellie.Bot.Common; +using Ellie.Services; +using Serilog; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Ellie.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 IEmbedBuilderService _eb; + private readonly Random _rng; + + private bool ready; + private ConcurrentHashSet _disabledGlobalExpressionGuilds; + + public EllieExpressionsService( + DbService db, + IBotStrings strings, + IBot bot, + DiscordSocketClient client, + ICommandHandler cmd, + IPubSub pubSub, + IEmbedBuilderService eb, + IPermissionChecker permChecker) + { + _db = db; + _client = client; + _cmd = cmd; + _strings = strings; + _bot = bot; + _pubSub = pubSub; + _eb = eb; + _permChecker = permChecker; + _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.Expressions.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.GuildConfigs + .Where(x => x.DisableGlobalExpressions) + .Select(x => x.GuildId) + .ToListAsyncLinqToDB()); + + lock (_gexprWriteLock) + { + var globalItems = uow.Expressions.AsNoTracking() + .Where(x => x.GuildId == null || x.GuildId == 0) + .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; + + var result = await _permChecker.CheckAsync( + guild, + msg.Channel, + msg.Author, + "ACTUALEXPRESSIONS", + expr.Trigger + ); + + if (!result.IsT0) + return false; + + // todo print error etc + + // if (await _cmdCds.TryBlock(guild, msg.Author, expr.Trigger)) + // return false; + + try + { + // if (_gperm.BlockedModules.Contains("ACTUALEXPRESSIONS")) + // { + // Log.Information( + // "User {UserName} [{UserId}] tried to use an expression but 'ActualExpressions' are globally disabled", + // msg.Author.ToString(), + // msg.Author.Id); + // + // return true; + // } + // + // if (guild is SocketGuild sg) + // { + // var pc = _perms.GetCacheFor(guild.Id); + // if (!pc.Permissions.CheckPermissions(msg, expr.Trigger, "ACTUALEXPRESSIONS", out var index)) + // { + // if (pc.Verbose) + // { + // var permissionMessage = _strings.GetText(strs.perm_prevent(index + 1, + // Format.Bold(pc.Permissions[index].GetCommand(_cmd.GetPrefix(guild), sg))), + // sg.Id); + // + // try + // { + // await msg.Channel.SendErrorAsync(_eb, permissionMessage); + // } + // catch + // { + // } + // + // Log.Information("{PermissionMessage}", permissionMessage); + // } + // + // return true; + // } + // } + + var sentMsg = await expr.Send(msg, _client, false); + + 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.Expressions.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, + new[] { 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, new[] { 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.Expressions.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.Expressions.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.Expressions.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.Expressions.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.Expressions.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.Expressions.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) + { + key = key.ToLowerInvariant(); + var expr = new EllieExpression + { + GuildId = guildId, + Trigger = key, + Response = message + }; + + if (expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = true; + + await using (var uow = _db.GetDbContext()) + { + uow.Expressions.Add(expr); + await uow.SaveChangesAsync(); + } + + await AddInternalAsync(guildId, expr); + + return expr; + } + + public async Task EditAsync(ulong? guildId, int id, string message) + { + await using var uow = _db.GetDbContext(); + var expr = uow.Expressions.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; + + 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.Expressions.GetById(id); + + if (toDelete is null) + return null; + + if ((toDelete.IsGlobal() && guildId is null) || guildId == toDelete.GuildId) + { + uow.Expressions.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; + } +} + diff --git a/src/Ellie.Bot.Modules.Expresssions/ExportedExpr.cs b/src/Ellie.Bot.Modules.Expresssions/ExportedExpr.cs new file mode 100644 index 0000000..4533d29 --- /dev/null +++ b/src/Ellie.Bot.Modules.Expresssions/ExportedExpr.cs @@ -0,0 +1,27 @@ +#nullable disable +using Ellie.Services.Database.Models; + +namespace Ellie.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/Ellie.Bot.Modules.Expresssions/ExprField.cs b/src/Ellie.Bot.Modules.Expresssions/ExprField.cs new file mode 100644 index 0000000..1216cfa --- /dev/null +++ b/src/Ellie.Bot.Modules.Expresssions/ExprField.cs @@ -0,0 +1,10 @@ +namespace Ellie.Modules.EllieExpressions; + +public enum ExprField +{ + AutoDelete, + DmResponse, + AllowTarget, + ContainsAnywhere, + Message +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Expresssions/GlobalUsings.cs b/src/Ellie.Bot.Modules.Expresssions/GlobalUsings.cs new file mode 100644 index 0000000..e16088b --- /dev/null +++ b/src/Ellie.Bot.Modules.Expresssions/GlobalUsings.cs @@ -0,0 +1,19 @@ +// // global using System.Collections.Concurrent; +global using NonBlocking; +// // ellie +global using Ellise.Common; // new project +global using Ellie.Common; // old + ellie specific things +global using Ellie.Extensions; + +// 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; \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Expresssions/TypeReaders/CommandOrExprTypeReader.cs b/src/Ellie.Bot.Modules.Expresssions/TypeReaders/CommandOrExprTypeReader.cs new file mode 100644 index 0000000..bbedcfb --- /dev/null +++ b/src/Ellie.Bot.Modules.Expresssions/TypeReaders/CommandOrExprTypeReader.cs @@ -0,0 +1,34 @@ +#nullable disable +using Ellie.Modules.EllieExpressions; +using Ellie.Services; + +namespace Ellie.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; + _commandHandler = commandHandler; + _exprs = exprs; + } + + 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