Begun reorganizing

This commit is contained in:
Kwoth
2023-03-13 03:08:31 +01:00
parent 1ad0bc33af
commit 0af8048938
21 changed files with 58 additions and 10 deletions

View File

@@ -1,46 +0,0 @@
using OneOf;
using OneOf.Types;
namespace NadekoBot.Common;
public static class BotCacheExtensions
{
public static async ValueTask<T?> GetOrDefaultAsync<T>(this IBotCache cache, TypedKey<T> key)
{
var result = await cache.GetAsync(key);
if (result.TryGetValue(out var val))
return val;
return default;
}
private static TypedKey<byte[]> GetImgKey(Uri uri)
=> new($"image:{uri}");
public static ValueTask SetImageDataAsync(this IBotCache c, string key, byte[] data)
=> c.SetImageDataAsync(new Uri(key), data);
public static async ValueTask SetImageDataAsync(this IBotCache c, Uri key, byte[] data)
=> await c.AddAsync(GetImgKey(key), data, expiry: TimeSpan.FromHours(48));
public static async ValueTask<OneOf<byte[], None>> GetImageDataAsync(this IBotCache c, Uri key)
=> await c.GetAsync(GetImgKey(key));
public static async Task<TimeSpan?> GetRatelimitAsync(
this IBotCache c,
TypedKey<long> key,
TimeSpan length)
{
var now = DateTime.UtcNow;
var nowB = now.ToBinary();
var cachedValue = await c.GetOrAddAsync(key,
() => Task.FromResult(now.ToBinary()),
expiry: length);
if (cachedValue == nowB)
return null;
var diff = now - DateTime.FromBinary(cachedValue);
return length - diff;
}
}

View File

@@ -1,47 +0,0 @@
using OneOf;
using OneOf.Types;
namespace NadekoBot.Common;
public interface IBotCache
{
/// <summary>
/// Adds an item to the cache
/// </summary>
/// <param name="key">Key to add</param>
/// <param name="value">Value to add to the cache</param>
/// <param name="expiry">Optional expiry</param>
/// <param name="overwrite">Whether old value should be overwritten</param>
/// <typeparam name="T">Type of the value</typeparam>
/// <returns>Returns whether add was sucessful. Always true unless ovewrite = false</returns>
ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true);
/// <summary>
/// Get an element from the cache
/// </summary>
/// <param name="key">Key</param>
/// <typeparam name="T">Type of the value</typeparam>
/// <returns>Either a value or <see cref="None"/></returns>
ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key);
/// <summary>
/// Remove a key from the cache
/// </summary>
/// <param name="key">Key to remove</param>
/// <typeparam name="T">Type of the value</typeparam>
/// <returns>Whether there was item</returns>
ValueTask<bool> RemoveAsync<T>(TypedKey<T> key);
/// <summary>
/// Get the key if it exists or add a new one
/// </summary>
/// <param name="key">Key to get and potentially add</param>
/// <param name="createFactory">Value creation factory</param>
/// <param name="expiry">Optional expiry</param>
/// <typeparam name="T">Type of the value</typeparam>
/// <returns>The retrieved or newly added value</returns>
ValueTask<T?> GetOrAddAsync<T>(
TypedKey<T> key,
Func<Task<T?>> createFactory,
TimeSpan? expiry = null);
}

View File

@@ -1,71 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using OneOf;
using OneOf.Types;
// ReSharper disable InconsistentlySynchronizedField
namespace NadekoBot.Common;
public sealed class MemoryBotCache : IBotCache
{
// needed for overwrites and Delete return value
private readonly object _cacheLock = new object();
private readonly MemoryCache _cache;
public MemoryBotCache()
{
_cache = new MemoryCache(new MemoryCacheOptions());
}
public ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true)
{
if (overwrite)
{
using var item = _cache.CreateEntry(key.Key);
item.Value = value;
item.AbsoluteExpirationRelativeToNow = expiry;
return new(true);
}
lock (_cacheLock)
{
if (_cache.TryGetValue(key.Key, out var old) && old is not null)
return new(false);
using var item = _cache.CreateEntry(key.Key);
item.Value = value;
item.AbsoluteExpirationRelativeToNow = expiry;
return new(true);
}
}
public async ValueTask<T?> GetOrAddAsync<T>(
TypedKey<T> key,
Func<Task<T?>> createFactory,
TimeSpan? expiry = null)
=> await _cache.GetOrCreateAsync(key.Key,
async ce =>
{
ce.AbsoluteExpirationRelativeToNow = expiry;
var val = await createFactory();
return val;
});
public ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key)
{
if (!_cache.TryGetValue(key.Key, out var val) || val is null)
return new(new None());
return new((T)val);
}
public ValueTask<bool> RemoveAsync<T>(TypedKey<T> key)
{
lock (_cacheLock)
{
var toReturn = _cache.TryGetValue(key.Key, out var old ) && old is not null;
_cache.Remove(key.Key);
return new(toReturn);
}
}
}

View File

@@ -1,10 +0,0 @@
using OneOf.Types;
using OneOf;
namespace NadekoBot.Common;
public static class OneOfExtensions
{
public static bool TryGetValue<T>(this OneOf<T, None> oneOf, out T value)
=> oneOf.TryPickT0(out value, out _);
}

View File

@@ -1,80 +0,0 @@
namespace NadekoBot.Common;
public class EventPubSub : IPubSub
{
private readonly Dictionary<string, Dictionary<Delegate, List<Func<object, ValueTask>>>> _actions = new();
private readonly object _locker = new();
public Task Sub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
where TData : notnull
{
Func<object, ValueTask> localAction = obj => action((TData)obj);
lock (_locker)
{
if (!_actions.TryGetValue(key.Key, out var keyActions))
{
keyActions = new();
_actions[key.Key] = keyActions;
}
if (!keyActions.TryGetValue(action, out var sameActions))
{
sameActions = new();
keyActions[action] = sameActions;
}
sameActions.Add(localAction);
return Task.CompletedTask;
}
}
public Task Pub<TData>(in TypedKey<TData> key, TData data)
where TData : notnull
{
lock (_locker)
{
if (_actions.TryGetValue(key.Key, out var actions))
// if this class ever gets used, this needs to be properly implemented
// 1. ignore all valuetasks which are completed
// 2. run all other tasks in parallel
return actions.SelectMany(kvp => kvp.Value).Select(action => action(data).AsTask()).WhenAll();
return Task.CompletedTask;
}
}
public Task Unsub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
{
lock (_locker)
{
// get subscriptions for this action
if (_actions.TryGetValue(key.Key, out var actions))
// get subscriptions which have the same action hash code
// note: having this as a list allows for multiple subscriptions of
// the same insance's/static method
{
if (actions.TryGetValue(action, out var sameActions))
{
// remove last subscription
sameActions.RemoveAt(sameActions.Count - 1);
// if the last subscription was the only subscription
// we can safely remove this action's dictionary entry
if (sameActions.Count == 0)
{
actions.Remove(action);
// if our dictionary has no more elements after
// removing the entry
// it's safe to remove it from the key's subscriptions
if (actions.Count == 0)
_actions.Remove(key.Key);
}
}
}
return Task.CompletedTask;
}
}
}

View File

@@ -1,10 +0,0 @@
namespace NadekoBot.Common;
public interface IPubSub
{
public Task Pub<TData>(in TypedKey<TData> key, TData data)
where TData : notnull;
public Task Sub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
where TData : notnull;
}

View File

@@ -1,7 +0,0 @@
namespace NadekoBot.Common;
public interface ISeria
{
byte[] Serialize<T>(T data);
T? Deserialize<T>(byte[]? data);
}

View File

@@ -1,30 +0,0 @@
namespace NadekoBot.Common;
public readonly struct TypedKey<TData>
{
public string Key { get; }
public TypedKey(in string key)
=> Key = key;
public static implicit operator TypedKey<TData>(in string input)
=> new(input);
public static implicit operator string(in TypedKey<TData> input)
=> input.Key;
public static bool operator ==(in TypedKey<TData> left, in TypedKey<TData> right)
=> left.Key == right.Key;
public static bool operator !=(in TypedKey<TData> left, in TypedKey<TData> right)
=> !(left == right);
public override bool Equals(object? obj)
=> obj is TypedKey<TData> o && o == this;
public override int GetHashCode()
=> Key?.GetHashCode() ?? 0;
public override string ToString()
=> Key;
}

View File

@@ -1,28 +0,0 @@
#nullable disable
using Nadeko.Common;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.NadekoExpressions;
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(NadekoExpression 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()
};
}

View File

@@ -1,10 +0,0 @@
namespace NadekoBot.Modules.NadekoExpressions;
public enum ExprField
{
AutoDelete,
DmResponse,
AllowTarget,
ContainsAnywhere,
Message
}

View File

@@ -1,88 +0,0 @@
#nullable disable
using NadekoBot.Services.Database.Models;
using System.Runtime.CompilerServices;
namespace NadekoBot.Modules.NadekoExpressions;
public static class NadekoExpressionExtensions
{
private static string ResolveTriggerString(this string str, DiscordSocketClient client)
=> str.Replace("%bot.mention%", client.CurrentUser.Mention, StringComparison.Ordinal);
public static async Task<IUserMessage> Send(
this NadekoExpression 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<char> str, in ReadOnlySpan<char> 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<char> 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
}

View File

@@ -1,393 +0,0 @@
#nullable disable
namespace NadekoBot.Modules.NadekoExpressions;
[Name("Expressions")]
public partial class NadekoExpressions : NadekoModule<NadekoExpressionsService>
{
public enum All
{
All
}
private readonly IBotCredentials _creds;
private readonly IHttpClientFactory _clientFactory;
public NadekoExpressions(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<string>();
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_NADEKO
[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();
}
}

View File

@@ -1,754 +0,0 @@
#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 LinqToDB.EntityFrameworkCore;
using Nadeko.Common;
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:
# - <List
# - of
# - reactions>
# 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<NadekoExpression> _gexprAddedKey = new("gexpr.added");
private readonly TypedKey<int> _gexprDeletedkey = new("gexpr.deleted");
private readonly TypedKey<NadekoExpression> _gexprEditedKey = new("gexpr.edited");
private readonly TypedKey<bool> _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[] globalExpressions = Array.Empty<NadekoExpression>();
private ConcurrentDictionary<ulong, NadekoExpression[]> newguildExpressions = new();
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;
private ConcurrentHashSet<ulong> _disabledGlobalExpressionGuilds;
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<ulong> 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, _bot.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, _bot.Mention);
return x;
})
.ToArray();
globalExpressions = 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 (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 NadekoExpression MatchExpressions(in ReadOnlySpan<char> content, NadekoExpression[] exprs)
{
var result = new List<NadekoExpression>(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<bool> 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)
{
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, NadekoExpression 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<NadekoExpression>(),
(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 NadekoExpression[] DeleteInternal(
IReadOnlyList<NadekoExpression> 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<string> 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();
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<bool> ImportExpressionsAsync(ulong? guildId, string input)
{
Dictionary<string, List<ExportedExpr>> data;
try
{
data = Yaml.Deserializer.Deserialize<Dictionary<string, List<ExportedExpr>>>(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[globalExpressions.Length + 1];
Array.Copy(globalExpressions, newGlobalReactions, globalExpressions.Length);
newGlobalReactions[globalExpressions.Length] = c;
globalExpressions = newGlobalReactions;
}
return default;
}
private ValueTask OnGexprEdited(NadekoExpression 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<NadekoExpression> 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<NadekoExpression> 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<NadekoExpression> 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 newguildExpressions.TryGetValue(guildId, out var exprs) ? exprs : Array.Empty<NadekoExpression>();
return globalExpressions;
}
#endregion
public async Task<bool> 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;
}
}

View File

@@ -108,6 +108,7 @@
<ProjectReference Include="..\Nadeko.Econ\Nadeko.Econ.csproj" />
<ProjectReference Include="..\Nadeko.Medusa\Nadeko.Medusa.csproj" />
<ProjectReference Include="..\NadekoBot.Generators\NadekoBot.Generators.csproj" OutputItemType="Analyzer" />
<ProjectReference Include="..\NadekoBot.Modules.Expresssions\NadekoBot.Modules.Expresssions.csproj" />
</ItemGroup>
<ItemGroup>
@@ -139,6 +140,9 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Folder Include="Modules\Expressions" />
</ItemGroup>
<PropertyGroup Condition=" '$(Version)' == '' ">
<VersionPrefix Condition=" '$(VersionPrefix)' == '' ">5.0.0</VersionPrefix>