using NadekoBot.Common.ModuleBehaviors; using NadekoBot.Services.Database.Models; using NadekoBot.Modules.CustomReactions.Extensions; using NadekoBot.Modules.Permissions.Common; using NadekoBot.Modules.Permissions.Services; using System.Runtime.CompilerServices; using Microsoft.EntityFrameworkCore; using NadekoBot.Common.Yml; using NadekoBot.Db; using YamlDotNet.Serialization; namespace NadekoBot.Modules.CustomReactions.Services; public sealed class CustomReactionsService : IEarlyBehavior, IReadyExecutor { public enum CrField { AutoDelete, DmResponse, AllowTarget, ContainsAnywhere, Message, } private readonly object _gcrWriteLock = new(); private readonly TypedKey _gcrAddedKey = new("gcr.added"); private readonly TypedKey _gcrDeletedkey = new("gcr.deleted"); private readonly TypedKey _gcrEditedKey = new("gcr.edited"); private readonly TypedKey _crsReloadedKey = new("crs.reloaded"); private const string MentionPh = "%bot.mention%"; // it is perfectly fine to have global customreactions as an array // 1. custom reactions 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 CustomReaction[] _globalReactions; private ConcurrentDictionary _newGuildReactions; public int Priority => 0; 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; public CustomReactionsService(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(_crsReloadedKey, OnCrsShouldReload); pubSub.Sub(_gcrAddedKey, OnGcrAdded); pubSub.Sub(_gcrDeletedkey, OnGcrDeleted); pubSub.Sub(_gcrEditedKey, OnGcrEdited); bot.JoinedGuild += OnJoinedGuild; _client.LeftGuild += OnLeftGuild; } private async Task ReloadInternal(IReadOnlyList allGuildIds) { await using var uow = _db.GetDbContext(); var guildItems = await uow.CustomReactions .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(MentionPh, _bot.Mention); return x; }).ToArray()) .ToConcurrent(); lock (_gcrWriteLock) { var globalItems = uow .CustomReactions .AsNoTracking() .Where(x => x.GuildId == null || x.GuildId == 0) .AsEnumerable() .Select(x => { x.Trigger = x.Trigger.Replace(MentionPh, _bot.Mention); return x; }) .ToArray(); _globalReactions = globalItems; } ready = true; } #region Event Handlers public Task OnReadyAsync() => ReloadInternal(_bot.GetCurrentGuildIds()); private ValueTask OnCrsShouldReload(bool _) => new(ReloadInternal(_bot.GetCurrentGuildIds())); private ValueTask OnGcrAdded(CustomReaction c) { lock (_gcrWriteLock) { var newGlobalReactions = new CustomReaction[_globalReactions.Length + 1]; Array.Copy(_globalReactions, newGlobalReactions, _globalReactions.Length); newGlobalReactions[_globalReactions.Length] = c; _globalReactions = newGlobalReactions; } return default; } private ValueTask OnGcrEdited(CustomReaction c) { lock (_gcrWriteLock) { for (var i = 0; i < _globalReactions.Length; i++) { if (_globalReactions[i].Id == c.Id) { _globalReactions[i] = c; return default; } } // if edited cr is not found?! // add it OnGcrAdded(c); } return default; } private ValueTask OnGcrDeleted(int id) { lock (_gcrWriteLock) { var newGlobalReactions = DeleteInternal(_globalReactions, id, out _); _globalReactions = newGlobalReactions; } return default; } public Task TriggerReloadCustomReactions() => _pubSub.Pub(_crsReloadedKey, 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 crs = await uow .CustomReactions .AsNoTracking() .Where(x => x.GuildId == gc.GuildId) .ToArrayAsync(); _newGuildReactions[gc.GuildId] = crs; } #endregion #region Basic Operations public async Task AddAsync(ulong? guildId, string key, string message) { key = key.ToLowerInvariant(); var cr = new CustomReaction() { GuildId = guildId, Trigger = key, Response = message, }; if (cr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) cr.AllowTarget = true; await using (var uow = _db.GetDbContext()) { uow.CustomReactions.Add(cr); await uow.SaveChangesAsync(); } await AddInternalAsync(guildId, cr); return cr; } public async Task EditAsync(ulong? guildId, int id, string message) { await using var uow = _db.GetDbContext(); var cr = uow.CustomReactions.GetById(id); if (cr is null || cr.GuildId != guildId) return null; // disable allowtarget if message had target, but it was removed from it if (!message.Contains("%target%", StringComparison.OrdinalIgnoreCase) && cr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) { cr.AllowTarget = false; } cr.Response = message; // enable allow target if message is edited to contain target if (cr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) cr.AllowTarget = true; await uow.SaveChangesAsync(); await UpdateInternalAsync(guildId, cr); return cr; } public async Task DeleteAsync(ulong? guildId, int id) { await using var uow = _db.GetDbContext(); var toDelete = uow.CustomReactions.GetById(id); if (toDelete is null) return null; if ((toDelete.IsGlobal() && guildId is null) || guildId == toDelete.GuildId) { uow.CustomReactions.Remove(toDelete); await uow.SaveChangesAsync(); await DeleteInternalAsync(guildId, id); return toDelete; } return null; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public CustomReaction[] GetCustomReactionsFor(ulong? maybeGuildId) { if (maybeGuildId is { } guildId) { return _newGuildReactions.TryGetValue(guildId, out var crs) ? crs : Array.Empty(); } return _globalReactions; } #endregion private bool ready; private CustomReaction TryGetCustomReaction(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 cr = MatchCustomReactions(content, reactions); if (cr is not null) return cr; } var localGrs = _globalReactions; return MatchCustomReactions(content, localGrs); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private CustomReaction MatchCustomReactions(in ReadOnlySpan content, CustomReaction[] crs) { var result = new List(1); for (var i = 0; i < crs.Length; i++) { var cr = crs[i]; var trigger = cr.Trigger; if (content.Length > trigger.Length) { // if input is greater than the trigger, it can only work if: // it has CA enabled if (cr.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(cr); } // 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 CR has AllowTarget, then the // content has to start with the trigger followed by a space if (cr.AllowTarget && content.StartsWith(trigger, StringComparison.OrdinalIgnoreCase) && content[trigger.Length] == ' ') { result.Add(cr); } } else if (content.Length < cr.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(cr.Trigger)) { result.Add(cr); } } } 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 RunBehavior(IGuild guild, IUserMessage msg) { // maybe this message is a custom reaction var cr = TryGetCustomReaction(msg); if (cr is null || cr.Response == "-") return false; if(await _cmdCds.TryBlock(guild, msg.Author, cr.Trigger)) return false; try { if (_gperm.BlockedModules.Contains("ActualCustomReactions")) { Log.Information("User {UserName} [{UserId}] tried to use a custom reaction but 'ActualCustomReactions' 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, cr.Trigger, "ActualCustomReactions", out var index)) { if (pc.Verbose) { var returnMsg = _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, returnMsg).ConfigureAwait(false); } catch { } Log.Information(returnMsg); } return true; } } var sentMsg = await cr.Send(msg, _client, false).ConfigureAwait(false); var reactions = cr.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, cr.GuildId); break; } await Task.Delay(1000); } if (cr.AutoDeleteTrigger) { try { await msg.DeleteAsync().ConfigureAwait(false); } catch { } } Log.Information("s: {GuildId} c: {ChannelId} u: {UserId} | {UserName} executed expression {Expr}", guild.Id, msg.Channel.Id, msg.Author.Id, msg.Author.ToString(), cr.Trigger); return true; } catch (Exception ex) { Log.Warning(ex.Message); } return false; } public async Task ResetCrReactions(ulong? maybeGuildId, int id) { CustomReaction cr; await using var uow = _db.GetDbContext(); cr = uow.CustomReactions.GetById(id); if (cr is null) return; cr.Reactions = string.Empty; await uow.SaveChangesAsync(); } private Task UpdateInternalAsync(ulong? maybeGuildId, CustomReaction cr) { if (maybeGuildId is { } guildId) UpdateInternal(guildId, cr); else return _pubSub.Pub(_gcrEditedKey, cr); return Task.CompletedTask; } private void UpdateInternal(ulong? maybeGuildId, CustomReaction cr) { if (maybeGuildId is { } guildId) { _newGuildReactions.AddOrUpdate(guildId, new[] {cr}, (key, old) => { var newArray = old.ToArray(); for (var i = 0; i < newArray.Length; i++) { if (newArray[i].Id == cr.Id) newArray[i] = cr; } return newArray; }); } else { lock (_gcrWriteLock) { var crs = _globalReactions; for (var i = 0; i < crs.Length; i++) { if (crs[i].Id == cr.Id) crs[i] = cr; } } } } private Task AddInternalAsync(ulong? maybeGuildId, CustomReaction cr) { // only do this for perf purposes cr.Trigger = cr.Trigger.Replace(MentionPh, _client.CurrentUser.Mention); if (maybeGuildId is { } guildId) { _newGuildReactions.AddOrUpdate(guildId, new[] {cr}, (key, old) => old.With(cr)); } else { return _pubSub.Pub(_gcrAddedKey, cr); } 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 (_gcrWriteLock) { var cr = Array.Find(_globalReactions, item => item.Id == id); if (cr is not null) { return _pubSub.Pub(_gcrDeletedkey, cr.Id); } } return Task.CompletedTask; } private CustomReaction[] DeleteInternal(IReadOnlyList crs, int id, out CustomReaction deleted) { deleted = null; if (crs is null || crs.Count == 0) return crs as CustomReaction[] ?? crs?.ToArray(); var newCrs = new CustomReaction[crs.Count - 1]; for (int i = 0, k = 0; i < crs.Count; i++, k++) { if (crs[i].Id == id) { deleted = crs[i]; k--; continue; } newCrs[k] = crs[i]; } return newCrs; } public async Task SetCrReactions(ulong? guildId, int id, IEnumerable emojis) { CustomReaction cr; await using (var uow = _db.GetDbContext()) { cr = uow.CustomReactions.GetById(id); if (cr is null) return; cr.Reactions = string.Join("@@@", emojis); await uow.SaveChangesAsync(); } await UpdateInternalAsync(guildId, cr); } public async Task<(bool Sucess, bool NewValue)> ToggleCrOptionAsync(int id, CrField field) { var newVal = false; CustomReaction cr; await using (var uow = _db.GetDbContext()) { cr = uow.CustomReactions.GetById(id); if (cr is null) return (false, false); if (field == CrField.AutoDelete) newVal = cr.AutoDeleteTrigger = !cr.AutoDeleteTrigger; else if (field == CrField.ContainsAnywhere) newVal = cr.ContainsAnywhere = !cr.ContainsAnywhere; else if (field == CrField.DmResponse) newVal = cr.DmResponse = !cr.DmResponse; else if (field == CrField.AllowTarget) newVal = cr.AllowTarget = !cr.AllowTarget; await uow.SaveChangesAsync(); } await UpdateInternalAsync(cr.GuildId, cr); return (true, newVal); } public CustomReaction GetCustomReaction(ulong? guildId, int id) { using var uow = _db.GetDbContext(); var cr = uow.CustomReactions.GetById(id); if (cr is null || cr.GuildId != guildId) return null; return cr; } public int DeleteAllCustomReactions(ulong guildId) { using var uow = _db.GetDbContext(); var count = uow.CustomReactions.ClearFromGuild(guildId); uow.SaveChanges(); _newGuildReactions.TryRemove(guildId, out _); return count; } public bool ReactionExists(ulong? guildId, string input) { using var uow = _db.GetDbContext(); var cr = uow.CustomReactions.GetByGuildIdAndInput(guildId, input); return cr != null; } private static readonly ISerializer _exportSerializer = new SerializerBuilder() .WithEventEmitter(args => new MultilineScalarFlowStyleEmitter(args)) .WithNamingConvention(YamlDotNet.Serialization.NamingConventions.CamelCaseNamingConvention.Instance) .WithIndentedSequences() .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitDefaults) .DisableAliases() .Build(); private const string _prependExport = @"# Keys are triggers, Each key has a LIST of custom reactions in the following format: # - res: Response string # id: Alphanumeric id used for commands related to the custom reaction. (Note, when using .crsimport, a new id will be generated.) # react: # - # at: Whether custom reaction allows targets (see .h .crat) # ca: Whether custom reaction expects trigger anywhere (see .h .crca) # dm: Whether custom reaction DMs the response (see .h .crdm) # ad: Whether custom reaction automatically deletes triggering message (see .h .crad) "; public string ExportCrs(ulong? guildId) { var crs = GetCustomReactionsFor(guildId); var crsDict = crs .GroupBy(x => x.Trigger) .ToDictionary(x => x.Key, x => x.Select(ExportedExpr.FromModel)); return _prependExport + _exportSerializer .Serialize(crsDict) .UnescapeUnicodeCodePoints(); } public async Task ImportCrsAsync(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.CustomReactions.AddRangeAsync(entry.Value .Where(cr => !string.IsNullOrWhiteSpace(cr.Res)) .Select(cr => new CustomReaction() { GuildId = guildId, Response = cr.Res, Reactions = cr.React?.JoinWith("@@@"), Trigger = trigger, AllowTarget = cr.At, ContainsAnywhere = cr.Ca, DmResponse = cr.Dm, AutoDeleteTrigger = cr.Ad, })); } await uow.SaveChangesAsync(); await TriggerReloadCustomReactions(); return true; } }