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