#nullable disable using Microsoft.EntityFrameworkCore; using NadekoBot.Common.ModuleBehaviors; using NadekoBot.Common.Yml; using NadekoBot.Db; using NadekoBot.Modules.Permissions.Common; using NadekoBot.Modules.Permissions.Services; using NadekoBot.Services.Database.Models; using System.Runtime.CompilerServices; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace NadekoBot.Modules.NadekoExpressions; public sealed class NadekoExpressionsService : 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 NadekoExpression[] globalReactions; private ConcurrentDictionary newGuildReactions; private readonly DbService _db; private readonly DiscordSocketClient _client; private readonly PermissionService _perms; private readonly CommandHandler _cmd; private readonly IBotStrings _strings; private readonly Bot _bot; private readonly GlobalPermissionService _gperm; private readonly CmdCdService _cmdCds; private readonly IPubSub _pubSub; private readonly IEmbedBuilderService _eb; private readonly Random _rng; private bool ready; public NadekoExpressionsService( PermissionService perms, DbService db, IBotStrings strings, Bot bot, DiscordSocketClient client, CommandHandler cmd, GlobalPermissionService gperm, CmdCdService cmdCds, IPubSub pubSub, IEmbedBuilderService eb) { _db = db; _client = client; _perms = perms; _cmd = cmd; _strings = strings; _bot = bot; _gperm = gperm; _cmdCds = cmdCds; _pubSub = pubSub; _eb = eb; _rng = new NadekoRandom(); _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(); newGuildReactions = guildItems.GroupBy(k => k.GuildId!.Value) .ToDictionary(g => g.Key, g => g.Select(x => { x.Trigger = x.Trigger.Replace(MENTION_PH, _bot.Mention); return x; }) .ToArray()) .ToConcurrent(); 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, _bot.Mention); return x; }) .ToArray(); globalReactions = globalItems; } ready = true; } private NadekoExpression TryGetExpression(IUserMessage umsg) { if (!ready) return null; if (umsg.Channel is not SocketTextChannel channel) return null; var content = umsg.Content.Trim().ToLowerInvariant(); if (newGuildReactions.TryGetValue(channel.Guild.Id, out var reactions) && reactions.Length > 0) { var expr = MatchExpressions(content, reactions); if (expr is not null) return expr; } var localGrs = globalReactions; return MatchExpressions(content, localGrs); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private NadekoExpression MatchExpressions(in ReadOnlySpan content, NadekoExpression[] 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; 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) { NadekoExpression 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, NadekoExpression expr) { if (maybeGuildId is { } guildId) UpdateInternal(guildId, expr); else return _pubSub.Pub(_gexprEditedKey, expr); return Task.CompletedTask; } private void UpdateInternal(ulong? maybeGuildId, NadekoExpression expr) { if (maybeGuildId is { } guildId) { newGuildReactions.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 = globalReactions; for (var i = 0; i < exprs.Length; i++) { if (exprs[i].Id == expr.Id) exprs[i] = expr; } } } } private Task AddInternalAsync(ulong? maybeGuildId, NadekoExpression expr) { // only do this for perf purposes expr.Trigger = expr.Trigger.Replace(MENTION_PH, _client.CurrentUser.Mention); if (maybeGuildId is { } guildId) newGuildReactions.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) { newGuildReactions.AddOrUpdate(guildId, Array.Empty(), (key, old) => DeleteInternal(old, id, out _)); return Task.CompletedTask; } lock (_gexprWriteLock) { var expr = Array.Find(globalReactions, item => item.Id == id); if (expr is not null) return _pubSub.Pub(_gexprDeletedkey, expr.Id); } return Task.CompletedTask; } private NadekoExpression[] DeleteInternal( IReadOnlyList exprs, int id, out NadekoExpression deleted) { deleted = null; if (exprs is null || exprs.Count == 0) return exprs as NadekoExpression[] ?? exprs?.ToArray(); var newExprs = new NadekoExpression[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) { NadekoExpression 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; NadekoExpression 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 NadekoExpression 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(); newGuildReactions.TryRemove(guildId, out _); return count; } public bool ExpressionExists(ulong? guildId, string input) { using var uow = _db.GetDbContext(); var expr = uow.Expressions.GetByGuildIdAndInput(guildId, input); return expr is not null; } 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 NadekoExpression { 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 Task OnReadyAsync() => ReloadInternal(_bot.GetCurrentGuildIds()); private ValueTask OnExprsShouldReload(bool _) => new(ReloadInternal(_bot.GetCurrentGuildIds())); private ValueTask OnGexprAdded(NadekoExpression c) { lock (_gexprWriteLock) { var newGlobalReactions = new NadekoExpression[globalReactions.Length + 1]; Array.Copy(globalReactions, newGlobalReactions, globalReactions.Length); newGlobalReactions[globalReactions.Length] = c; globalReactions = newGlobalReactions; } return default; } private ValueTask OnGexprEdited(NadekoExpression c) { lock (_gexprWriteLock) { for (var i = 0; i < globalReactions.Length; i++) { if (globalReactions[i].Id == c.Id) { globalReactions[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(globalReactions, id, out _); globalReactions = newGlobalReactions; } return default; } public Task TriggerReloadExpressions() => _pubSub.Pub(_exprsReloadedKey, true); #endregion #region Client Event Handlers private Task OnLeftGuild(SocketGuild arg) { newGuildReactions.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(); newGuildReactions[gc.GuildId] = exprs; } #endregion #region Basic Operations public async Task AddAsync(ulong? guildId, string key, string message) { key = key.ToLowerInvariant(); var expr = new NadekoExpression { 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 NadekoExpression[] GetExpressionsFor(ulong? maybeGuildId) { if (maybeGuildId is { } guildId) return newGuildReactions.TryGetValue(guildId, out var exprs) ? exprs : Array.Empty(); return globalReactions; } #endregion }