diff --git a/src/EllieBot/_common/Abstractions/AsyncLazy.cs b/src/EllieBot/_common/Abstractions/AsyncLazy.cs new file mode 100644 index 0000000..6c86693 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/AsyncLazy.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; + +namespace Ellie.Common; + +public class AsyncLazy : Lazy> +{ + public AsyncLazy(Func valueFactory) + : base(() => Task.Run(valueFactory)) + { + } + + public AsyncLazy(Func> taskFactory) + : base(() => Task.Run(taskFactory)) + { + } + + public TaskAwaiter GetAwaiter() + => Value.GetAwaiter(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs b/src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs new file mode 100644 index 0000000..2c6ea67 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs @@ -0,0 +1,46 @@ +using OneOf; +using OneOf.Types; + +namespace Ellie.Common; + +public static class BotCacheExtensions +{ + public static async ValueTask GetOrDefaultAsync(this IBotCache cache, TypedKey key) + { + var result = await cache.GetAsync(key); + if (result.TryGetValue(out var val)) + return val; + + return default; + } + + private static TypedKey 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> GetImageDataAsync(this IBotCache c, Uri key) + => await c.GetAsync(GetImgKey(key)); + + public static async Task GetRatelimitAsync( + this IBotCache c, + TypedKey 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; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Cache/IBotCache.cs b/src/EllieBot/_common/Abstractions/Cache/IBotCache.cs new file mode 100644 index 0000000..15f9e3f --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Cache/IBotCache.cs @@ -0,0 +1,47 @@ +using OneOf; +using OneOf.Types; + +namespace Ellie.Common; + +public interface IBotCache +{ + /// + /// Adds an item to the cache + /// + /// Key to add + /// Value to add to the cache + /// Optional expiry + /// Whether old value should be overwritten + /// Type of the value + /// Returns whether add was sucessful. Always true unless ovewrite = false + ValueTask AddAsync(TypedKey key, T value, TimeSpan? expiry = null, bool overwrite = true); + + /// + /// Get an element from the cache + /// + /// Key + /// Type of the value + /// Either a value or + ValueTask> GetAsync(TypedKey key); + + /// + /// Remove a key from the cache + /// + /// Key to remove + /// Type of the value + /// Whether there was item + ValueTask RemoveAsync(TypedKey key); + + /// + /// Get the key if it exists or add a new one + /// + /// Key to get and potentially add + /// Value creation factory + /// Optional expiry + /// Type of the value + /// The retrieved or newly added value + ValueTask GetOrAddAsync( + TypedKey key, + Func> createFactory, + TimeSpan? expiry = null); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs new file mode 100644 index 0000000..2368ac2 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Caching.Memory; +using OneOf; +using OneOf.Types; + +// ReSharper disable InconsistentlySynchronizedField + +namespace Ellie.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 AddAsync(TypedKey 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 GetOrAddAsync( + TypedKey key, + Func> createFactory, + TimeSpan? expiry = null) + => await _cache.GetOrCreateAsync(key.Key, + async ce => + { + ce.AbsoluteExpirationRelativeToNow = expiry; + var val = await createFactory(); + return val; + }); + + public ValueTask> GetAsync(TypedKey key) + { + if (!_cache.TryGetValue(key.Key, out var val) || val is null) + return new(new None()); + + return new((T)val); + } + + public ValueTask RemoveAsync(TypedKey key) + { + lock (_cacheLock) + { + var toReturn = _cache.TryGetValue(key.Key, out var old ) && old is not null; + _cache.Remove(key.Key); + return new(toReturn); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs b/src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs new file mode 100644 index 0000000..7d5bbc1 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; + +namespace System.Collections.Generic; + +[DebuggerDisplay("{_backingStore.Count}")] +public sealed class ConcurrentHashSet : IReadOnlyCollection, ICollection where T : notnull +{ + private readonly ConcurrentDictionary _backingStore; + + public ConcurrentHashSet() + => _backingStore = new(); + + public ConcurrentHashSet(IEnumerable values, IEqualityComparer? comparer = null) + => _backingStore = new(values.Select(x => new KeyValuePair(x, true)), comparer); + + public IEnumerator GetEnumerator() + => _backingStore.Keys.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + /// + /// Adds the specified item to the . + /// + /// The item to add. + /// + /// true if the items was added to the + /// successfully; false if it already exists. + /// + /// + /// The + /// contains too many items. + /// + public bool Add(T item) + => _backingStore.TryAdd(item, true); + + void ICollection.Add(T item) + => Add(item); + + public void Clear() + => _backingStore.Clear(); + + public bool Contains(T item) + => _backingStore.ContainsKey(item); + + public void CopyTo(T[] array, int arrayIndex) + { + ArgumentNullException.ThrowIfNull(array); + ArgumentOutOfRangeException.ThrowIfNegative(arrayIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(arrayIndex, array.Length); + + CopyToInternal(array, arrayIndex); + } + + private void CopyToInternal(T[] array, int arrayIndex) + { + var len = array.Length; + foreach (var (k, _) in _backingStore) + { + if (arrayIndex >= len) + throw new IndexOutOfRangeException(nameof(arrayIndex)); + + array[arrayIndex++] = k; + } + } + + bool ICollection.Remove(T item) + => TryRemove(item); + + public bool TryRemove(T item) + => _backingStore.TryRemove(item, out _); + + public void RemoveWhere(Func predicate) + { + foreach (var elem in this.Where(predicate)) + TryRemove(elem); + } + + public int Count + => _backingStore.Count; + + public bool IsReadOnly + => false; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Collections/IndexedCollection.cs b/src/EllieBot/_common/Abstractions/Collections/IndexedCollection.cs new file mode 100644 index 0000000..dce86a0 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Collections/IndexedCollection.cs @@ -0,0 +1,148 @@ +using System.Collections; + +namespace Ellie.Common; + +public interface IIndexed +{ + int Index { get; set; } +} + +public class IndexedCollection : IList + where T : class, IIndexed +{ + public List Source { get; } + + public int Count + => Source.Count; + + public bool IsReadOnly + => false; + + public virtual T this[int index] + { + get => Source[index]; + set + { + lock (_locker) + { + value.Index = index; + Source[index] = value; + } + } + } + + private readonly object _locker = new(); + + public IndexedCollection() + => Source = new(); + + public IndexedCollection(IEnumerable source) + { + lock (_locker) + { + Source = source.OrderBy(x => x.Index).ToList(); + UpdateIndexes(); + } + } + + public int IndexOf(T item) + => item?.Index ?? -1; + + public IEnumerator GetEnumerator() + => Source.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => Source.GetEnumerator(); + + public void Add(T item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (_locker) + { + item.Index = Source.Count; + Source.Add(item); + } + } + + public virtual void Clear() + { + lock (_locker) + { + Source.Clear(); + } + } + + public bool Contains(T item) + { + lock (_locker) + { + return Source.Contains(item); + } + } + + public void CopyTo(T[] array, int arrayIndex) + { + lock (_locker) + { + Source.CopyTo(array, arrayIndex); + } + } + + public virtual bool Remove(T item) + { + lock (_locker) + { + if (Source.Remove(item)) + { + for (var i = 0; i < Source.Count; i++) + { + if (Source[i].Index != i) + Source[i].Index = i; + } + + return true; + } + } + + return false; + } + + public virtual void Insert(int index, T item) + { + lock (_locker) + { + Source.Insert(index, item); + for (var i = index; i < Source.Count; i++) + Source[i].Index = i; + } + } + + public virtual void RemoveAt(int index) + { + lock (_locker) + { + Source.RemoveAt(index); + for (var i = index; i < Source.Count; i++) + Source[i].Index = i; + } + } + + public void UpdateIndexes() + { + lock (_locker) + { + for (var i = 0; i < Source.Count; i++) + { + if (Source[i].Index != i) + Source[i].Index = i; + } + } + } + + public static implicit operator List(IndexedCollection x) + => x.Source; + + public List ToList() + => Source.ToList(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/EllieRandom.cs b/src/EllieBot/_common/Abstractions/EllieRandom.cs new file mode 100644 index 0000000..df2d073 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/EllieRandom.cs @@ -0,0 +1,66 @@ +#nullable disable +using System.Security.Cryptography; + +namespace Ellie.Common; + +public sealed class EllieRandom : Random +{ + private readonly RandomNumberGenerator _rng; + + public EllieRandom() + => _rng = RandomNumberGenerator.Create(); + + public override int Next() + { + var bytes = new byte[sizeof(int)]; + _rng.GetBytes(bytes); + return Math.Abs(BitConverter.ToInt32(bytes, 0)); + } + + /// + /// Generates a random integer between 0 (inclusive) and + /// a specified exclusive upper bound using a cryptographically strong random number generator. + /// + /// Exclusive max value + /// A random number + public override int Next(int maxValue) + => RandomNumberGenerator.GetInt32(maxValue); + + /// + /// Generates a random integer between a specified inclusive lower bound and a + /// specified exclusive upper bound using a cryptographically strong random number generator. + /// + /// Inclusive min value + /// Exclusive max value + /// A random number + public override int Next(int minValue, int maxValue) + => RandomNumberGenerator.GetInt32(minValue, maxValue); + + public long NextLong(long minValue, long maxValue) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(minValue, maxValue); + if (minValue == maxValue) + return minValue; + var bytes = new byte[sizeof(long)]; + _rng.GetBytes(bytes); + var sign = Math.Sign(BitConverter.ToInt64(bytes, 0)); + return (sign * BitConverter.ToInt64(bytes, 0) % (maxValue - minValue)) + minValue; + } + + public override void NextBytes(byte[] buffer) + => _rng.GetBytes(buffer); + + protected override double Sample() + { + var bytes = new byte[sizeof(double)]; + _rng.GetBytes(bytes); + return Math.Abs((BitConverter.ToDouble(bytes, 0) / double.MaxValue) + 1); + } + + public override double NextDouble() + { + var bytes = new byte[sizeof(double)]; + _rng.GetBytes(bytes); + return BitConverter.ToDouble(bytes, 0); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs new file mode 100644 index 0000000..f3e8ae3 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs @@ -0,0 +1,62 @@ +using System.Security.Cryptography; + +namespace Ellie.Common; + +// made for expressions because they almost never get added +// and they get looped through constantly +public static class ArrayExtensions +{ + /// + /// Create a new array from the old array + new element at the end + /// + /// Input array + /// Item to add to the end of the output array + /// Type of the array + /// A new array with the new element at the end + public static T[] With(this T[] input, T added) + { + var newExprs = new T[input.Length + 1]; + Array.Copy(input, 0, newExprs, 0, input.Length); + newExprs[input.Length] = added; + return newExprs; + } + + /// + /// Creates a new array by applying the specified function to every element in the input array + /// + /// Array to modify + /// Function to apply + /// Orignal type of the elements in the array + /// Output type of the elements of the array + /// New array with updated elements + public static TOut[] Map(this TIn[] arr, Func f) + => Array.ConvertAll(arr, x => f(x)); + + /// + /// Creates a new array by applying the specified function to every element in the input array + /// + /// Array to modify + /// Function to apply + /// Orignal type of the elements in the array + /// Output type of the elements of the array + /// New array with updated elements + public static TOut[] Map(this IReadOnlyCollection col, Func f) + { + var toReturn = new TOut[col.Count]; + + var i = 0; + foreach (var item in col) + toReturn[i++] = f(item); + + return toReturn; + } + + public static T? RandomOrDefault(this T[] data) + { + if (data.Length == 0) + return default; + + var index = RandomNumberGenerator.GetInt32(0, data.Length); + return data[index]; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs new file mode 100644 index 0000000..1ebec24 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs @@ -0,0 +1,113 @@ +using System.Security.Cryptography; + +namespace Ellie.Common; + +public static class EnumerableExtensions +{ + /// + /// Concatenates the members of a collection, using the specified separator between each member. + /// + /// Collection to join + /// + /// The character to use as a separator. separator is included in the returned string only if + /// values has more than one element. + /// + /// Optional transformation to apply to each element before concatenation. + /// The type of the members of values. + /// + /// A string that consists of the members of values delimited by the separator character. -or- Empty if values has + /// no elements. + /// + public static string Join(this IEnumerable data, char separator, Func? func = null) + => string.Join(separator, data.Select(func ?? (x => x?.ToString() ?? string.Empty))); + + /// + /// Concatenates the members of a collection, using the specified separator between each member. + /// + /// Collection to join + /// + /// The string to use as a separator.separator is included in the returned string only if values + /// has more than one element. + /// + /// Optional transformation to apply to each element before concatenation. + /// The type of the members of values. + /// + /// A string that consists of the members of values delimited by the separator character. -or- Empty if values has + /// no elements. + /// + public static string Join(this IEnumerable data, string separator, Func? func = null) + => string.Join(separator, data.Select(func ?? (x => x?.ToString() ?? string.Empty))); + + /// + /// Randomize element order by performing the Fisher-Yates shuffle + /// + /// Item type + /// Items to shuffle + public static IReadOnlyList Shuffle(this IEnumerable items) + { + var list = items.ToArray(); + var n = list.Length; + while (n-- > 1) + { + var k = RandomNumberGenerator.GetInt32(n); + (list[k], list[n]) = (list[n], list[k]); + } + + return list; + } + + /// + /// Initializes a new instance of the class + /// that contains elements copied from the specified + /// has the default concurrency level, has the default initial capacity, + /// and uses the default comparer for the key type. + /// + /// + /// The whose elements are copied to the new + /// . + /// + /// A new instance of the class + public static ConcurrentDictionary ToConcurrent( + this IEnumerable> dict) + where TKey : notnull + => new(dict); + + /// + /// Initializes a new instance of the class + /// that contains elements copied from the specified + /// has the default concurrency level, has the default initial capacity, + /// and uses the default comparer for the key type. + /// + /// + /// The whose elements are copied to the new + /// . + /// + /// A new instance of the class + public static ConcurrentHashSet ToConcurrentSet( + this IReadOnlyCollection dict) + where TValue : notnull + => new(dict); + + public static IndexedCollection ToIndexed(this IEnumerable enumerable) + where T : class, IIndexed + => new(enumerable); + + /// + /// Creates a task that will complete when all of the objects in an enumerable + /// collection have completed + /// + /// The tasks to wait on for completion. + /// The type of the completed task. + /// A task that represents the completion of all of the supplied tasks. + public static Task WhenAll(this IEnumerable> tasks) + => Task.WhenAll(tasks); + + /// + /// Creates a task that will complete when all of the objects in an enumerable + /// collection have completed + /// + /// The tasks to wait on for completion. + /// A task that represents the completion of all of the supplied tasks. + public static Task WhenAll(this IEnumerable tasks) + => Task.WhenAll(tasks); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/Extensions.cs b/src/EllieBot/_common/Abstractions/Extensions/Extensions.cs new file mode 100644 index 0000000..8abf5d6 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/Extensions.cs @@ -0,0 +1,7 @@ +namespace Ellie.Common; + +public static class Extensions +{ + public static long ToTimestamp(this in DateTime value) + => (value.Ticks - 621355968000000000) / 10000000; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs new file mode 100644 index 0000000..38e6396 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs @@ -0,0 +1,35 @@ +using System.Net.Http.Headers; + +namespace Ellie.Common; + +public static class HttpClientExtensions +{ + public static HttpClient AddFakeHeaders(this HttpClient http) + { + AddFakeHeaders(http.DefaultRequestHeaders); + return http; + } + + public static void AddFakeHeaders(this HttpHeaders dict) + { + dict.Clear(); + dict.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); + dict.Add("User-Agent", + "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/535.1 (KHTML, like Gecko) Chrome/14.0.835.202 Safari/535.1"); + } + + public static bool IsImage(this HttpResponseMessage msg) + => IsImage(msg, out _); + + public static bool IsImage(this HttpResponseMessage msg, out string? mimeType) + { + mimeType = msg.Content.Headers.ContentType?.MediaType; + if (mimeType is "image/png" or "image/jpeg" or "image/gif") + return true; + + return false; + } + + public static long GetContentLength(this HttpResponseMessage msg) + => msg.Content.Headers.ContentLength ?? long.MaxValue; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs new file mode 100644 index 0000000..f9c4cde --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs @@ -0,0 +1,10 @@ +using OneOf.Types; +using OneOf; + +namespace Ellie.Common; + +public static class OneOfExtensions +{ + public static bool TryGetValue(this OneOf oneOf, out T value) + => oneOf.TryPickT0(out value, out _); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs new file mode 100644 index 0000000..65b5bb2 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs @@ -0,0 +1,22 @@ +namespace Ellie.Common; + +public delegate TOut PipeFunc(in TIn a); +public delegate TOut PipeFunc(in TIn1 a, in TIn2 b); + +public static class PipeExtensions +{ + public static TOut Pipe(this TIn a, Func fn) + => fn(a); + + public static TOut Pipe(this TIn a, PipeFunc fn) + => fn(a); + + public static TOut Pipe(this (TIn1, TIn2) a, PipeFunc fn) + => fn(a.Item1, a.Item2); + + public static (TIn, TExtra) With(this TIn a, TExtra b) + => (a, b); + + public static async Task Pipe(this Task a, Func fn) + => fn(await a); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs new file mode 100644 index 0000000..2515e45 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs @@ -0,0 +1,151 @@ +using EllieBot.Common.Yml; +using System.Text; +using System.Text.RegularExpressions; + +namespace EllieBot.Extensions; + +public static class StringExtensions +{ + private static readonly HashSet _lettersAndDigits = + [ + ..Enumerable.Range(48, 10) + .Concat(Enumerable.Range(65, 26)) + .Concat(Enumerable.Range(97, 26)) + .Select(x => (char)x) + ]; + + private static readonly Regex _filterRegex = new(@"discord(?:\.gg|\.io|\.me|\.li|(?:app)?\.com\/invite)\/(\w+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex _codePointRegex = + new(@"(\\U(?[a-zA-Z0-9]{8})|\\u(?[a-zA-Z0-9]{4})|\\x(?[a-zA-Z0-9]{2}))", + RegexOptions.Compiled); + + public static string PadBoth(this string str, int length) + { + var spaces = length - str.Length; + var padLeft = (spaces / 2) + str.Length; + return str.PadLeft(padLeft, ' ').PadRight(length, ' '); + } + + public static string StripHtml(this string input) + => Regex.Replace(input, "<.*?>", string.Empty); + + public static string? TrimTo(this string? str, int maxLength, bool hideDots = false) + { + if (hideDots) + { + return str?.Substring(0, Math.Min(str?.Length ?? 0, maxLength)); + } + + if (str is null || str.Length <= maxLength) + return str; + + return string.Concat(str.AsSpan(0, maxLength - 1), "…"); + } + + public static string ToTitleCase(this string str) + { + var tokens = str.Split([" "], StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < tokens.Length; i++) + { + var token = tokens[i]; + tokens[i] = token[..1].ToUpperInvariant() + token[1..]; + } + + return tokens.Join(" ").Replace(" Of ", " of ").Replace(" The ", " the "); + } + + //http://www.dotnetperls.com/levenshtein + public static int LevenshteinDistance(this string s, string t) + { + var n = s.Length; + var m = t.Length; + var d = new int[n + 1, m + 1]; + + // Step 1 + if (n == 0) + return m; + + if (m == 0) + return n; + + // Step 2 + for (var i = 0; i <= n; d[i, 0] = i++) + { + } + + for (var j = 0; j <= m; d[0, j] = j++) + { + } + + // Step 3 + for (var i = 1; i <= n; i++) + //Step 4 + for (var j = 1; j <= m; j++) + { + // Step 5 + var cost = t[j - 1] == s[i - 1] ? 0 : 1; + + // Step 6 + d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); + } + + // Step 7 + return d[n, m]; + } + + public static async Task ToStream(this string str) + { + var ms = new MemoryStream(); + var sw = new StreamWriter(ms); + await sw.WriteAsync(str); + await sw.FlushAsync(); + ms.Position = 0; + return ms; + } + + public static bool IsDiscordInvite(this string str) + => _filterRegex.IsMatch(str); + + public static string Unmention(this string str) + => str.Replace("@", "ම", StringComparison.InvariantCulture); + + public static string SanitizeMentions(this string str, bool sanitizeRoleMentions = false) + { + str = str.Replace("@everyone", "@everyοne", StringComparison.InvariantCultureIgnoreCase) + .Replace("@here", "@һere", StringComparison.InvariantCultureIgnoreCase); + if (sanitizeRoleMentions) + str = str.SanitizeRoleMentions(); + + return str; + } + + public static string SanitizeRoleMentions(this string str) + => str.Replace("<@&", "<ම&", StringComparison.InvariantCultureIgnoreCase); + + public static string SanitizeAllMentions(this string str) + => str.SanitizeMentions().SanitizeRoleMentions(); + + public static string ToBase64(this string plainText) + { + var plainTextBytes = Encoding.UTF8.GetBytes(plainText); + return Convert.ToBase64String(plainTextBytes); + } + + public static string GetInitials(this string txt, string glue = "") + => txt.Split(' ').Select(x => x.FirstOrDefault()).Join(glue); + + public static bool IsAlphaNumeric(this string txt) + => txt.All(c => _lettersAndDigits.Contains(c)); + + public static string UnescapeUnicodeCodePoints(this string input) + => _codePointRegex.Replace(input, + me => + { + var str = me.Groups["code"].Value; + var newString = str.UnescapeUnicodeCodePoint(); + return newString; + }); + +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs b/src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs new file mode 100644 index 0000000..8983740 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs @@ -0,0 +1,35 @@ +using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; +using System.Text; + +namespace Ellie.Common; + +public static class LogSetup +{ + public static void SetupLogger(object source) + { + Log.Logger = new LoggerConfiguration().MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("System", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console(LogEventLevel.Information, + theme: GetTheme(), + outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] | #{LogSource} | {Message:lj}{NewLine}{Exception}") + .Enrich.WithProperty("LogSource", source) + .CreateLogger(); + + Console.OutputEncoding = Encoding.UTF8; + } + + private static ConsoleTheme GetTheme() + { + if (Environment.OSVersion.Platform == PlatformID.Unix) + return AnsiConsoleTheme.Code; +#if DEBUG + return AnsiConsoleTheme.Code; +#else + return ConsoleTheme.None; +#endif + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs b/src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs new file mode 100644 index 0000000..d277b7e --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs @@ -0,0 +1,7 @@ +namespace Ellie.Common; + +public static class StandardConversions +{ + public static double CelsiusToFahrenheit(double cel) + => (cel * 1.8f) + 32; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Kwum.cs b/src/EllieBot/_common/Abstractions/Kwum.cs new file mode 100644 index 0000000..267d38b --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Kwum.cs @@ -0,0 +1,100 @@ +using System.Runtime.CompilerServices; + +namespace Ellie.Common; + +// needs proper invalid input check (character array input out of range) +// needs negative number support +// ReSharper disable once InconsistentNaming +#pragma warning disable CS8981 +public readonly struct kwum : IEquatable +#pragma warning restore CS8981 +{ + private const string VALID_CHARACTERS = "23456789abcdefghijkmnpqrstuvwxyz"; + private readonly int _value; + + public kwum(int num) + => _value = num; + + public kwum(in char c) + { + if (!IsValidChar(c)) + throw new ArgumentException("Character needs to be a valid kwum character.", nameof(c)); + + _value = InternalCharToValue(c); + } + + public kwum(in ReadOnlySpan input) + { + _value = 0; + for (var index = 0; index < input.Length; index++) + { + var c = input[index]; + if (!IsValidChar(c)) + throw new ArgumentException("All characters need to be a valid kwum characters.", nameof(input)); + + _value += VALID_CHARACTERS.IndexOf(c) * (int)Math.Pow(VALID_CHARACTERS.Length, input.Length - index - 1); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int InternalCharToValue(in char c) + => VALID_CHARACTERS.IndexOf(c); + + public static bool TryParse(in ReadOnlySpan input, out kwum value) + { + value = default; + foreach (var c in input) + { + if (!IsValidChar(c)) + return false; + } + + value = new(input); + return true; + } + + public static kwum operator +(kwum left, kwum right) + => new(left._value + right._value); + + public static bool operator ==(kwum left, kwum right) + => left._value == right._value; + + public static bool operator !=(kwum left, kwum right) + => !(left == right); + + public static implicit operator long(kwum kwum) + => kwum._value; + + public static implicit operator int(kwum kwum) + => kwum._value; + + public static implicit operator kwum(int num) + => new(num); + + public static bool IsValidChar(char c) + => VALID_CHARACTERS.Contains(c); + + public override string ToString() + { + var count = VALID_CHARACTERS.Length; + var localValue = _value; + var arrSize = (int)Math.Log(localValue, count) + 1; + Span chars = new char[arrSize]; + while (localValue > 0) + { + localValue = Math.DivRem(localValue, count, out var rem); + chars[--arrSize] = VALID_CHARACTERS[rem]; + } + + return new(chars); + } + + public override bool Equals(object? obj) + => obj is kwum kw && kw == this; + + public bool Equals(kwum other) + => other == this; + + public override int GetHashCode() + => _value.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs b/src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs new file mode 100644 index 0000000..87ce07f --- /dev/null +++ b/src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs @@ -0,0 +1,80 @@ +namespace Ellie.Common; + +public class EventPubSub : IPubSub +{ + private readonly Dictionary>>> _actions = new(); + private readonly object _locker = new(); + + public Task Sub(in TypedKey key, Func action) + where TData : notnull + { + Func 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(in TypedKey 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(in TypedKey key, Func 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; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs b/src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs new file mode 100644 index 0000000..7c092cb --- /dev/null +++ b/src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs @@ -0,0 +1,10 @@ +namespace Ellie.Common; + +public interface IPubSub +{ + public Task Pub(in TypedKey key, TData data) + where TData : notnull; + + public Task Sub(in TypedKey key, Func action) + where TData : notnull; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/PubSub/ISeria.cs b/src/EllieBot/_common/Abstractions/PubSub/ISeria.cs new file mode 100644 index 0000000..57217c2 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/PubSub/ISeria.cs @@ -0,0 +1,7 @@ +namespace Ellie.Common; + +public interface ISeria +{ + byte[] Serialize(T data); + T? Deserialize(byte[]? data); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/QueueRunner.cs b/src/EllieBot/_common/Abstractions/QueueRunner.cs new file mode 100644 index 0000000..7bba829 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/QueueRunner.cs @@ -0,0 +1,61 @@ +using System.Threading.Channels; + +namespace Ellie.Common; + +public sealed class QueueRunner +{ + private readonly Channel> _channel; + private readonly int _delayMs; + + public QueueRunner(int delayMs = 0, int maxCapacity = -1) + { + ArgumentOutOfRangeException.ThrowIfNegative(delayMs); + + _delayMs = delayMs; + _channel = maxCapacity switch + { + 0 or < -1 => throw new ArgumentOutOfRangeException(nameof(maxCapacity)), + -1 => Channel.CreateUnbounded>(new UnboundedChannelOptions() + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = true, + }), + _ => Channel.CreateBounded>(new BoundedChannelOptions(maxCapacity) + { + Capacity = maxCapacity, + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = true + }) + }; + } + + public async Task RunAsync(CancellationToken cancel = default) + { + while (true) + { + var func = await _channel.Reader.ReadAsync(cancel); + + try + { + await func(); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception executing a staggered func: {ErrorMessage}", ex.Message); + } + finally + { + if (_delayMs != 0) + { + await Task.Delay(_delayMs, cancel); + } + } + } + } + + public ValueTask EnqueueAsync(Func action) + => _channel.Writer.WriteAsync(action); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/TypedKey.cs b/src/EllieBot/_common/Abstractions/TypedKey.cs new file mode 100644 index 0000000..0ca2554 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/TypedKey.cs @@ -0,0 +1,30 @@ +namespace Ellie.Common; + +public readonly struct TypedKey +{ + public string Key { get; } + + public TypedKey(in string key) + => Key = key; + + public static implicit operator TypedKey(in string input) + => new(input); + + public static implicit operator string(in TypedKey input) + => input.Key; + + public static bool operator ==(in TypedKey left, in TypedKey right) + => left.Key == right.Key; + + public static bool operator !=(in TypedKey left, in TypedKey right) + => !(left == right); + + public override bool Equals(object? obj) + => obj is TypedKey o && o == this; + + public override int GetHashCode() + => Key?.GetHashCode() ?? 0; + + public override string ToString() + => Key; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/YamlHelper.cs b/src/EllieBot/_common/Abstractions/YamlHelper.cs new file mode 100644 index 0000000..e3c39f0 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/YamlHelper.cs @@ -0,0 +1,48 @@ +#nullable disable +namespace EllieBot.Common.Yml; + +public static class YamlHelper +{ + // https://github.com/aaubry/YamlDotNet/blob/0f4cc205e8b2dd8ef6589d96de32bf608a687c6f/YamlDotNet/Core/Scanner.cs#L1687 + /// + /// This is modified code from yamldotnet's repo which handles parsing unicode code points + /// it is needed as yamldotnet doesn't support unescaped unicode characters + /// + /// Unicode code point + /// Actual character + public static string UnescapeUnicodeCodePoint(this string point) + { + var character = 0; + + // Scan the character value. + + foreach (var c in point) + { + if (!IsHex(c)) + return point; + + character = (character << 4) + AsHex(c); + } + + // Check the value and write the character. + + if (character is (>= 0xD800 and <= 0xDFFF) or > 0x10FFFF) + return point; + + return char.ConvertFromUtf32(character); + } + + public static bool IsHex(char c) + => c is (>= '0' and <= '9') or (>= 'A' and <= 'F') or (>= 'a' and <= 'f'); + + public static int AsHex(char c) + { + if (c <= '9') + return c - '0'; + + if (c <= 'F') + return c - 'A' + 10; + + return c - 'a' + 10; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs b/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs new file mode 100644 index 0000000..faeae4f --- /dev/null +++ b/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs @@ -0,0 +1,79 @@ +#nullable disable +namespace EllieBot; + +public interface IBotCredentials +{ + string Token { get; } + string EllieAiToken { get; } + ICollection OwnerIds { get; set; } + string GoogleApiKey { get; } + bool UsePrivilegedIntents { get; } + string RapidApiKey { get; } + + Creds.DbOptions Db { get; } + string OsuApiKey { get; } + int TotalShards { get; } + Creds.PatreonSettings Patreon { get; } + string CleverbotApiKey { get; } + string Gpt3ApiKey { get; } + RestartConfig RestartCommand { get; } + Creds.VotesSettings Votes { get; } + string BotListToken { get; } + string RedisOptions { get; } + string LocationIqApiKey { get; } + string TimezoneDbApiKey { get; } + string CoinmarketcapApiKey { get; } + string TrovoClientId { get; } + string CoordinatorUrl { get; set; } + string TwitchClientId { get; set; } + string TwitchClientSecret { get; set; } + GoogleApiConfig Google { get; set; } + BotCacheImplemenation BotCache { get; set; } +} + +public interface IVotesSettings +{ + string TopggServiceUrl { get; set; } + string TopggKey { get; set; } + string DiscordsServiceUrl { get; set; } + string DiscordsKey { get; set; } +} + +public interface IPatreonSettings +{ + public string ClientId { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string ClientSecret { get; set; } + public string CampaignId { get; set; } +} + +public interface IRestartConfig +{ + string Cmd { get; set; } + string Args { get; set; } +} + +public class RestartConfig : IRestartConfig +{ + public string Cmd { get; set; } + public string Args { get; set; } +} + +public enum BotCacheImplemenation +{ + Memory, + Redis +} + +public interface IDbOptions +{ + string Type { get; set; } + string ConnectionString { get; set; } +} + +public interface IGoogleApiConfig +{ + string SearchId { get; init; } + string ImageSearchId { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs b/src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs new file mode 100644 index 0000000..ecc90f0 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs @@ -0,0 +1,8 @@ +namespace EllieBot; + +public interface IBotCredsProvider +{ + public void Reload(); + public IBotCredentials GetCreds(); + public void ModifyCredsFile(Action func); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/CommandStrings.cs b/src/EllieBot/_common/Abstractions/strings/CommandStrings.cs new file mode 100644 index 0000000..64efd85 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/CommandStrings.cs @@ -0,0 +1,35 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace EllieBot.Services; + +// public sealed record class CommandStrings +// { +// [YamlMember(Alias = "desc")] +// public string Desc { get; set; } +// +// [YamlMember(Alias = "args")] +// public string[] Args { get; set; } +// } + +public sealed record class CommandStrings +{ + [YamlMember(Alias = "desc")] + public string Desc { get; set; } + + [YamlMember(Alias = "ex")] + public string[] Examples { get; set; } + + [YamlMember(Alias = "params")] + public Dictionary[] Params { get; set; } +} + +public sealed record class CommandStringParam +{ + // [YamlMember(Alias = "type", ScalarStyle = ScalarStyle.DoubleQuoted)] + // public string Type { get; set; } + + [YamlMember(Alias = "desc", ScalarStyle = ScalarStyle.DoubleQuoted)] + public string Desc{ get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/IBotStrings.cs b/src/EllieBot/_common/Abstractions/strings/IBotStrings.cs new file mode 100644 index 0000000..5c43755 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/IBotStrings.cs @@ -0,0 +1,16 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Common; + +/// +/// Defines methods to retrieve and reload bot strings +/// +public interface IBotStrings +{ + string GetText(string key, ulong? guildId = null, params object[] data); + string GetText(string key, CultureInfo locale, params object[] data); + void Reload(); + CommandStrings GetCommandStrings(string commandName, ulong? guildId = null); + CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs b/src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs new file mode 100644 index 0000000..a635b4b --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs @@ -0,0 +1,17 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Common; + +public static class BotStringsExtensions +{ + // this one is for pipe fun, see PipeExtensions.cs + public static string GetText(this IBotStrings strings, in LocStr str, in ulong guildId) + => strings.GetText(str.Key, guildId, str.Params); + + public static string GetText(this IBotStrings strings, in LocStr str, ulong? guildId = null) + => strings.GetText(str.Key, guildId, str.Params); + + public static string GetText(this IBotStrings strings, in LocStr str, CultureInfo culture) + => strings.GetText(str.Key, culture, str.Params); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs b/src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs new file mode 100644 index 0000000..ef98051 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs @@ -0,0 +1,28 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// Implemented by classes which provide localized strings in their own ways +/// +public interface IBotStringsProvider +{ + /// + /// Gets localized string + /// + /// Language name + /// String key + /// Localized string + string GetText(string localeName, string key); + + /// + /// Reloads string cache + /// + void Reload(); + + /// + /// Gets command arg examples and description + /// + /// Language name + /// Command name + CommandStrings GetCommandStrings(string localeName, string commandName); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/IStringsSource.cs b/src/EllieBot/_common/Abstractions/strings/IStringsSource.cs new file mode 100644 index 0000000..08ad986 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/IStringsSource.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// Basic interface used for classes implementing strings loading mechanism +/// +public interface IStringsSource +{ + /// + /// Gets all response strings + /// + /// Dictionary(localename, Dictionary(key, response)) + Dictionary> GetResponseStrings(); + + Dictionary> GetCommandStrings(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/LocStr.cs b/src/EllieBot/_common/Abstractions/strings/LocStr.cs new file mode 100644 index 0000000..78a8bb7 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/LocStr.cs @@ -0,0 +1,13 @@ +namespace EllieBot; + +public readonly struct LocStr +{ + public readonly string Key; + public readonly object[] Params; + + public LocStr(string key, params object[] data) + { + Key = key; + Params = data; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/AddRemove.cs b/src/EllieBot/_common/AddRemove.cs new file mode 100644 index 0000000..cccd892 --- /dev/null +++ b/src/EllieBot/_common/AddRemove.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Common; + +public enum AddRemove +{ + Add = int.MinValue, + Remove = int.MinValue + 1, + Rem = int.MinValue + 1, + Rm = int.MinValue + 1 +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/AliasesAttribute.cs b/src/EllieBot/_common/Attributes/AliasesAttribute.cs new file mode 100644 index 0000000..bef833e --- /dev/null +++ b/src/EllieBot/_common/Attributes/AliasesAttribute.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class AliasesAttribute : AliasAttribute +{ + public AliasesAttribute([CallerMemberName] string memberName = "") + : base(CommandNameLoadHelper.GetAliasesFor(memberName)) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/CmdAttribute.cs b/src/EllieBot/_common/Attributes/CmdAttribute.cs new file mode 100644 index 0000000..b778c7d --- /dev/null +++ b/src/EllieBot/_common/Attributes/CmdAttribute.cs @@ -0,0 +1,18 @@ +using System.Runtime.CompilerServices; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class CmdAttribute : CommandAttribute +{ + public string MethodName { get; } + + public CmdAttribute([CallerMemberName] string memberName = "") + : base(CommandNameLoadHelper.GetCommandNameFor(memberName)) + { + MethodName = memberName.ToLowerInvariant(); + Aliases = CommandNameLoadHelper.GetAliasesFor(memberName); + Remarks = memberName.ToLowerInvariant(); + Summary = memberName.ToLowerInvariant(); + } +} diff --git a/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs new file mode 100644 index 0000000..608e079 --- /dev/null +++ b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Common; + +/// +/// Classed marked with this attribute will not be added to the service provider +/// +[AttributeUsage(AttributeTargets.Class)] +public class DIIgnoreAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs b/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs new file mode 100644 index 0000000..7db4315 --- /dev/null +++ b/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class EllieOptionsAttribute : Attribute + where TOption: IEllieCommandOptions +{ +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs b/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs new file mode 100644 index 0000000..183fee5 --- /dev/null +++ b/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs @@ -0,0 +1,18 @@ +#nullable disable +namespace EllieBot.Common; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class NoPublicBotAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { +#if GLOBAL_ELLIE + return Task.FromResult(PreconditionResult.FromError("Not available on the public bot. To learn how to selfhost a private bot, click [here](https://docs.elliebot.net).")); +#else + return Task.FromResult(PreconditionResult.FromSuccess()); +#endif + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs b/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs new file mode 100644 index 0000000..2ee7958 --- /dev/null +++ b/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs @@ -0,0 +1,21 @@ +#nullable disable +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Common; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +[SuppressMessage("Style", "IDE0022:Use expression body for methods")] +public sealed class OnlyPublicBotAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { +#if GLOBAL_ELLIE || DEBUG + return Task.FromResult(PreconditionResult.FromSuccess()); +#else + return Task.FromResult(PreconditionResult.FromError("Only available on the public bot.")); +#endif + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs b/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs new file mode 100644 index 0000000..c1baa53 --- /dev/null +++ b/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class OwnerOnlyAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var creds = services.GetRequiredService().GetCreds(); + + return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id + ? PreconditionResult.FromSuccess() + : PreconditionResult.FromError("Not owner")); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/RatelimitAttribute.cs b/src/EllieBot/_common/Attributes/RatelimitAttribute.cs new file mode 100644 index 0000000..54402d9 --- /dev/null +++ b/src/EllieBot/_common/Attributes/RatelimitAttribute.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class RatelimitAttribute : PreconditionAttribute +{ + public int Seconds { get; } + + public RatelimitAttribute(int seconds) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(seconds); + + Seconds = seconds; + } + + public override async Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + if (Seconds == 0) + return PreconditionResult.FromSuccess(); + + var cache = services.GetRequiredService(); + var rem = await cache.GetRatelimitAsync( + new($"precondition:{context.User.Id}:{command.Name}"), + Seconds.Seconds()); + + if (rem is null) + return PreconditionResult.FromSuccess(); + + var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s."; + + return PreconditionResult.FromError(msgContent); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/UserPermAttribute.cs b/src/EllieBot/_common/Attributes/UserPermAttribute.cs new file mode 100644 index 0000000..2e0af03 --- /dev/null +++ b/src/EllieBot/_common/Attributes/UserPermAttribute.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Discord; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class UserPermAttribute : RequireUserPermissionAttribute +{ + public UserPermAttribute(GuildPerm permission) + : base(permission) + { + } + + public UserPermAttribute(ChannelPerm permission) + : base(permission) + { + } + + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var permService = services.GetRequiredService(); + if (permService.TryGetOverrides(context.Guild?.Id ?? 0, command.Name.ToUpperInvariant(), out _)) + return Task.FromResult(PreconditionResult.FromSuccess()); + + return base.CheckPermissionsAsync(context, command, services); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/BotCommandTypeReader.cs b/src/EllieBot/_common/BotCommandTypeReader.cs new file mode 100644 index 0000000..fc839bd --- /dev/null +++ b/src/EllieBot/_common/BotCommandTypeReader.cs @@ -0,0 +1,30 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class CommandTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + private readonly ICommandHandler _handler; + + public CommandTypeReader(ICommandHandler handler, CommandService cmds) + { + _handler = handler; + _cmds = cmds; + } + + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + { + input = input.ToUpperInvariant(); + var prefix = _handler.GetPrefix(ctx.Guild); + if (!input.StartsWith(prefix.ToUpperInvariant(), StringComparison.InvariantCulture)) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such command found.")); + + input = input[prefix.Length..]; + + var cmd = _cmds.Commands.FirstOrDefault(c => c.Aliases.Select(a => a.ToUpperInvariant()).Contains(input)); + if (cmd is null) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such command found.")); + + return new(TypeReaderResult.FromSuccess(cmd)); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/CleanupModuleBase.cs b/src/EllieBot/_common/CleanupModuleBase.cs new file mode 100644 index 0000000..1e97a66 --- /dev/null +++ b/src/EllieBot/_common/CleanupModuleBase.cs @@ -0,0 +1,25 @@ +#nullable disable +namespace EllieBot.Common; + +public abstract class CleanupModuleBase : EllieModule +{ + protected async Task ConfirmActionInternalAsync(string name, Func action) + { + try + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.sql_confirm_exec)) + .WithDescription(name); + + if (!await PromptUserConfirmAsync(embed)) + return; + + await action(); + await ctx.OkAsync(); + } + catch (Exception ex) + { + await Response().Error(ex.ToString()).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/CleverBotResponseStr.cs b/src/EllieBot/_common/CleverBotResponseStr.cs new file mode 100644 index 0000000..2f06a67 --- /dev/null +++ b/src/EllieBot/_common/CleverBotResponseStr.cs @@ -0,0 +1,10 @@ +#nullable disable +using System.Runtime.InteropServices; + +namespace EllieBot.Modules.Permissions; + +[StructLayout(LayoutKind.Sequential, Size = 1)] +public readonly struct CleverBotResponseStr +{ + public const string CLEVERBOT_RESPONSE = "CLEVERBOT:RESPONSE"; +} \ No newline at end of file diff --git a/src/EllieBot/_common/CmdStrings.cs b/src/EllieBot/_common/CmdStrings.cs new file mode 100644 index 0000000..c28ed1a --- /dev/null +++ b/src/EllieBot/_common/CmdStrings.cs @@ -0,0 +1,17 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Common; + +public class CmdStrings +{ + public string[] Usages { get; } + public string Description { get; } + + [JsonConstructor] + public CmdStrings([JsonProperty("args")] string[] usages, [JsonProperty("desc")] string description) + { + Usages = usages; + Description = description; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/CommandData.cs b/src/EllieBot/_common/CommandData.cs new file mode 100644 index 0000000..f0514da --- /dev/null +++ b/src/EllieBot/_common/CommandData.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Common; + +public class CommandData +{ + public string Cmd { get; set; } + public string Desc { get; set; } + public string[] Usage { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/CommandNameLoadHelper.cs b/src/EllieBot/_common/CommandNameLoadHelper.cs new file mode 100644 index 0000000..0a811e2 --- /dev/null +++ b/src/EllieBot/_common/CommandNameLoadHelper.cs @@ -0,0 +1,40 @@ +using EllieBot.Common.Yml; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Attributes; + +public static class CommandNameLoadHelper +{ + private static readonly IDeserializer _deserializer = new Deserializer(); + + private static readonly Lazy> _lazyCommandAliases + = new(() => LoadAliases()); + + public static Dictionary LoadAliases(string aliasesFilePath = "data/aliases.yml") + { + var text = File.ReadAllText(aliasesFilePath); + return _deserializer.Deserialize>(text); + } + + public static Dictionary LoadCommandStrings( + string commandsFilePath = "data/strings/commands.yml") + { + var text = File.ReadAllText(commandsFilePath); + + return Yaml.Deserializer.Deserialize>(text); + } + + public static string[] GetAliasesFor(string methodName) + => _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1 + ? aliases.ToArray() + : Array.Empty(); + + public static string GetCommandNameFor(string methodName) + { + methodName = methodName.ToLowerInvariant(); + var toReturn = _lazyCommandAliases.Value.TryGetValue(methodName, out var aliases) && aliases.Length > 0 + ? aliases[0] + : methodName; + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Configs/BotConfig.cs b/src/EllieBot/_common/Configs/BotConfig.cs new file mode 100644 index 0000000..df6bc1a --- /dev/null +++ b/src/EllieBot/_common/Configs/BotConfig.cs @@ -0,0 +1,196 @@ +#nullable disable +using Cloneable; +using EllieBot.Common.Yml; +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Configs; + +[Cloneable] +public sealed partial class BotConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 8; + + [Comment(""" + Most commands, when executed, have a small colored line + next to the response. The color depends whether the command + is completed, errored or in progress (pending) + Color settings below are for the color of those lines. + To get color's hex, you can go here https://htmlcolorcodes.com/ + and copy the hex code fo your selected color (marked as #) + """)] + public ColorConfig Color { get; set; } + + [Comment("Default bot language. It has to be in the list of supported languages (.langli)")] + public CultureInfo DefaultLocale { get; set; } + + [Comment(""" + Style in which executed commands will show up in the console. + Allowed values: Simple, Normal, None + """)] + public ConsoleOutputType ConsoleOutputType { get; set; } + + [Comment("""Whether the bot will check for new releases every hour""")] + public bool CheckForUpdates { get; set; } = true; + + [Comment("""Do you want any messages sent by users in Bot's DM to be forwarded to the owner(s)?""")] + public bool ForwardMessages { get; set; } + + [Comment(""" + Do you want the message to be forwarded only to the first owner specified in the list of owners (in creds.yml), + or all owners? (this might cause the bot to lag if there's a lot of owners specified) + """)] + public bool ForwardToAllOwners { get; set; } + + [Comment(""" + Any messages sent by users in Bot's DM to be forwarded to the specified channel. + This option will only work when ForwardToAllOwners is set to false + """)] + public ulong? ForwardToChannel { get; set; } + + [Comment(""" + Should the bot ignore messages from other bots? + Settings this to false might get your bot banned if it gets into a spam loop with another bot. + This will only affect command executions, other features will still block bots from access. + Default true + """)] + public bool IgnoreOtherBots { get; set; } + + [Comment(""" + When a user DMs the bot with a message which is not a command + they will receive this message. Leave empty for no response. The string which will be sent whenever someone DMs the bot. + Supports embeds. How it looks: https://puu.sh/B0BLV.png + """)] + [YamlMember(ScalarStyle = ScalarStyle.Literal)] + public string DmHelpText { get; set; } + + [Comment(""" + Only users who send a DM to the bot containing one of the specified words will get a DmHelpText response. + Case insensitive. + Leave empty to reply with DmHelpText to every DM. + """)] + public List DmHelpTextKeywords { get; set; } + + [Comment("""This is the response for the .h command""")] + [YamlMember(ScalarStyle = ScalarStyle.Literal)] + public string HelpText { get; set; } + + [Comment("""List of modules and commands completely blocked on the bot""")] + public BlockedConfig Blocked { get; set; } + + [Comment("""Which string will be used to recognize the commands""")] + public string Prefix { get; set; } + + [Comment(""" + Whether the bot will rotate through all specified statuses. + This setting can be changed via .ropl command. + See RotatingStatuses submodule in Administration. + """)] + public bool RotateStatuses { get; set; } + + public BotConfig() + { + var color = new ColorConfig(); + Color = color; + DefaultLocale = new("en-US"); + ConsoleOutputType = ConsoleOutputType.Normal; + ForwardMessages = false; + ForwardToAllOwners = false; + DmHelpText = """{"description": "Type `%prefix%h` for help."}"""; + HelpText = """ + { + "title": "To invite me to your server, use this link", + "description": "https://discordapp.com/oauth2/authorize?client_id={0}&scope=bot&permissions=66186303", + "color": 53380, + "thumbnail": "https://cdn.elliebot.net/Ellie.png", + "fields": [ + { + "name": "Useful help commands", + "value": "`%bot.prefix%modules` Lists all bot modules. + `%prefix%h CommandName` Shows some help about a specific command. + `%prefix%commands ModuleName` Lists all commands in a module.", + "inline": false + }, + { + "name": "List of all Commands", + "value": "https://commands.elliebot.net", + "inline": false + }, + { + "name": "Ellie Support Server", + "value": "https://discord.gg/etQdZxSyEH ", + "inline": true + } + ] + } + """; + var blocked = new BlockedConfig(); + Blocked = blocked; + Prefix = "."; + RotateStatuses = false; + DmHelpTextKeywords = + [ + "help", + "commands", + "cmds", + "module", + "can you do" + ]; + } + +// [Comment(@"Whether the prefix will be a suffix, or prefix. +// For example, if your prefix is ! you will run a command called 'cash' by typing either +// '!cash @Someone' if your prefixIsSuffix: false or +// 'cash @Someone!' if your prefixIsSuffix: true")] +// public bool PrefixIsSuffix { get; set; } + + // public string Prefixed(string text) => PrefixIsSuffix + // ? text + Prefix + // : Prefix + text; + + public string Prefixed(string text) + => Prefix + text; +} + +[Cloneable] +public sealed partial class BlockedConfig +{ + public HashSet Commands { get; set; } + public HashSet Modules { get; set; } + + public BlockedConfig() + { + Modules = []; + Commands = []; + } +} + +[Cloneable] +public partial class ColorConfig +{ + [Comment("""Color used for embed responses when command successfully executes""")] + public Rgba32 Ok { get; set; } + + [Comment("""Color used for embed responses when command has an error""")] + public Rgba32 Error { get; set; } + + [Comment("""Color used for embed responses while command is doing work or is in progress""")] + public Rgba32 Pending { get; set; } + + public ColorConfig() + { + Ok = Rgba32.ParseHex("00e584"); + Error = Rgba32.ParseHex("ee281f"); + Pending = Rgba32.ParseHex("faa61a"); + } +} + +public enum ConsoleOutputType +{ + Normal = 0, + Simple = 1, + None = 2 +} diff --git a/src/EllieBot/_common/Configs/IConfigSeria.cs b/src/EllieBot/_common/Configs/IConfigSeria.cs new file mode 100644 index 0000000..a5d3a10 --- /dev/null +++ b/src/EllieBot/_common/Configs/IConfigSeria.cs @@ -0,0 +1,18 @@ +namespace EllieBot.Common.Configs; + +/// +/// Base interface for available config serializers +/// +public interface IConfigSeria +{ + /// + /// Serialize the object to string + /// + public string Serialize(T obj) + where T : notnull; + + /// + /// Deserialize string data into an object of the specified type + /// + public T Deserialize(string data); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Creds.cs b/src/EllieBot/_common/Creds.cs new file mode 100644 index 0000000..65de7e6 --- /dev/null +++ b/src/EllieBot/_common/Creds.cs @@ -0,0 +1,285 @@ +#nullable disable +using EllieBot.Common.Yml; + +namespace EllieBot.Common; + +public sealed class Creds : IBotCredentials +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } + + [Comment("""Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/""")] + public string Token { get; set; } + + [Comment(""" + List of Ids of the users who have bot owner permissions + **DO NOT ADD PEOPLE YOU DON'T TRUST** + """)] + public ICollection OwnerIds { get; set; } + + [Comment("Keep this on 'true' unless you're sure your bot shouldn't use privileged intents or you're waiting to be accepted")] + public bool UsePrivilegedIntents { get; set; } + + [Comment(""" + The number of shards that the bot will be running on. + Leave at 1 if you don't know what you're doing. + + note: If you are planning to have more than one shard, then you must change botCache to 'redis'. + Also, in that case you should be using EllieBot.Coordinator to start the bot, and it will correctly override this value. + """)] + public int TotalShards { get; set; } + + [Comment(""" + Pledge 5$ or more on https://patreon.com/elliebot and connect your discord account to Patreon. + Go to https://dashy.elliebot.net/me and login with your discord account + Go to the Keys page and click "Generate New Key" and copy it here + You and anyone else with the permission to run `.prompt` command will be able to use natural language to run bot's commands. + For example '@Bot how's the weather in Paris' will return the current weather in Paris as if you were to run `.weather Paris` command. + ⚠ This does not currently work and is a work in progress. + """)] + public string EllieAiToken { get; set; } + + [Comment( + """ + Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it. + Then, go to APIs and Services -> Credentials and click Create credentials -> API key. + Used only for Youtube Data Api (at the moment). + """)] + public string GoogleApiKey { get; set; } + + [Comment( + """ + Create a new custom search here https://programmablesearchengine.google.com/cse/create/new + Enable SafeSearch + Remove all Sites to Search + Enable Search the entire web + Copy the 'Search Engine ID' to the SearchId field + + Do all steps again but enable image search for the ImageSearchId + """)] + public GoogleApiConfig Google { get; set; } + + [Comment("""Settings for voting system for discordbots. Meant for use on global Ellie.""")] + public VotesSettings Votes { get; set; } + + [Comment(""" + Patreon auto reward system settings. + go to https://www.patreon.com/portal -> my clients -> create client + """)] + public PatreonSettings Patreon { get; set; } + + [Comment("""Api key for sending stats to DiscordBotList.""")] + public string BotListToken { get; set; } + + [Comment("""Official cleverbot api key.""")] + public string CleverbotApiKey { get; set; } + + [Comment(@"OpenAi api key.")] + public string Gpt3ApiKey { get; set; } + + [Comment(""" + Which cache implementation should bot use. + 'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset. + 'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml + """)] + public BotCacheImplemenation BotCache { get; set; } + + [Comment(""" + Redis connection string. Don't change if you don't know what you're doing. + Only used if botCache is set to 'redis' + """)] + public string RedisOptions { get; set; } + + [Comment("""Database options. Don't change if you don't know what you're doing. Leave null for default values""")] + public DbOptions Db { get; set; } + + [Comment(""" + Address and port of the coordinator endpoint. Leave empty for default. + Change only if you've changed the coordinator address or port. + """)] + public string CoordinatorUrl { get; set; } + + [Comment( + """Api key obtained on https://rapidapi.com (go to MyApps -> Add New App -> Enter Name -> Application key)""")] + public string RapidApiKey { get; set; } + + [Comment(""" + https://locationiq.com api key (register and you will receive the token in the email). + Used only for .time command. + """)] + public string LocationIqApiKey { get; set; } + + [Comment(""" + https://timezonedb.com api key (register and you will receive the token in the email). + Used only for .time command + """)] + public string TimezoneDbApiKey { get; set; } + + [Comment(""" + https://pro.coinmarketcap.com/account/ api key. There is a free plan for personal use. + Used for cryptocurrency related commands. + """)] + public string CoinmarketcapApiKey { get; set; } + +// [Comment(@"https://polygon.io/dashboard/api-keys api key. Free plan allows for 5 queries per minute. +// Used for stocks related commands.")] +// public string PolygonIoApiKey { get; set; } + + [Comment("""Api key used for Osu related commands. Obtain this key at https://osu.ppy.sh/p/api""")] + public string OsuApiKey { get; set; } + + [Comment(""" + Optional Trovo client id. + You should use this if Trovo stream notifications stopped working or you're getting ratelimit errors. + """)] + public string TrovoClientId { get; set; } + + [Comment("""Obtain by creating an application at https://dev.twitch.tv/console/apps""")] + public string TwitchClientId { get; set; } + + [Comment("""Obtain by creating an application at https://dev.twitch.tv/console/apps""")] + public string TwitchClientSecret { get; set; } + + [Comment(""" + Command and args which will be used to restart the bot. + Only used if bot is executed directly (NOT through the coordinator) + placeholders: + {0} -> shard id + {1} -> total shards + Linux default + cmd: dotnet + args: "EllieBot.dll -- {0}" + Windows default + cmd: EllieBot.exe + args: "{0}" + """)] + public RestartConfig RestartCommand { get; set; } + + public Creds() + { + Version = 9; + Token = string.Empty; + UsePrivilegedIntents = true; + OwnerIds = new List(); + TotalShards = 1; + GoogleApiKey = string.Empty; + Votes = new VotesSettings(string.Empty, string.Empty, string.Empty, string.Empty); + Patreon = new PatreonSettings(string.Empty, string.Empty, string.Empty, string.Empty); + BotListToken = string.Empty; + CleverbotApiKey = string.Empty; + Gpt3ApiKey = string.Empty; + BotCache = BotCacheImplemenation.Memory; + RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password="; + Db = new DbOptions() + { + Type = "sqlite", + ConnectionString = "Data Source=data/EllieBot.db" + }; + + CoordinatorUrl = "http://localhost:3442"; + + RestartCommand = new RestartConfig(); + Google = new GoogleApiConfig(); + } + + public class DbOptions + : IDbOptions + { + [Comment(""" + Database type. "sqlite", "mysql" and "postgresql" are supported. + Default is "sqlite" + """)] + public string Type { get; set; } + + [Comment(""" + Database connection string. + You MUST change this if you're not using "sqlite" type. + Default is "Data Source=data/EllieBot.db" + Example for mysql: "Server=localhost;Port=3306;Uid=root;Pwd=my_super_secret_mysql_password;Database=ellie" + Example for postgresql: "Server=localhost;Port=5432;User Id=postgres;Password=my_super_secret_postgres_password;Database=ellie;" + """)] + public string ConnectionString { get; set; } + } + + public sealed record PatreonSettings : IPatreonSettings + { + public string ClientId { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string ClientSecret { get; set; } + + [Comment( + """Campaign ID of your patreon page. Go to your patreon page (make sure you're logged in) and type "prompt('Campaign ID', window.patreon.bootstrap.creator.data.id);" in the console. (ctrl + shift + i)""")] + public string CampaignId { get; set; } + + public PatreonSettings( + string accessToken, + string refreshToken, + string clientSecret, + string campaignId) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + ClientSecret = clientSecret; + CampaignId = campaignId; + } + + public PatreonSettings() + { + } + } + + public sealed record VotesSettings : IVotesSettings + { + [Comment(""" + top.gg votes service url + This is the url of your instance of the EllieBot.Votes api + Example: https://votes.my.cool.bot.com + """)] + public string TopggServiceUrl { get; set; } + + [Comment(""" + Authorization header value sent to the TopGG service url with each request + This should be equivalent to the TopggKey in your EllieBot.Votes api appsettings.json file + """)] + public string TopggKey { get; set; } + + [Comment(""" + discords.com votes service url + This is the url of your instance of the EllieBot.Votes api + Example: https://votes.my.cool.bot.com + """)] + public string DiscordsServiceUrl { get; set; } + + [Comment(""" + Authorization header value sent to the Discords service url with each request + This should be equivalent to the DiscordsKey in your EllieBot.Votes api appsettings.json file + """)] + public string DiscordsKey { get; set; } + + public VotesSettings() + { + } + + public VotesSettings( + string topggServiceUrl, + string topggKey, + string discordsServiceUrl, + string discordsKey) + { + TopggServiceUrl = topggServiceUrl; + TopggKey = topggKey; + DiscordsServiceUrl = discordsServiceUrl; + DiscordsKey = discordsKey; + } + } +} + +public class GoogleApiConfig : IGoogleApiConfig +{ + public string SearchId { get; init; } + public string ImageSearchId { get; init; } +} + + + diff --git a/src/EllieBot/_common/DbService.cs b/src/EllieBot/_common/DbService.cs new file mode 100644 index 0000000..089c1c2 --- /dev/null +++ b/src/EllieBot/_common/DbService.cs @@ -0,0 +1,16 @@ +#nullable disable + +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Services; + +public abstract class DbService +{ + /// + /// Call this to apply all migrations + /// + public abstract Task SetupAsync(); + + public abstract DbContext CreateRawDbContext(string dbType, string connString); + public abstract EllieContext GetDbContext(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/DoAsUserMessage.cs b/src/EllieBot/_common/DoAsUserMessage.cs new file mode 100644 index 0000000..68a9188 --- /dev/null +++ b/src/EllieBot/_common/DoAsUserMessage.cs @@ -0,0 +1,215 @@ +using MessageType = Discord.MessageType; + +namespace EllieBot.Modules.Administration; + +public sealed class DoAsUserMessage : IUserMessage +{ + private readonly string _message; + private IUserMessage _msg; + private readonly IUser _user; + + public DoAsUserMessage(SocketUserMessage msg, IUser user, string message) + { + _msg = msg; + _user = user; + _message = message; + } + + public ulong Id + => _msg.Id; + + public DateTimeOffset CreatedAt + => _msg.CreatedAt; + + public Task DeleteAsync(RequestOptions? options = null) + { + return _msg.DeleteAsync(options); + } + + public Task AddReactionAsync(IEmote emote, RequestOptions? options = null) + { + return _msg.AddReactionAsync(emote, options); + } + + public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions? options = null) + { + return _msg.RemoveReactionAsync(emote, user, options); + } + + public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions? options = null) + { + return _msg.RemoveReactionAsync(emote, userId, options); + } + + public Task RemoveAllReactionsAsync(RequestOptions? options = null) + { + return _msg.RemoveAllReactionsAsync(options); + } + + public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions? options = null) + { + return _msg.RemoveAllReactionsForEmoteAsync(emote, options); + } + + public IAsyncEnumerable> GetReactionUsersAsync( + IEmote emoji, + int limit, + RequestOptions? options = null, + ReactionType type = ReactionType.Normal) + => _msg.GetReactionUsersAsync(emoji, limit, options, type); + + public IAsyncEnumerable> GetReactionUsersAsync( + IEmote emoji, + int limit, + RequestOptions? options = null) + { + return _msg.GetReactionUsersAsync(emoji, limit, options); + } + + public MessageType Type + => _msg.Type; + + public MessageSource Source + => _msg.Source; + + public bool IsTTS + => _msg.IsTTS; + + public bool IsPinned + => _msg.IsPinned; + + public bool IsSuppressed + => _msg.IsSuppressed; + + public bool MentionedEveryone + => _msg.MentionedEveryone; + + public string Content + => _message; + + public string CleanContent + => _msg.CleanContent; + + public DateTimeOffset Timestamp + => _msg.Timestamp; + + public DateTimeOffset? EditedTimestamp + => _msg.EditedTimestamp; + + public IMessageChannel Channel + => _msg.Channel; + + public IUser Author + => _user; + + public IThreadChannel Thread + => _msg.Thread; + + public IReadOnlyCollection Attachments + => _msg.Attachments; + + public IReadOnlyCollection Embeds + => _msg.Embeds; + + public IReadOnlyCollection Tags + => _msg.Tags; + + public IReadOnlyCollection MentionedChannelIds + => _msg.MentionedChannelIds; + + public IReadOnlyCollection MentionedRoleIds + => _msg.MentionedRoleIds; + + public IReadOnlyCollection MentionedUserIds + => _msg.MentionedUserIds; + + public MessageActivity Activity + => _msg.Activity; + + public MessageApplication Application + => _msg.Application; + + public MessageReference Reference + => _msg.Reference; + + public IReadOnlyDictionary Reactions + => _msg.Reactions; + + public IReadOnlyCollection Components + => _msg.Components; + + public IReadOnlyCollection Stickers + => _msg.Stickers; + + public MessageFlags? Flags + => _msg.Flags; + + [Obsolete("Obsolete in favor of InteractionMetadata")] + public IMessageInteraction Interaction + => _msg.Interaction; + + public MessageRoleSubscriptionData RoleSubscriptionData + => _msg.RoleSubscriptionData; + + public PurchaseNotification PurchaseNotification + => _msg.PurchaseNotification; + + public MessageCallData? CallData + => _msg.CallData; + + public Task ModifyAsync(Action func, RequestOptions? options = null) + { + return _msg.ModifyAsync(func, options); + } + + public Task PinAsync(RequestOptions? options = null) + { + return _msg.PinAsync(options); + } + + public Task UnpinAsync(RequestOptions? options = null) + { + return _msg.UnpinAsync(options); + } + + public Task CrosspostAsync(RequestOptions? options = null) + { + return _msg.CrosspostAsync(options); + } + + public string Resolve( + TagHandling userHandling = TagHandling.Name, + TagHandling channelHandling = TagHandling.Name, + TagHandling roleHandling = TagHandling.Name, + TagHandling everyoneHandling = TagHandling.Ignore, + TagHandling emojiHandling = TagHandling.Name) + { + return _msg.Resolve(userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + } + + public Task EndPollAsync(RequestOptions options) + => _msg.EndPollAsync(options); + + public IAsyncEnumerable> GetPollAnswerVotersAsync( + uint answerId, + int? limit = null, + ulong? afterId = null, + RequestOptions? options = null) + => _msg.GetPollAnswerVotersAsync( + answerId, + limit, + afterId, + options); + + public MessageResolvedData ResolvedData + => _msg.ResolvedData; + + public IUserMessage ReferencedMessage + => _msg.ReferencedMessage; + + public IMessageInteractionMetadata InteractionMetadata + => _msg.InteractionMetadata; + + public Poll? Poll + => _msg.Poll; +} \ No newline at end of file diff --git a/src/EllieBot/_common/DownloadTracker.cs b/src/EllieBot/_common/DownloadTracker.cs new file mode 100644 index 0000000..07a5670 --- /dev/null +++ b/src/EllieBot/_common/DownloadTracker.cs @@ -0,0 +1,38 @@ +#nullable disable +namespace EllieBot.Common; + +public class DownloadTracker : IEService +{ + private ConcurrentDictionary LastDownloads { get; } = new(); + private readonly SemaphoreSlim _downloadUsersSemaphore = new(1, 1); + + /// + /// Ensures all users on the specified guild were downloaded within the last hour. + /// + /// Guild to check and potentially download users from + /// Task representing download state + public async Task EnsureUsersDownloadedAsync(IGuild guild) + { +#if GLOBAL_NADEKO + return; +#endif + await _downloadUsersSemaphore.WaitAsync(); + try + { + var now = DateTime.UtcNow; + + // download once per hour at most + var added = LastDownloads.AddOrUpdate(guild.Id, + now, + (_, old) => now - old > TimeSpan.FromHours(1) ? now : old); + + // means that this entry was just added - download the users + if (added == now) + await guild.DownloadUsersAsync(); + } + finally + { + _downloadUsersSemaphore.Release(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/DryIocExtensions.cs b/src/EllieBot/_common/DryIocExtensions.cs new file mode 100644 index 0000000..c7d8c2a --- /dev/null +++ b/src/EllieBot/_common/DryIocExtensions.cs @@ -0,0 +1,43 @@ +using DryIoc; + +namespace EllieBot.Extensions; + +public static class DryIocExtensions +{ + public static IContainer AddSingleton(this IContainer container) + where TImpl : TSvc + { + container.Register(Reuse.Singleton); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, TImpl obj) + where TImpl : TSvc + { + container.RegisterInstance(obj); + + return container; + } + + public static IContainer AddSingleton(this IContainer container) + { + container.Register(Reuse.Singleton); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, TImpl obj) + { + container.RegisterInstance(obj); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, Func factory) + { + container.RegisterDelegate(factory); + + return container; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/EllieModule.cs b/src/EllieBot/_common/EllieModule.cs new file mode 100644 index 0000000..d8867c1 --- /dev/null +++ b/src/EllieBot/_common/EllieModule.cs @@ -0,0 +1,108 @@ +#nullable disable +using System.Globalization; + +// ReSharper disable InconsistentNaming + +namespace EllieBot.Common; + +[UsedImplicitly(ImplicitUseTargetFlags.Default + | ImplicitUseTargetFlags.WithInheritors + | ImplicitUseTargetFlags.WithMembers)] +public abstract class EllieModule : ModuleBase +{ + protected CultureInfo Culture { get; set; } + + // Injected by Discord.net + public IBotStrings Strings { get; set; } + public ICommandHandler _cmdHandler { get; set; } + public ILocalization _localization { get; set; } + public IEllieInteractionService _inter { get; set; } + public IReplacementService repSvc { get; set; } + public IMessageSenderService _sender { get; set; } + public BotConfigService _bcs { get; set; } + + protected string prefix + => _cmdHandler.GetPrefix(ctx.Guild); + + protected ICommandContext ctx + => Context; + + public ResponseBuilder Response() + => new ResponseBuilder(Strings, _bcs, (DiscordSocketClient)ctx.Client) + .Context(ctx); + + protected override void BeforeExecute(CommandInfo command) + => Culture = _localization.GetCultureInfo(ctx.Guild?.Id); + + protected string GetText(in LocStr data) + => Strings.GetText(data, Culture); + + // localized normal + public async Task PromptUserConfirmAsync(EmbedBuilder embed) + { + embed.WithPendingColor() + .WithFooter("yes/no"); + + var msg = await Response().Embed(embed).SendAsync(); + try + { + var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id); + input = input?.ToUpperInvariant(); + + if (input != "YES" && input != "Y") + return false; + + return true; + } + finally + { + _ = Task.Run(() => msg.DeleteAsync()); + } + } + + // TypeConverter typeConverter = TypeDescriptor.GetConverter(propType); ? + public async Task GetUserInputAsync(ulong userId, ulong channelId, Func validate = null) + { + var userInputTask = new TaskCompletionSource(); + var dsc = (DiscordSocketClient)ctx.Client; + try + { + dsc.MessageReceived += MessageReceived; + + if (await Task.WhenAny(userInputTask.Task, Task.Delay(10000)) != userInputTask.Task) + return null; + + return await userInputTask.Task; + } + finally + { + dsc.MessageReceived -= MessageReceived; + } + + Task MessageReceived(SocketMessage arg) + { + _ = Task.Run(() => + { + if (arg is not SocketUserMessage userMsg + || userMsg.Channel is not ITextChannel + || userMsg.Author.Id != userId + || userMsg.Channel.Id != channelId) + return Task.CompletedTask; + + if (validate is not null && !validate(arg.Content)) + return Task.CompletedTask; + + if (userInputTask.TrySetResult(arg.Content)) + userMsg.DeleteAfter(1); + + return Task.CompletedTask; + }); + return Task.CompletedTask; + } + } +} + +public abstract class EllieModule : EllieModule +{ + public TService _service { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/EllieTypeReader.cs b/src/EllieBot/_common/EllieTypeReader.cs new file mode 100644 index 0000000..f14aa1e --- /dev/null +++ b/src/EllieBot/_common/EllieTypeReader.cs @@ -0,0 +1,15 @@ +#nullable disable + +namespace EllieBot.Common.TypeReaders; + +[MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)] +public abstract class EllieTypeReader : TypeReader +{ + public abstract ValueTask> ReadAsync(ICommandContext ctx, string input); + + public override async Task ReadAsync( + ICommandContext ctx, + string input, + IServiceProvider services) + => await ReadAsync(ctx, input); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Helpers.cs b/src/EllieBot/_common/Helpers.cs new file mode 100644 index 0000000..a7d458f --- /dev/null +++ b/src/EllieBot/_common/Helpers.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Common; + +public static class Helpers +{ + public static void ReadErrorAndExit(int exitCode) + { + if (!Console.IsInputRedirected) + Console.ReadKey(); + + Environment.Exit(exitCode); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/IBot.cs b/src/EllieBot/_common/IBot.cs new file mode 100644 index 0000000..c6c5a06 --- /dev/null +++ b/src/EllieBot/_common/IBot.cs @@ -0,0 +1,12 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot; + +public interface IBot +{ + IReadOnlyList GetCurrentGuildIds(); + event Func JoinedGuild; + IReadOnlyCollection AllGuildConfigs { get; } + bool IsReady { get; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/ICloneable.cs b/src/EllieBot/_common/ICloneable.cs new file mode 100644 index 0000000..c8d3fa8 --- /dev/null +++ b/src/EllieBot/_common/ICloneable.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Common; + +public interface ICloneable + where T : new() +{ + public T Clone(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ICurrencyProvider.cs b/src/EllieBot/_common/ICurrencyProvider.cs new file mode 100644 index 0000000..0cca0ae --- /dev/null +++ b/src/EllieBot/_common/ICurrencyProvider.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using System.Numerics; + +namespace EllieBot.Common; + +public interface ICurrencyProvider +{ + string GetCurrencySign(); +} + +public static class CurrencyHelper +{ + public static string N(T cur, IFormatProvider format) + where T : INumber + => cur.ToString("C0", format); + + public static string N(T cur, CultureInfo culture, string currencySign) + where T : INumber + => N(cur, GetCurrencyFormat(culture, currencySign)); + + private static IFormatProvider GetCurrencyFormat(CultureInfo culture, string currencySign) + { + var flowersCurrencyCulture = (CultureInfo)culture.Clone(); + flowersCurrencyCulture.NumberFormat.CurrencySymbol = currencySign; + flowersCurrencyCulture.NumberFormat.CurrencyNegativePattern = 5; + + return flowersCurrencyCulture; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/IDiscordPermOverrideService.cs b/src/EllieBot/_common/IDiscordPermOverrideService.cs new file mode 100644 index 0000000..b8471c3 --- /dev/null +++ b/src/EllieBot/_common/IDiscordPermOverrideService.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace Ellie.Common; + +public interface IDiscordPermOverrideService +{ + bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm); +} \ No newline at end of file diff --git a/src/EllieBot/_common/IEllieCommandOptions.cs b/src/EllieBot/_common/IEllieCommandOptions.cs new file mode 100644 index 0000000..bb758b5 --- /dev/null +++ b/src/EllieBot/_common/IEllieCommandOptions.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Common; + +public interface IEllieCommandOptions +{ + void NormalizeOptions(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ILogCommandService.cs b/src/EllieBot/_common/ILogCommandService.cs new file mode 100644 index 0000000..7e5bcd5 --- /dev/null +++ b/src/EllieBot/_common/ILogCommandService.cs @@ -0,0 +1,34 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Common; + +public interface ILogCommandService +{ + void AddDeleteIgnore(ulong xId); + Task LogServer(ulong guildId, ulong channelId, bool actionValue); + bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType); + LogSetting? GetGuildLogSettings(ulong guildId); + bool Log(ulong guildId, ulong? channelId, LogType type); +} + +public enum LogType +{ + Other, + MessageUpdated, + MessageDeleted, + UserJoined, + UserLeft, + UserBanned, + UserUnbanned, + UserUpdated, + ChannelCreated, + ChannelDestroyed, + ChannelUpdated, + UserPresence, + VoicePresence, + UserMuted, + UserWarned, + + ThreadDeleted, + ThreadCreated +} \ No newline at end of file diff --git a/src/EllieBot/_common/IPermissionChecker.cs b/src/EllieBot/_common/IPermissionChecker.cs new file mode 100644 index 0000000..5b4bc48 --- /dev/null +++ b/src/EllieBot/_common/IPermissionChecker.cs @@ -0,0 +1,37 @@ +using OneOf; + +namespace EllieBot.Common; + +public interface IPermissionChecker +{ + Task CheckPermsAsync(IGuild guild, + IMessageChannel channel, + IUser author, + string module, + string? cmd); +} + +[GenerateOneOf] +public partial class PermCheckResult + : OneOfBase +{ + public bool IsAllowed + => IsT0; + + public bool IsCooldown + => IsT1; + + public bool IsGlobalBlock + => IsT2; + + public bool IsDisallowed + => IsT3; +} + +public readonly record struct PermAllowed; + +public readonly record struct PermCooldown; + +public readonly record struct PermGlobalBlock; + +public readonly record struct PermDisallowed(int PermIndex, string PermText, bool IsVerbose); \ No newline at end of file diff --git a/src/EllieBot/_common/IPlaceholderProvider.cs b/src/EllieBot/_common/IPlaceholderProvider.cs new file mode 100644 index 0000000..20fcc12 --- /dev/null +++ b/src/EllieBot/_common/IPlaceholderProvider.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Common; + +public interface IPlaceholderProvider +{ + public IEnumerable<(string Name, Func Func)> GetPlaceholders(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ImageUrls.cs b/src/EllieBot/_common/ImageUrls.cs new file mode 100644 index 0000000..fa253a7 --- /dev/null +++ b/src/EllieBot/_common/ImageUrls.cs @@ -0,0 +1,36 @@ +#nullable disable +using EllieBot.Common.Yml; +using Cloneable; + +namespace EllieBot.Common; + +[Cloneable] +public partial class ImageUrls : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 5; + + public CoinData Coins { get; set; } + public Uri[] Currency { get; set; } + public Uri[] Dice { get; set; } + public XpData Xp { get; set; } + + public SlotData Slots { get; set; } + + public class SlotData + { + public Uri[] Emojis { get; set; } + public Uri Bg { get; set; } + } + + public class CoinData + { + public Uri[] Heads { get; set; } + public Uri[] Tails { get; set; } + } + + public class XpData + { + public Uri Bg { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/EllieInteraction.cs b/src/EllieBot/_common/Interaction/EllieInteraction.cs new file mode 100644 index 0000000..0206599 --- /dev/null +++ b/src/EllieBot/_common/Interaction/EllieInteraction.cs @@ -0,0 +1,174 @@ +namespace EllieBot; + +public abstract class EllieInteractionBase +{ + private readonly ulong _authorId; + private readonly Func _onAction; + private readonly bool _onlyAuthor; + public DiscordSocketClient Client { get; } + + private readonly TaskCompletionSource _interactionCompletedSource; + + private IUserMessage message = null!; + private readonly string _customId; + private readonly bool _singleUse; + private readonly bool _clearAfter; + + public EllieInteractionBase( + DiscordSocketClient client, + ulong authorId, + string customId, + Func onAction, + bool onlyAuthor, + bool singleUse = true, + bool clearAfter = true) + { + _authorId = authorId; + _customId = customId; + _onAction = onAction; + _onlyAuthor = onlyAuthor; + _singleUse = singleUse; + _clearAfter = clearAfter; + + _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Client = client; + } + + public async Task RunAsync(IUserMessage msg) + { + message = msg; + + Client.InteractionCreated += OnInteraction; + await Task.WhenAny(Task.Delay(30_000), _interactionCompletedSource.Task); + Client.InteractionCreated -= OnInteraction; + + if (_clearAfter) + await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build()); + } + + private Task OnInteraction(SocketInteraction arg) + { + if (arg is not SocketMessageComponent smc) + return Task.CompletedTask; + + if (smc.Message.Id != message.Id) + return Task.CompletedTask; + + if (_onlyAuthor && smc.User.Id != _authorId) + return Task.CompletedTask; + + if (smc.Data.CustomId != _customId) + return Task.CompletedTask; + + if (_interactionCompletedSource.Task.IsCompleted) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + if (_singleUse) + _interactionCompletedSource.TrySetResult(true); + + await ExecuteOnActionAsync(smc); + + if (!smc.HasResponded) + { + await smc.DeferAsync(); + } + } + catch (Exception ex) + { + Log.Warning(ex, "An exception occured while handling an interaction: {Message}", ex.Message); + } + }); + + return Task.CompletedTask; + } + + + public abstract void AddTo(ComponentBuilder cb); + + public Task ExecuteOnActionAsync(SocketMessageComponent smc) + => _onAction(smc); + + public void SetCompleted() + => _interactionCompletedSource.TrySetResult(true); +} + +public sealed class EllieModalSubmitHandler +{ + private readonly ulong _authorId; + private readonly Func _onAction; + private readonly bool _onlyAuthor; + public DiscordSocketClient Client { get; } + + private readonly TaskCompletionSource _interactionCompletedSource; + + private IUserMessage message = null!; + private readonly string _customId; + + public EllieModalSubmitHandler( + DiscordSocketClient client, + ulong authorId, + string customId, + Func onAction, + bool onlyAuthor) + { + _authorId = authorId; + _customId = customId; + _onAction = onAction; + _onlyAuthor = onlyAuthor; + _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Client = client; + } + + public async Task RunAsync(IUserMessage msg) + { + message = msg; + + Client.ModalSubmitted += OnInteraction; + await Task.WhenAny(Task.Delay(300_000), _interactionCompletedSource.Task); + Client.ModalSubmitted -= OnInteraction; + + await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build()); + } + + private Task OnInteraction(SocketModal sm) + { + if (sm.Message.Id != message.Id) + return Task.CompletedTask; + + if (_onlyAuthor && sm.User.Id != _authorId) + return Task.CompletedTask; + + if (sm.Data.CustomId != _customId) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + _interactionCompletedSource.TrySetResult(true); + await ExecuteOnActionAsync(sm); + + if (!sm.HasResponded) + { + await sm.DeferAsync(); + } + } + catch (Exception ex) + { + Log.Warning(ex, "An exception occured while handling a: {Message}", ex.Message); + } + }); + + return Task.CompletedTask; + } + + + public Task ExecuteOnActionAsync(SocketModal smd) + => _onAction(smd); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/EllieInteractionService.cs b/src/EllieBot/_common/Interaction/EllieInteractionService.cs new file mode 100644 index 0000000..813ba1f --- /dev/null +++ b/src/EllieBot/_common/Interaction/EllieInteractionService.cs @@ -0,0 +1,82 @@ +namespace EllieBot; + +public class EllieInteractionService : IEllieInteractionService, IEService +{ + private readonly DiscordSocketClient _client; + + public EllieInteractionService(DiscordSocketClient client) + { + _client = client; + } + + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + bool singleUse = true, + bool clearAfter = true) + => new EllieButtonInteractionHandler(_client, + userId, + button, + onTrigger, + onlyAuthor: true, + singleUse: singleUse, + clearAfter: clearAfter); + + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + in T state, + bool singleUse = true, + bool clearAfter = true + ) + => Create(userId, + button, + ((Func>)((data) + => smc => onTrigger(smc, data)))(state), + singleUse, + clearAfter); + + public EllieInteractionBase Create( + ulong userId, + SelectMenuBuilder menu, + Func onTrigger, + bool singleUse = true) + => new EllieButtonSelectInteractionHandler(_client, + userId, + menu, + onTrigger, + onlyAuthor: true, + singleUse: singleUse); + + + /// + /// Create an interaction which opens a modal + /// + /// Id of the author + /// Button builder for the button that will open the modal + /// Modal + /// The function that will be called when the modal is submitted + /// Whether the button is single use + /// + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + ModalBuilder modal, + Func onTrigger, + bool singleUse = true) + => Create(userId, + button, + async (smc) => + { + await smc.RespondWithModalAsync(modal.Build()); + var modalHandler = new EllieModalSubmitHandler(_client, + userId, + modal.CustomId, + onTrigger, + true); + await modalHandler.RunAsync(smc.Message); + }, + singleUse: singleUse); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/IEllieInteractionService.cs b/src/EllieBot/_common/Interaction/IEllieInteractionService.cs new file mode 100644 index 0000000..26adc74 --- /dev/null +++ b/src/EllieBot/_common/Interaction/IEllieInteractionService.cs @@ -0,0 +1,33 @@ +namespace EllieBot; + +public interface IEllieInteractionService +{ + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + bool singleUse = true, + bool clearAfter = true); + + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + in T state, + bool singleUse = true, + bool clearAfter = true); + + EllieInteractionBase Create( + ulong userId, + SelectMenuBuilder menu, + Func onTrigger, + bool singleUse = true); + + EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + ModalBuilder modal, + Func onTrigger, + bool singleUse = true); + +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/InteractionHelpers.cs b/src/EllieBot/_common/Interaction/InteractionHelpers.cs new file mode 100644 index 0000000..0bac67f --- /dev/null +++ b/src/EllieBot/_common/Interaction/InteractionHelpers.cs @@ -0,0 +1,7 @@ +namespace EllieBot; + +public static class InteractionHelpers +{ + public static readonly IEmote ArrowLeft = Emote.Parse("<:x:1232256519844790302>"); + public static readonly IEmote ArrowRight = Emote.Parse("<:x:1232256515298295838>"); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs b/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs new file mode 100644 index 0000000..676ab5d --- /dev/null +++ b/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs @@ -0,0 +1,23 @@ +namespace EllieBot; + +public sealed class EllieButtonInteractionHandler : EllieInteractionBase +{ + public EllieButtonInteractionHandler( + DiscordSocketClient client, + ulong authorId, + ButtonBuilder button, + Func onAction, + bool onlyAuthor, + bool singleUse = true, + bool clearAfter = true) + : base(client, authorId, button.CustomId, onAction, onlyAuthor, singleUse, clearAfter) + { + Button = button; + } + + public ButtonBuilder Button { get; } + + public override void AddTo(ComponentBuilder cb) + => cb.WithButton(Button); + +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs b/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs new file mode 100644 index 0000000..fa3b0e0 --- /dev/null +++ b/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs @@ -0,0 +1,15 @@ +namespace EllieBot; + +public static class EllieInteractionExtensions +{ + public static MessageComponent CreateComponent( + this EllieInteractionBase nadekoInteractionBase + ) + { + var cb = new ComponentBuilder(); + + nadekoInteractionBase.AddTo(cb); + + return cb.Build(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs b/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs new file mode 100644 index 0000000..7100f01 --- /dev/null +++ b/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs @@ -0,0 +1,21 @@ +namespace EllieBot; + +public sealed class EllieButtonSelectInteractionHandler : EllieInteractionBase +{ + public EllieButtonSelectInteractionHandler( + DiscordSocketClient client, + ulong authorId, + SelectMenuBuilder menu, + Func onAction, + bool onlyAuthor, + bool singleUse = true) + : base(client, authorId, menu.CustomId, onAction, onlyAuthor, singleUse) + { + Menu = menu; + } + + public SelectMenuBuilder Menu { get; } + + public override void AddTo(ComponentBuilder cb) + => cb.WithSelectMenu(Menu); +} \ No newline at end of file diff --git a/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs b/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs new file mode 100644 index 0000000..969a416 --- /dev/null +++ b/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs @@ -0,0 +1,14 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Common.JsonConverters; + +public class CultureInfoConverter : JsonConverter +{ + public override CultureInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new(reader.GetString() ?? "en-US"); + + public override void Write(Utf8JsonWriter writer, CultureInfo value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Name); +} diff --git a/src/EllieBot/_common/JsonConverters/NumberToStringConverter.cs b/src/EllieBot/_common/JsonConverters/NumberToStringConverter.cs new file mode 100644 index 0000000..bc20b10 --- /dev/null +++ b/src/EllieBot/_common/JsonConverters/NumberToStringConverter.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +public class NumberToStringConverter : JsonConverter +{ + public override bool CanConvert(Type typeToConvert) + => typeof(string) == typeToConvert; + + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Number: + return reader.TryGetInt64(out var l) + ? l.ToString() + : reader.GetDouble().ToString(CultureInfo.InvariantCulture); + case JsonTokenType.String: + return reader.GetString() ?? string.Empty; + default: + { + using var document = JsonDocument.ParseValue(ref reader); + return document.RootElement.Clone().ToString(); + } + } + } + + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} \ No newline at end of file diff --git a/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs b/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs new file mode 100644 index 0000000..4b2a313 --- /dev/null +++ b/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs @@ -0,0 +1,14 @@ +using SixLabors.ImageSharp.PixelFormats; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Common.JsonConverters; + +public class Rgba32Converter : JsonConverter +{ + public override Rgba32 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Rgba32.ParseHex(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, Rgba32 value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToHex()); +} \ No newline at end of file diff --git a/src/EllieBot/_common/LbOpts.cs b/src/EllieBot/_common/LbOpts.cs new file mode 100644 index 0000000..5df4986 --- /dev/null +++ b/src/EllieBot/_common/LbOpts.cs @@ -0,0 +1,14 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Common; + +public class LbOpts : IEllieCommandOptions +{ + [Option('c', "clean", Default = false, HelpText = "Only show users who are on the server.")] + public bool Clean { get; set; } + + public void NormalizeOptions() + { + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Linq2DbExpressions.cs b/src/EllieBot/_common/Linq2DbExpressions.cs new file mode 100644 index 0000000..baa1f74 --- /dev/null +++ b/src/EllieBot/_common/Linq2DbExpressions.cs @@ -0,0 +1,17 @@ +#nullable disable +using LinqToDB; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +namespace EllieBot.Common; + +public static class Linq2DbExpressions +{ + [ExpressionMethod(nameof(GuildOnShardExpression))] + public static bool GuildOnShard(ulong guildId, int totalShards, int shardId) + => throw new NotSupportedException(); + + private static Expression> GuildOnShardExpression() + => (guildId, totalShards, shardId) + => guildId / 4194304 % (ulong)totalShards == (ulong)shardId; +} \ No newline at end of file diff --git a/src/EllieBot/_common/LoginErrorHandler.cs b/src/EllieBot/_common/LoginErrorHandler.cs new file mode 100644 index 0000000..bbdc9ce --- /dev/null +++ b/src/EllieBot/_common/LoginErrorHandler.cs @@ -0,0 +1,52 @@ +#nullable disable +using System.Net; +using System.Runtime.CompilerServices; + +namespace EllieBot.Common; + +public class LoginErrorHandler +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Handle(Exception ex) + => Log.Fatal(ex, "A fatal error has occurred while attempting to connect to Discord"); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Handle(HttpException ex) + { + switch (ex.HttpCode) + { + case HttpStatusCode.Unauthorized: + Log.Error("Your bot token is wrong.\n" + + "You can find the bot token under the Bot tab in the developer page.\n" + + "Fix your token in the credentials file and restart the bot"); + break; + + case HttpStatusCode.BadRequest: + Log.Error("Something has been incorrectly formatted in your credentials file.\n" + + "Use the JSON Guide as reference to fix it and restart the bot"); + Log.Error("If you are on Linux, make sure Redis is installed and running"); + break; + + case HttpStatusCode.RequestTimeout: + Log.Error("The request timed out. Make sure you have no external program blocking the bot " + + "from connecting to the internet"); + break; + + case HttpStatusCode.ServiceUnavailable: + case HttpStatusCode.InternalServerError: + Log.Error("Discord is having internal issues. Please, try again later"); + break; + + case HttpStatusCode.TooManyRequests: + Log.Error("Your bot has been ratelimited by Discord. Please, try again later.\n" + + "Global ratelimits usually last for an hour"); + break; + + default: + Log.Warning("An error occurred while attempting to connect to Discord"); + break; + } + + Log.Fatal(ex, "Fatal error occurred while loading credentials"); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/MessageType.cs b/src/EllieBot/_common/MessageType.cs new file mode 100644 index 0000000..ada8d99 --- /dev/null +++ b/src/EllieBot/_common/MessageType.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Common; + +public enum MsgType +{ + Ok, + Pending, + Error +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IBehavior.cs b/src/EllieBot/_common/ModuleBehaviors/IBehavior.cs new file mode 100644 index 0000000..5fdcc5c --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IBehavior.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Common.ModuleBehaviors; + +public interface IBehavior +{ + public virtual string Name => this.GetType().Name; +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs b/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs new file mode 100644 index 0000000..f23d085 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs @@ -0,0 +1,19 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Executed if no command was found for this message +/// +public interface IExecNoCommand : IBehavior +{ + /// + /// Executed at the end of the lifecycle if no command was found + /// → + /// → + /// → + /// [ | **] + /// + /// + /// + /// A task representing completion + Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs b/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs new file mode 100644 index 0000000..3e39152 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs @@ -0,0 +1,21 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Implemented by modules to handle non-bot messages received +/// +public interface IExecOnMessage : IBehavior +{ + int Priority { get; } + + /// + /// Ran after a non-bot message was received + /// ** → + /// → + /// → + /// [ | ] + /// + /// Guild where the message was sent + /// The message that was received + /// Whether further processing of this message should be blocked + Task ExecOnMessageAsync(IGuild guild, IUserMessage msg); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs b/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs new file mode 100644 index 0000000..bad88f8 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs @@ -0,0 +1,22 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after the command successfully finished execution. +/// ***There is no support for this method in EllieBot services.*** +/// It is only meant to be used in medusa system +/// +public interface IExecPostCommand : IBehavior +{ + /// + /// Executed after a command was successfully executed + /// → + /// → + /// → + /// [** | ] + /// + /// Command context + /// Module name + /// Command name + /// A task representing completion + ValueTask ExecPostCommandAsync(ICommandContext ctx, string moduleName, string commandName); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs b/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs new file mode 100644 index 0000000..4320d8f --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after a command was found but before it was executed. +/// Able to block further processing of a command +/// +public interface IExecPreCommand : IBehavior +{ + public int Priority { get; } + + /// + /// + /// Ran after a command was found but before execution. + /// + /// → + /// → + /// ** → + /// [ | ] + /// + /// Command context + /// Name of the module + /// Command info + /// Whether further processing of the command is blocked + Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs b/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs new file mode 100644 index 0000000..7039989 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Implemented by services which may transform input before a command is searched for +/// +public interface IInputTransformer : IBehavior +{ + /// + /// Ran after a non-bot message was received + /// -> + /// ** -> + /// -> + /// [ OR ] + /// + /// Guild + /// Channel in which the message was sent + /// User who sent the message + /// Content of the message + /// New input, if any, otherwise null + Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs b/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs new file mode 100644 index 0000000..9364286 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// All services which need to execute something after +/// the bot is ready should implement this interface +/// +public interface IReadyExecutor : IBehavior +{ + /// + /// Executed when bot is ready + /// + public Task OnReadyAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/OptionsParser.cs b/src/EllieBot/_common/OptionsParser.cs new file mode 100644 index 0000000..6908c8f --- /dev/null +++ b/src/EllieBot/_common/OptionsParser.cs @@ -0,0 +1,23 @@ +using CommandLine; + +namespace EllieBot.Common; + +public static class OptionsParser +{ + public static T ParseFrom(string[]? args) + where T : IEllieCommandOptions, new() + => ParseFrom(new T(), args).Item1; + + public static (T, bool) ParseFrom(T options, string[]? args) + where T : IEllieCommandOptions + { + using var p = new Parser(x => + { + x.HelpWriter = null; + }); + var res = p.ParseArguments(args); + var output = res.MapResult(x => x, _ => options); + output.NormalizeOptions(); + return (output, res.Tag == ParserResultType.Parsed); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/FeatureLimitKey.cs b/src/EllieBot/_common/Patronage/FeatureLimitKey.cs new file mode 100644 index 0000000..10278e1 --- /dev/null +++ b/src/EllieBot/_common/Patronage/FeatureLimitKey.cs @@ -0,0 +1,14 @@ +namespace EllieBot.Modules.Patronage; + +public enum LimitedFeatureName +{ + ChatBot, + ReactionRole, + Prune, + +} +public readonly struct FeatureLimitKey +{ + public string PrettyName { get; init; } + public string Key { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/IPatronData.cs b/src/EllieBot/_common/Patronage/IPatronData.cs new file mode 100644 index 0000000..8cd99e0 --- /dev/null +++ b/src/EllieBot/_common/Patronage/IPatronData.cs @@ -0,0 +1,11 @@ +namespace EllieBot.Modules.Patronage; + +public interface ISubscriberData +{ + public string UniquePlatformUserId { get; } + public ulong UserId { get; } + public int Cents { get; } + + public DateTime? LastCharge { get; } + public SubscriptionChargeStatus ChargeStatus { get; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/IPatronageService.cs b/src/EllieBot/_common/Patronage/IPatronageService.cs new file mode 100644 index 0000000..379de6b --- /dev/null +++ b/src/EllieBot/_common/Patronage/IPatronageService.cs @@ -0,0 +1,42 @@ +namespace EllieBot.Modules.Patronage; + +/// +/// Manages patrons and provides access to their data +/// +public interface IPatronageService +{ + /// + /// Called when the payment is made. + /// Either as a single payment for that patron, + /// or as a recurring monthly donation. + /// + public event Func OnNewPatronPayment; + + /// + /// Called when the patron changes the pledge amount + /// (Patron old, Patron new) => Task + /// + public event Func OnPatronUpdated; + + /// + /// Called when the patron refunds the purchase or it's marked as fraud + /// + public event Func OnPatronRefunded; + + /// + /// Gets a Patron with the specified userId + /// + /// UserId for which to get the patron data for. + /// A patron with the specifeid userId + public Task GetPatronAsync(ulong userId); + + Task LimitHitAsync(LimitedFeatureName key, ulong userId, int amount = 1); + Task LimitForceHit(LimitedFeatureName key, ulong userId, int amount); + Task GetUserLimit(LimitedFeatureName name, ulong userId); + + Task> LimitStats(ulong userId); + + PatronConfigData GetConfig(); + int PercentBonus(Patron? user); + int PercentBonus(long amount); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/ISubscriptionHandler.cs b/src/EllieBot/_common/Patronage/ISubscriptionHandler.cs new file mode 100644 index 0000000..95160f7 --- /dev/null +++ b/src/EllieBot/_common/Patronage/ISubscriptionHandler.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +/// +/// Services implementing this interface are handling pledges/subscriptions/payments coming +/// from a payment platform. +/// +public interface ISubscriptionHandler +{ + /// + /// Get Current patrons in batches. + /// This will only return patrons who have their discord account connected + /// + /// Batched patrons + public IAsyncEnumerable> GetPatronsAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/Patron.cs b/src/EllieBot/_common/Patronage/Patron.cs new file mode 100644 index 0000000..93a7575 --- /dev/null +++ b/src/EllieBot/_common/Patronage/Patron.cs @@ -0,0 +1,38 @@ +namespace EllieBot.Modules.Patronage; + +public readonly struct Patron +{ + /// + /// Unique id assigned to this patron by the payment platform + /// + public string UniquePlatformUserId { get; init; } + + /// + /// Discord UserId to which this is connected to + /// + public ulong UserId { get; init; } + + /// + /// Amount the Patron is currently pledging or paid in cents + /// + public int Amount { get; init; } + + /// + /// Current Tier of the patron + /// (do not question it in consumer classes, as the calculation should be always internal and may change) + /// + public PatronTier Tier { get; init; } + + /// + /// When was the last time this was paid + /// + public DateTime PaidAt { get; init; } + + /// + /// After which date does the user's Patronage benefit end + /// + public DateTime ValidThru { get; init; } + + public bool IsActive + => !ValidThru.IsBeforeToday(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/PatronConfigData.cs b/src/EllieBot/_common/Patronage/PatronConfigData.cs new file mode 100644 index 0000000..9becae0 --- /dev/null +++ b/src/EllieBot/_common/Patronage/PatronConfigData.cs @@ -0,0 +1,17 @@ +using EllieBot.Common.Yml; +using Cloneable; + +namespace EllieBot.Modules.Patronage; + +[Cloneable] +public partial class PatronConfigData : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 3; + + [Comment("Whether the patronage feature is enabled")] + public bool IsEnabled { get; set; } + + [Comment("Who can do how much of what")] + public Dictionary> Limits { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/PatronExtensions.cs b/src/EllieBot/_common/Patronage/PatronExtensions.cs new file mode 100644 index 0000000..1686a4c --- /dev/null +++ b/src/EllieBot/_common/Patronage/PatronExtensions.cs @@ -0,0 +1,30 @@ +namespace EllieBot.Modules.Patronage; + +public static class PatronExtensions +{ + public static string ToFullName(this PatronTier tier) + => tier switch + { + _ => $"Patron Tier {tier}", + }; + + public static DateTime DayOfNextMonth(this DateTime date, int day) + { + var nextMonth = date.AddMonths(1); + var dt = DateTime.SpecifyKind(new(nextMonth.Year, nextMonth.Month, day), DateTimeKind.Utc); + return dt; + } + + public static DateTime FirstOfNextMonth(this DateTime date) + => date.DayOfNextMonth(1); + + public static DateTime SecondOfNextMonth(this DateTime date) + => date.DayOfNextMonth(2); + + public static string ToShortAndRelativeTimestampTag(this DateTime date) + { + var fullResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.ShortDateTime); + var relativeResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.Relative); + return $"{fullResetStr}\n{relativeResetStr}"; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/PatronTier.cs b/src/EllieBot/_common/Patronage/PatronTier.cs new file mode 100644 index 0000000..0bbe804 --- /dev/null +++ b/src/EllieBot/_common/Patronage/PatronTier.cs @@ -0,0 +1,14 @@ +// ReSharper disable InconsistentNaming +namespace EllieBot.Modules.Patronage; + +public enum PatronTier +{ + None, + I, + V, + X, + XX, + L, + C, + ComingSoon +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/QuotaLimit.cs b/src/EllieBot/_common/Patronage/QuotaLimit.cs new file mode 100644 index 0000000..5669c0c --- /dev/null +++ b/src/EllieBot/_common/Patronage/QuotaLimit.cs @@ -0,0 +1,23 @@ +namespace EllieBot.Modules.Patronage; + +/// +/// Represents information about why the user has triggered a quota limit +/// +public readonly struct QuotaLimit +{ + /// + /// Amount of usages reached, which is the limit + /// + public int Quota { get; init; } + + /// + /// Which period is this quota limit for (hourly, daily, monthly, etc...) + /// + public QuotaPer QuotaPeriod { get; init; } + + public QuotaLimit(int quota, QuotaPer quotaPeriod) + { + Quota = quota; + QuotaPeriod = quotaPeriod; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/QuotaPer.cs b/src/EllieBot/_common/Patronage/QuotaPer.cs new file mode 100644 index 0000000..9f67a40 --- /dev/null +++ b/src/EllieBot/_common/Patronage/QuotaPer.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Patronage; + +public enum QuotaPer +{ + PerHour, + PerDay, + PerMonth, + Total, +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs b/src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs new file mode 100644 index 0000000..7ef541f --- /dev/null +++ b/src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +public enum SubscriptionChargeStatus +{ + Paid, + Refunded, + Unpaid, + Other, +} \ No newline at end of file diff --git a/src/EllieBot/_common/Pokemon/PokemonNameId.cs b/src/EllieBot/_common/Pokemon/PokemonNameId.cs new file mode 100644 index 0000000..341b5c2 --- /dev/null +++ b/src/EllieBot/_common/Pokemon/PokemonNameId.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Common.Pokemon; + +public class PokemonNameId +{ + public int Id { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Pokemon/SearchPokemon.cs b/src/EllieBot/_common/Pokemon/SearchPokemon.cs new file mode 100644 index 0000000..b4ecae1 --- /dev/null +++ b/src/EllieBot/_common/Pokemon/SearchPokemon.cs @@ -0,0 +1,41 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Common.Pokemon; + +public class SearchPokemon +{ + [JsonPropertyName("num")] + public int Id { get; set; } + + public string Species { get; set; } + public string[] Types { get; set; } + public GenderRatioClass GenderRatio { get; set; } + public BaseStatsClass BaseStats { get; set; } + public Dictionary Abilities { get; set; } + public float HeightM { get; set; } + public float WeightKg { get; set; } + public string Color { get; set; } + public string[] Evos { get; set; } + public string[] EggGroups { get; set; } + + public class GenderRatioClass + { + public float M { get; set; } + public float F { get; set; } + } + + public class BaseStatsClass + { + public int Hp { get; set; } + public int Atk { get; set; } + public int Def { get; set; } + public int Spa { get; set; } + public int Spd { get; set; } + public int Spe { get; set; } + + public override string ToString() + => $@"💚**HP:** {Hp,-4} ⚔**ATK:** {Atk,-4} 🛡**DEF:** {Def,-4} +✨**SPA:** {Spa,-4} 🎇**SPD:** {Spd,-4} 💨**SPE:** {Spe,-4}"; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs b/src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs new file mode 100644 index 0000000..f401284 --- /dev/null +++ b/src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Common.Pokemon; + +public class SearchPokemonAbility +{ + public string Desc { get; set; } + public string ShortDesc { get; set; } + public string Name { get; set; } + public float Rating { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/IReplacementPatternStore.cs b/src/EllieBot/_common/Replacements/IReplacementPatternStore.cs new file mode 100644 index 0000000..0a6483a --- /dev/null +++ b/src/EllieBot/_common/Replacements/IReplacementPatternStore.cs @@ -0,0 +1,20 @@ +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public interface IReplacementPatternStore : IEService +{ + IReadOnlyDictionary Replacements { get; } + IReadOnlyDictionary RegexReplacements { get; } + + ValueTask Register(string token, Func> repFactory); + ValueTask Register(string token, Func> repFactory); + ValueTask Register(string token, Func> repFactory); + + ValueTask Register(string token, Func repFactory); + ValueTask Register(string token, Func repFactory); + ValueTask Register(string token, Func repFactory); + + ValueTask Register(Regex regex, Func repFactory); + ValueTask Register(Regex regex, Func repFactory); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/IReplacementService.cs b/src/EllieBot/_common/Replacements/IReplacementService.cs new file mode 100644 index 0000000..66a0896 --- /dev/null +++ b/src/EllieBot/_common/Replacements/IReplacementService.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Common; + +public interface IReplacementService +{ + ValueTask ReplaceAsync(string input, ReplacementContext repCtx); + ValueTask ReplaceAsync(SmartText input, ReplacementContext repCtx); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs new file mode 100644 index 0000000..4e87ce9 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed class ReplacementContext +{ + public DiscordSocketClient? Client { get; } + public IGuild? Guild { get; } + public IMessageChannel? Channel { get; } + public IUser? User { get; } + + private readonly List _overrides = new(); + private readonly HashSet _tokens = new(); + + public IReadOnlyList Overrides + => _overrides.AsReadOnly(); + + private readonly List _regexOverrides = new(); + private readonly HashSet _regexPatterns = new(); + + public IReadOnlyList RegexOverrides + => _regexOverrides.AsReadOnly(); + + public ReplacementContext(ICommandContext cmdContext) + : this(cmdContext.Client as DiscordSocketClient, + cmdContext.Guild, + cmdContext.Channel, + cmdContext.User) + { + } + + public ReplacementContext( + DiscordSocketClient? client = null, + IGuild? guild = null, + IMessageChannel? channel = null, + IUser? user = null) + { + Client = client; + Guild = guild; + Channel = channel; + User = user; + } + + public ReplacementContext WithOverride(string key, Func> repFactory) + { + if (_tokens.Add(key)) + { + _overrides.Add(new(key, repFactory)); + } + + return this; + } + + public ReplacementContext WithOverride(string key, Func repFactory) + => WithOverride(key, () => new ValueTask(repFactory())); + + public ReplacementContext WithOverride(Regex regex, Func> repFactory) + { + if (_regexPatterns.Add(regex.ToString())) + { + _regexOverrides.Add(new(regex, repFactory)); + } + + return this; + } + + public ReplacementContext WithOverride(Regex regex, Func repFactory) + => WithOverride(regex, (Match m) => new ValueTask(repFactory(m))); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs new file mode 100644 index 0000000..254de96 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed class ReplacementInfo +{ + private readonly Delegate _del; + public IReadOnlyCollection InputTypes { get; } + public string Token { get; } + + private static readonly Func> _falllbackFunc = static () => default; + + public ReplacementInfo(string token, Delegate del) + { + _del = del; + InputTypes = del.GetMethodInfo().GetParameters().Select(x => x.ParameterType).ToArray().AsReadOnly(); + Token = token; + } + + public async Task GetValueAsync(params object?[]? objs) + => await (ValueTask)(_del.DynamicInvoke(objs) ?? _falllbackFunc); + + public override int GetHashCode() + => Token.GetHashCode(); + + public override bool Equals(object? obj) + => obj is ReplacementInfo ri && ri.Token == Token; +} + +public sealed class RegexReplacementInfo +{ + private readonly Delegate _del; + public IReadOnlyCollection InputTypes { get; } + + public Regex Regex { get; } + public string Pattern { get; } + + private static readonly Func> _falllbackFunc = static _ => default; + + public RegexReplacementInfo(Regex regex, Delegate del) + { + _del = del; + InputTypes = del.GetMethodInfo().GetParameters().Select(x => x.ParameterType).ToArray().AsReadOnly(); + Regex = regex; + Pattern = Regex.ToString(); + } + + public async Task GetValueAsync(Match m, params object?[]? objs) + => await ((Func>)(_del.DynamicInvoke(objs) ?? _falllbackFunc))(m); + + public override int GetHashCode() + => Regex.GetHashCode(); + + public override bool Equals(object? obj) + => obj is RegexReplacementInfo ri && ri.Pattern == Pattern; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs new file mode 100644 index 0000000..3692184 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs @@ -0,0 +1,130 @@ +using System.Text.RegularExpressions; +using OneOf; + +namespace EllieBot.Common; + +public sealed partial class ReplacementPatternStore : IReplacementPatternStore, IEService +{ + private readonly ConcurrentDictionary> _guids = new(); + + private readonly ConcurrentDictionary _defaultReplacements = new(); + private readonly ConcurrentDictionary _regexReplacements = new(); + + public IReadOnlyDictionary Replacements + => _defaultReplacements.AsReadOnly(); + + public IReadOnlyDictionary RegexReplacements + => _regexReplacements.AsReadOnly(); + + public ReplacementPatternStore() + { + WithClient(); + WithChannel(); + WithServer(); + WithUsers(); + WithDefault(); + WithRegex(); + } + + // private async ValueTask InternalReplace(string input, ReplacementContexta repCtx) + // { + // // multiple executions vs single execution per replacement + // var minIndex = -1; + // var index = -1; + // foreach (var rep in _replacements) + // { + // while ((index = input.IndexOf(rep.Key, StringComparison.InvariantCulture)) != -1 && index > minIndex) + // { + // var valueToInsert = await rep.Value(repCtx); + // input = input[..index] + valueToInsert +input[(index + rep.Key.Length)..]; + // minIndex = (index + valueToInsert.Length); + // } + // } + // + // return input; + // } + + private ValueTask InternalRegister(string token, Delegate repFactory) + { + if (!token.StartsWith('%') || !token.EndsWith('%')) + { + Log.Warning( + """ + Invalid replacement token: {Token} + Tokens have to start and end with a '%', ex: %mytoken% + """, + token); + return new(default(Guid?)); + } + + if (_defaultReplacements.TryAdd(token, new ReplacementInfo(token, repFactory))) + { + var guid = Guid.NewGuid(); + _guids[guid] = token; + return new(guid); + } + + return new(default(Guid?)); + } + + public ValueTask Register(string token, Func> repFactory) + => InternalRegister(token, repFactory); + + public ValueTask Register(string token, Func> repFactory) + => InternalRegister(token, repFactory); + + public ValueTask Register(string token, Func> repFactory) + => InternalRegister(token, repFactory); + + public ValueTask Register(string token, Func repFactory) + => InternalRegister(token, () => new ValueTask(repFactory())); + + public ValueTask Register(string token, Func repFactory) + => InternalRegister(token, (T1 a) => new ValueTask(repFactory(a))); + + public ValueTask Register(string token, Func repFactory) + => InternalRegister(token, (T1 a, T2 b) => new ValueTask(repFactory(a, b))); + + + private ValueTask InternalRegexRegister(Regex regex, Delegate repFactory) + { + var regexPattern = regex.ToString(); + if (!regexPattern.StartsWith('%') || !regexPattern.EndsWith('%')) + { + Log.Warning( + """ + Invalid replacement pattern: {Token} + Tokens have to start and end with a '%', ex: %mytoken% + """, + regex); + return new(default(Guid?)); + } + + if (_regexReplacements.TryAdd(regexPattern, new RegexReplacementInfo(regex, repFactory))) + { + var guid = Guid.NewGuid(); + _guids[guid] = regex; + return new(guid); + } + + return new(default(Guid?)); + } + + public ValueTask Register(Regex regex, Func repFactory) + => InternalRegexRegister(regex, () => (Match m) => new ValueTask(repFactory(m))); + + public ValueTask Register(Regex regex, Func repFactory) + => InternalRegexRegister(regex, (T1 a) => (Match m) => new ValueTask(repFactory(m, a))); + + public bool Unregister(Guid guid) + { + if (_guids.TryRemove(guid, out var pattern)) + { + return pattern.Match( + token => _defaultReplacements.TryRemove(token, out _), + regex => _regexReplacements.TryRemove(regex.ToString(), out _)); + } + + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs new file mode 100644 index 0000000..82d1d14 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs @@ -0,0 +1,126 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed partial class ReplacementPatternStore +{ + private static readonly Regex _rngRegex = new(@"%rng(?:(?(?:-)?\d+)-(?(?:-)?\d+))?%", + RegexOptions.Compiled); + + + private void WithDefault() + { + Register("%bot.time%", + static () + => DateTime.Now.ToString("HH:mm " + TimeZoneInfo.Local.StandardName.GetInitials())); + } + + private void WithClient() + { + Register("%bot.status%", static (DiscordSocketClient client) => client.Status.ToString()); + Register("%bot.latency%", static (DiscordSocketClient client) => client.Latency.ToString()); + Register("%bot.name%", static (DiscordSocketClient client) => client.CurrentUser.Username); + Register("%bot.fullname%", static (DiscordSocketClient client) => client.CurrentUser.ToString()); + Register("%bot.discrim%", static (DiscordSocketClient client) => client.CurrentUser.Discriminator); + Register("%bot.id%", static (DiscordSocketClient client) => client.CurrentUser.Id.ToString()); + Register("%bot.avatar%", + static (DiscordSocketClient client) => client.CurrentUser.RealAvatarUrl().ToString()); + + Register("%bot.mention%", static (DiscordSocketClient client) => client.CurrentUser.Mention); + + Register("%shard.servercount%", static (DiscordSocketClient c) => c.Guilds.Count.ToString()); + Register("%shard.usercount%", + static (DiscordSocketClient c) => c.Guilds.Sum(g => g.MemberCount).ToString()); + Register("%shard.id%", static (DiscordSocketClient c) => c.ShardId.ToString()); + } + + private void WithServer() + { + Register("%server%", static (IGuild g) => g.Name); + Register("%server.id%", static (IGuild g) => g.Id.ToString()); + Register("%server.name%", static (IGuild g) => g.Name); + Register("%server.icon%", static (IGuild g) => g.IconUrl); + Register("%server.members%", static (IGuild g) => (g as SocketGuild)?.MemberCount.ToString() ?? "?"); + Register("%server.boosters%", static (IGuild g) => g.PremiumSubscriptionCount.ToString()); + Register("%server.boost_level%", static (IGuild g) => ((int)g.PremiumTier).ToString()); + } + + private void WithChannel() + { + Register("%channel%", static (IMessageChannel ch) => ch.Name); + Register("%channel.mention%", + static (IMessageChannel ch) => (ch as ITextChannel)?.Mention ?? "#" + ch.Name); + Register("%channel.name%", static (IMessageChannel ch) => ch.Name); + Register("%channel.id%", static (IMessageChannel ch) => ch.Id.ToString()); + Register("%channel.created%", + static (IMessageChannel ch) => ch.CreatedAt.ToString("HH:mm dd.MM.yyyy")); + Register("%channel.nsfw%", + static (IMessageChannel ch) => (ch as ITextChannel)?.IsNsfw.ToString() ?? "-"); + Register("%channel.topic%", static (IMessageChannel ch) => (ch as ITextChannel)?.Topic ?? "-"); + } + + private void WithUsers() + { + Register("%user%", static (IUser user) => user.Mention); + Register("%user.mention%", static (IUser user) => user.Mention); + Register("%user.fullname%", static (IUser user) => user.ToString()!); + Register("%user.name%", static (IUser user) => user.Username); + Register("%user.discrim%", static (IUser user) => user.Discriminator); + Register("%user.avatar%", static (IUser user) => user.RealAvatarUrl().ToString()); + Register("%user.id%", static (IUser user) => user.Id.ToString()); + Register("%user.created_time%", static (IUser user) => user.CreatedAt.ToString("HH:mm")); + Register("%user.created_date%", static (IUser user) => user.CreatedAt.ToString("dd.MM.yyyy")); + Register("%user.joined_time%", static (IGuildUser user) => user.JoinedAt?.ToString("HH:mm")); + Register("%user.joined_date%", static (IGuildUser user) => user.JoinedAt?.ToString("dd.MM.yyyy")); + + Register("%user%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Mention))); + Register("%user.mention%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Mention))); + Register("%user.fullname%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.ToString()))); + Register("%user.name%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Username))); + Register("%user.discrim%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Discriminator))); + Register("%user.avatar%", + static (IUser[] users) + => string.Join(" ", users.Select(user => user.RealAvatarUrl().ToString()))); + Register("%user.id%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Id.ToString()))); + Register("%user.created_time%", + static (IUser[] users) + => string.Join(" ", users.Select(user => user.CreatedAt.ToString("HH:mm")))); + Register("%user.created_date%", + static (IUser[] users) + => string.Join(" ", users.Select(user => user.CreatedAt.ToString("dd.MM.yyyy")))); + Register("%user.joined_time%", + static (IUser[] users) => string.Join(" ", + users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("HH:mm") ?? "-"))); + Register("%user.joined_date%", + static (IUser[] users) => string.Join(" ", + users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("dd.MM.yyyy") ?? "-"))); + } + + private void WithRegex() + { + Register(_rngRegex, + match => + { + var rng = new EllieRandom(); + if (!int.TryParse(match.Groups["from"].ToString(), out var from)) + from = 0; + if (!int.TryParse(match.Groups["to"].ToString(), out var to)) + to = 0; + + if (from == 0 && to == 0) + return rng.Next(0, 11).ToString(); + + if (from >= to) + return string.Empty; + + return rng.Next(from, to + 1).ToString(); + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementService.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementService.cs new file mode 100644 index 0000000..18712d8 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementService.cs @@ -0,0 +1,137 @@ +namespace EllieBot.Common; + +public sealed class ReplacementService : IReplacementService, IEService +{ + private readonly IReplacementPatternStore _repReg; + + public ReplacementService(IReplacementPatternStore repReg) + { + _repReg = repReg; + } + + public async ValueTask ReplaceAsync(SmartText input, ReplacementContext repCtx) + { + var reps = GetReplacementsForContext(repCtx); + var regReps = GetRegexReplacementsForContext(repCtx); + + var inputData = GetInputData(repCtx); + var rep = new Replacer(reps.Values, regReps.Values, inputData); + + return await rep.ReplaceAsync(input); + } + + public async ValueTask ReplaceAsync(string input, ReplacementContext repCtx) + { + var reps = GetReplacementsForContext(repCtx); + var regReps = GetRegexReplacementsForContext(repCtx); + + var inputData = GetInputData(repCtx); + var rep = new Replacer(reps.Values, regReps.Values, inputData); + + return await rep.ReplaceAsync(input); + } + + private object[] GetInputData(ReplacementContext repCtx) + { + var obj = new List(); + if (repCtx.Client is not null) + obj.Add(repCtx.Client); + + if (repCtx.Guild is not null) + obj.Add(repCtx.Guild); + + if (repCtx.User is not null) + obj.Add(repCtx.User); + + if (repCtx.Channel is not null) + obj.Add(repCtx.Channel); + + return obj.ToArray(); + } + + private IDictionary GetReplacementsForContext(ReplacementContext repCtx) + { + var reps = GetOriginalReplacementsForContext(repCtx); + foreach (var ovrd in repCtx.Overrides) + { + reps.Remove(ovrd.Token); + reps.TryAdd(ovrd.Token, ovrd); + } + + return reps; + } + + private IDictionary GetRegexReplacementsForContext(ReplacementContext repCtx) + { + var reps = GetOriginalRegexReplacementsForContext(repCtx); + foreach (var ovrd in repCtx.RegexOverrides) + { + reps.Remove(ovrd.Pattern); + reps.TryAdd(ovrd.Pattern, ovrd); + } + + return reps; + } + + private IDictionary GetOriginalReplacementsForContext(ReplacementContext repCtx) + { + var objs = new List(); + if (repCtx.Client is not null) + { + objs.Add(repCtx.Client); + } + + if (repCtx.Channel is not null) + { + objs.Add(repCtx.Channel); + } + + if (repCtx.User is not null) + { + objs.Add(repCtx.User); + } + + if (repCtx.Guild is not null) + { + objs.Add(repCtx.Guild); + } + + var types = objs.Map(x => x.GetType()).OrderBy(x => x.Name).ToHashSet(); + + return _repReg.Replacements + .Values + .Where(rep => rep.InputTypes.All(t => types.Any(x => x.IsAssignableTo((t))))) + .ToDictionary(rep => rep.Token, rep => rep); + } + + private IDictionary GetOriginalRegexReplacementsForContext(ReplacementContext repCtx) + { + var objs = new List(); + if (repCtx.Client is not null) + { + objs.Add(repCtx.Client); + } + + if (repCtx.Channel is not null) + { + objs.Add(repCtx.Channel); + } + + if (repCtx.User is not null) + { + objs.Add(repCtx.User); + } + + if (repCtx.Guild is not null) + { + objs.Add(repCtx.Guild); + } + + var types = objs.Map(x => x.GetType()).OrderBy(x => x.Name).ToHashSet(); + + return _repReg.RegexReplacements + .Values + .Where(rep => rep.InputTypes.All(t => types.Any(x => x.IsAssignableTo((t))))) + .ToDictionary(rep => rep.Pattern, rep => rep); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/Replacer.cs b/src/EllieBot/_common/Replacements/Impl/Replacer.cs new file mode 100644 index 0000000..adc5e4d --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/Replacer.cs @@ -0,0 +1,141 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed partial class Replacer +{ + private readonly IEnumerable _reps; + private readonly IEnumerable _regexReps; + private readonly object[] _inputData; + + // [GeneratedRegex(@"\%[\p{L}\p{N}\._]*[\p{L}\p{N}]+[\p{L}\p{N}\._]*\%")] + // private static partial Regex TokenExtractionRegex(); + + public Replacer(IEnumerable reps, IEnumerable regexReps, object[] inputData) + { + _reps = reps; + _inputData = inputData; + _regexReps = regexReps; + } + + public async ValueTask ReplaceAsync(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return input; + + // var matches = TokenExtractionRegex().IsMatch(input); + + // if (matches) + // { + foreach (var rep in _reps) + { + if (input.Contains(rep.Token, StringComparison.InvariantCulture)) + { + var objs = GetParams(rep.InputTypes); + input = input.Replace(rep.Token, await rep.GetValueAsync(objs), StringComparison.InvariantCulture); + } + } + // } + + foreach (var rep in _regexReps) + { + var sb = new StringBuilder(); + + var objs = GetParams(rep.InputTypes); + var match = rep.Regex.Match(input); + if (match.Success) + { + sb.Append(input, 0, match.Index) + .Append(await rep.GetValueAsync(match, objs)); + + var lastIndex = match.Index + match.Length; + sb.Append(input, lastIndex, input.Length - lastIndex); + input = sb.ToString(); + } + } + + return input; + } + + private object?[]? GetParams(IReadOnlyCollection inputTypes) + { + if (inputTypes.Count == 0) + return null; + + var objs = new List(); + foreach (var t in inputTypes) + { + var datum = _inputData.FirstOrDefault(x => x.GetType().IsAssignableTo(t)); + if (datum is not null) + objs.Add(datum); + } + + return objs.ToArray(); + } + + public async ValueTask ReplaceAsync(SmartText data) + => data switch + { + SmartEmbedText embedData => await ReplaceAsync(embedData) with + { + PlainText = await ReplaceAsync(embedData.PlainText), + Color = embedData.Color + }, + SmartPlainText plain => await ReplaceAsync(plain), + SmartEmbedTextArray arr => await ReplaceAsync(arr), + _ => throw new ArgumentOutOfRangeException(nameof(data), "Unsupported argument type") + }; + + private async Task ReplaceAsync(SmartEmbedTextArray embedArr) + => new() + { + Embeds = await embedArr.Embeds.Map(async e => await ReplaceAsync(e) with + { + Color = e.Color + }) + .WhenAll(), + Content = await ReplaceAsync(embedArr.Content) + }; + + private async ValueTask ReplaceAsync(SmartPlainText plain) + => await ReplaceAsync(plain.Text); + + private async Task ReplaceAsync(T embedData) + where T : SmartEmbedTextBase, new() + { + var newEmbedData = new T + { + Description = await ReplaceAsync(embedData.Description), + Title = await ReplaceAsync(embedData.Title), + Thumbnail = await ReplaceAsync(embedData.Thumbnail), + Image = await ReplaceAsync(embedData.Image), + Url = await ReplaceAsync(embedData.Url), + Author = embedData.Author is null + ? null + : new() + { + Name = await ReplaceAsync(embedData.Author.Name), + IconUrl = await ReplaceAsync(embedData.Author.IconUrl) + }, + Fields = await Task.WhenAll(embedData + .Fields? + .Map(async f => new SmartTextEmbedField + { + Name = await ReplaceAsync(f.Name), + Value = await ReplaceAsync(f.Value), + Inline = f.Inline + }) + ?? []), + Footer = embedData.Footer is null + ? null + : new() + { + Text = await ReplaceAsync(embedData.Footer.Text), + IconUrl = await ReplaceAsync(embedData.Footer.IconUrl) + } + }; + + return newEmbedData; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs b/src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs new file mode 100644 index 0000000..74a0367 --- /dev/null +++ b/src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs @@ -0,0 +1,15 @@ +#nullable disable +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace EllieBot.Common; + +public class RequireObjectPropertiesContractResolver : DefaultContractResolver +{ + protected override JsonObjectContract CreateObjectContract(Type objectType) + { + var contract = base.CreateObjectContract(objectType); + contract.ItemRequired = Required.DisallowNull; + return contract; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/IMessageSenderService.cs b/src/EllieBot/_common/Sender/IMessageSenderService.cs new file mode 100644 index 0000000..ccc8c0f --- /dev/null +++ b/src/EllieBot/_common/Sender/IMessageSenderService.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Extensions; + +public interface IMessageSenderService +{ + ResponseBuilder Response(IMessageChannel channel); + ResponseBuilder Response(ICommandContext ctx); + ResponseBuilder Response(IUser user); + + ResponseBuilder Response(SocketMessageComponent smc); + + EllieEmbedBuilder CreateEmbed(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/MessageSenderService.cs b/src/EllieBot/_common/Sender/MessageSenderService.cs new file mode 100644 index 0000000..88f1c40 --- /dev/null +++ b/src/EllieBot/_common/Sender/MessageSenderService.cs @@ -0,0 +1,56 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Extensions; + +public sealed class MessageSenderService : IMessageSenderService, IEService +{ + private readonly IBotStrings _bs; + private readonly BotConfigService _bcs; + private readonly DiscordSocketClient _client; + + public MessageSenderService(IBotStrings bs, BotConfigService bcs, DiscordSocketClient client) + { + _bs = bs; + _bcs = bcs; + _client = client; + } + + + public ResponseBuilder Response(IMessageChannel channel) + => new ResponseBuilder(_bs, _bcs, _client) + .Channel(channel); + + public ResponseBuilder Response(ICommandContext ctx) + => new ResponseBuilder(_bs, _bcs, _client) + .Context(ctx); + + public ResponseBuilder Response(IUser user) + => new ResponseBuilder(_bs, _bcs, _client) + .User(user); + + public ResponseBuilder Response(SocketMessageComponent smc) + => new ResponseBuilder(_bs, _bcs, _client) + .Channel(smc.Channel); + + public EllieEmbedBuilder CreateEmbed() + => new EllieEmbedBuilder(_bcs); +} + +public class EllieEmbedBuilder : EmbedBuilder +{ + private readonly BotConfig _bc; + + public EllieEmbedBuilder(BotConfigService bcs) + { + _bc = bcs.Data; + } + + public EmbedBuilder WithOkColor() + => WithColor(_bc.Color.Ok.ToDiscordColor()); + + public EmbedBuilder WithErrorColor() + => WithColor(_bc.Color.Error.ToDiscordColor()); + + public EmbedBuilder WithPendingColor() + => WithColor(_bc.Color.Pending.ToDiscordColor()); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs b/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs new file mode 100644 index 0000000..92d871a --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs @@ -0,0 +1,167 @@ +namespace EllieBot.Extensions; + +public partial class ResponseBuilder +{ + public class PaginationSender + { + private const string BUTTON_LEFT = "BUTTON_LEFT"; + private const string BUTTON_RIGHT = "BUTTON_RIGHT"; + + private readonly SourcedPaginatedResponseBuilder _paginationBuilder; + private readonly ResponseBuilder _builder; + private readonly DiscordSocketClient _client; + private int currentPage; + + private EllieButtonInteractionHandler? left; + private EllieButtonInteractionHandler? right; + private EllieInteractionBase? extra; + + public PaginationSender( + SourcedPaginatedResponseBuilder paginationBuilder, + ResponseBuilder builder) + { + _paginationBuilder = paginationBuilder; + _builder = builder; + + _client = builder.Client; + currentPage = paginationBuilder.InitialPage; + } + + public async Task SendAsync(bool ephemeral = false) + { + var lastPage = (_paginationBuilder.Elems - 1) + / _paginationBuilder.ItemsPerPage; + + var items = (await _paginationBuilder.ItemsFunc(currentPage)).ToArray(); + var embed = await _paginationBuilder.PageFunc(items, currentPage); + + if (_paginationBuilder.AddPaginatedFooter) + embed.AddPaginatedFooter(currentPage, lastPage); + + EllieInteractionBase? maybeInter = null; + + var model = await _builder.BuildAsync(ephemeral); + + async Task<(EllieButtonInteractionHandler left, EllieInteractionBase? extra, EllieButtonInteractionHandler right)> + GetInteractions() + { + var leftButton = new ButtonBuilder() + .WithStyle(ButtonStyle.Primary) + .WithCustomId(BUTTON_LEFT) + .WithEmote(InteractionHelpers.ArrowLeft) + .WithDisabled(lastPage == 0 || currentPage <= 0); + + var leftBtnInter = new EllieButtonInteractionHandler(_client, + model.User?.Id ?? 0, + leftButton, + (smc) => + { + try + { + if (currentPage > 0) + currentPage--; + + _ = UpdatePageAsync(smc); + } + catch (Exception ex) + { + Log.Error(ex, "Error in pagination: {ErrorMessage}", ex.Message); + } + + return Task.CompletedTask; + }, + true, + singleUse: false, + clearAfter: false); + + if (_paginationBuilder.InteractionFunc is not null) + { + maybeInter = await _paginationBuilder.InteractionFunc(currentPage); + } + + var rightButton = new ButtonBuilder() + .WithStyle(ButtonStyle.Primary) + .WithCustomId(BUTTON_RIGHT) + .WithEmote(InteractionHelpers.ArrowRight) + .WithDisabled(lastPage == 0 || currentPage >= lastPage); + + var rightBtnInter = new EllieButtonInteractionHandler(_client, + model.User?.Id ?? 0, + rightButton, + (smc) => + { + try + { + if (currentPage >= lastPage) + return Task.CompletedTask; + + currentPage++; + + _ = UpdatePageAsync(smc); + } + catch (Exception ex) + { + Log.Error(ex, "Error in pagination: {ErrorMessage}", ex.Message); + } + + return Task.CompletedTask; + }, + true, + singleUse: false, + clearAfter: false); + + return (leftBtnInter, maybeInter, rightBtnInter); + } + + (left, extra, right) = await GetInteractions(); + + async Task UpdatePageAsync(SocketMessageComponent smc) + { + var pageItems = (await _paginationBuilder.ItemsFunc(currentPage)).ToArray(); + var toSend = await _paginationBuilder.PageFunc(pageItems, currentPage); + if (_paginationBuilder.AddPaginatedFooter) + toSend.AddPaginatedFooter(currentPage, lastPage); + + left?.SetCompleted(); + right?.SetCompleted(); + extra?.SetCompleted(); + (left, extra, right) = (await GetInteractions()); + + var cb = new ComponentBuilder(); + left.AddTo(cb); + right.AddTo(cb); + extra?.AddTo(cb); + + await smc.ModifyOriginalResponseAsync(x => + { + x.Embed = toSend.Build(); + x.Components = cb.Build(); + }); + + await Task.WhenAll(left.RunAsync(smc.Message), extra?.RunAsync(smc.Message) ?? Task.CompletedTask, right.RunAsync(smc.Message)); + } + + + var cb = new ComponentBuilder(); + left.AddTo(cb); + right.AddTo(cb); + extra?.AddTo(cb); + + var msg = await model.TargetChannel + .SendMessageAsync(model.Text, + embed: embed.Build(), + components: cb.Build(), + allowedMentions: model.SanitizeMentions, + messageReference: model.MessageReference); + + if (lastPage == 0 && _paginationBuilder.InteractionFunc is null) + return; + + await Task.WhenAll(left.RunAsync(msg), extra?.RunAsync(msg) ?? Task.CompletedTask, right.RunAsync(msg)); + + await Task.Delay(30_000); + + await msg.ModifyAsync(mp => mp.Components = new ComponentBuilder().Build()); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/ResponseBuilder.cs b/src/EllieBot/_common/Sender/ResponseBuilder.cs new file mode 100644 index 0000000..08d1ad3 --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseBuilder.cs @@ -0,0 +1,490 @@ +using System.Collections.ObjectModel; + +namespace EllieBot.Extensions; + +public sealed partial class ResponseBuilder +{ + private ICommandContext? ctx; + private IMessageChannel? channel; + private string? plainText; + private IReadOnlyCollection? embeds; + private IUserMessage? msg; + private IUser? user; + private bool sanitizeMentions = true; + private LocStr? locTxt; + private object[] locParams = []; + private bool shouldReply = true; + private readonly IBotStrings _bs; + private readonly BotConfigService _bcs; + private EmbedBuilder? embedBuilder; + private EllieInteractionBase? inter; + private Stream? fileStream; + private string? fileName; + private EmbedColor color = EmbedColor.Ok; + private LocStr? embedLocDesc; + + public DiscordSocketClient Client { get; set; } + + public ResponseBuilder(IBotStrings bs, BotConfigService bcs, DiscordSocketClient client) + { + _bs = bs; + _bcs = bcs; + Client = client; + } + + + private MessageReference? CreateMessageReference(IMessageChannel targetChannel) + { + if (!shouldReply) + return null; + + var replyTo = msg ?? ctx?.Message; + // what message are we replying to + if (replyTo is null) + return null; + + // we have to have a channel where we are sending the message in order to know whether we can reply to it + if (targetChannel.Id != replyTo.Channel.Id) + return null; + + return new(replyTo.Id, + replyTo.Channel.Id, + (replyTo.Channel as ITextChannel)?.GuildId, + failIfNotExists: false); + } + + public async Task BuildAsync(bool ephemeral) + { + var targetChannel = await InternalResolveChannel() ?? throw new ArgumentNullException(nameof(channel)); + var msgReference = CreateMessageReference(targetChannel); + + var txt = GetText(locTxt, targetChannel); + + if (embedLocDesc is LocStr ls) + { + InternalCreateEmbed(null, GetText(ls, targetChannel)); + } + + if (embedBuilder is not null) + PaintEmbedInternal(embedBuilder); + + var finalEmbed = embedBuilder?.Build(); + + + var buildModel = new ResponseMessageModel() + { + TargetChannel = targetChannel, + MessageReference = msgReference, + Text = txt, + User = user ?? ctx?.User, + Embed = finalEmbed, + Embeds = embeds?.Map(x => x.Build()), + SanitizeMentions = sanitizeMentions ? new(AllowedMentionTypes.Users) : AllowedMentions.All, + Ephemeral = ephemeral, + Interaction = inter + }; + + return buildModel; + } + + public async Task SendAsync(bool ephemeral = false) + { + var model = await BuildAsync(ephemeral); + var sentMsg = await SendAsync(model); + + + return sentMsg; + } + + public async Task SendAsync(ResponseMessageModel model) + { + IUserMessage sentMsg; + if (fileStream is Stream stream) + { + sentMsg = await model.TargetChannel.SendFileAsync(stream, + filename: fileName, + model.Text, + embed: model.Embed, + components: inter?.CreateComponent(), + allowedMentions: model.SanitizeMentions, + messageReference: model.MessageReference); + } + else + { + sentMsg = await model.TargetChannel.SendMessageAsync( + model.Text, + embed: model.Embed, + embeds: model.Embeds, + components: inter?.CreateComponent(), + allowedMentions: model.SanitizeMentions, + messageReference: model.MessageReference); + } + + if (model.Interaction is not null) + { + await model.Interaction.RunAsync(sentMsg); + } + + return sentMsg; + } + + private EmbedBuilder PaintEmbedInternal(EmbedBuilder eb) + => color switch + { + EmbedColor.Ok => eb.WithOkColor(), + EmbedColor.Pending => eb.WithPendingColor(), + EmbedColor.Error => eb.WithErrorColor(), + _ => throw new NotSupportedException() + }; + + private ulong? InternalResolveGuildId(IMessageChannel? targetChannel) + => ctx?.Guild?.Id ?? (targetChannel as ITextChannel)?.GuildId; + + private async Task InternalResolveChannel() + { + if (user is not null) + { + var ch = await user.CreateDMChannelAsync(); + + if (ch is not null) + { + return ch; + } + } + + return channel ?? ctx?.Channel ?? msg?.Channel; + } + + private string? GetText(LocStr? locStr, IMessageChannel targetChannel) + { + var guildId = InternalResolveGuildId(targetChannel); + return locStr is LocStr ls ? _bs.GetText(ls.Key, guildId, locParams) : plainText; + } + + private string GetText(LocStr locStr, IMessageChannel targetChannel) + { + var guildId = InternalResolveGuildId(targetChannel); + return _bs.GetText(locStr.Key, guildId, locStr.Params); + } + + public ResponseBuilder Text(LocStr str) + { + locTxt = str; + return this; + } + + public ResponseBuilder Text(SmartText text) + { + if (text is SmartPlainText spt) + plainText = spt.Text; + else if (text is SmartEmbedText set) + { + plainText = set.PlainText ?? plainText; + embedBuilder = set.GetEmbed(); + } + else if (text is SmartEmbedTextArray ser) + { + plainText = ser.Content ?? plainText; + embeds = ser.GetEmbedBuilders(); + } + + return this; + } + + private void InternalCreateEmbed( + string? title, + string text, + string? url = null, + string? footer = null) + { + var eb = new EllieEmbedBuilder(_bcs) + .WithDescription(text); + + if (!string.IsNullOrWhiteSpace(title)) + eb.WithTitle(title); + + if (!string.IsNullOrWhiteSpace(url)) + eb = eb.WithUrl(url); + + if (!string.IsNullOrWhiteSpace(footer)) + eb = eb.WithFooter(footer); + + embedBuilder = eb; + } + + public ResponseBuilder Confirm( + string? title, + string text, + string? url = null, + string? footer = null) + { + InternalCreateEmbed(title, text, url, footer); + color = EmbedColor.Ok; + return this; + } + + public ResponseBuilder Error( + string? title, + string text, + string? url = null, + string? footer = null) + { + InternalCreateEmbed(title, text, url, footer); + color = EmbedColor.Error; + return this; + } + + public ResponseBuilder Pending( + string? title, + string text, + string? url = null, + string? footer = null) + { + InternalCreateEmbed(title, text, url, footer); + color = EmbedColor.Pending; + return this; + } + + public ResponseBuilder Confirm(string text) + { + InternalCreateEmbed(null, text); + color = EmbedColor.Ok; + return this; + } + + public ResponseBuilder Confirm(LocStr str) + { + embedLocDesc = str; + color = EmbedColor.Ok; + return this; + } + + public ResponseBuilder Pending(string text) + { + InternalCreateEmbed(null, text); + color = EmbedColor.Pending; + return this; + } + + public ResponseBuilder Pending(LocStr str) + { + embedLocDesc = str; + color = EmbedColor.Pending; + return this; + } + + public ResponseBuilder Error(string text) + { + InternalCreateEmbed(null, text); + color = EmbedColor.Error; + return this; + } + + public ResponseBuilder Error(LocStr str) + { + embedLocDesc = str; + color = EmbedColor.Error; + return this; + } + + public ResponseBuilder UserBasedMentions() + { + sanitizeMentions = !((InternalResolveUser() as IGuildUser)?.GuildPermissions.MentionEveryone ?? false); + return this; + } + + private IUser? InternalResolveUser() + => ctx?.User ?? user ?? msg?.Author; + + public ResponseBuilder Embed(EmbedBuilder eb) + { + embedBuilder = eb; + return this; + } + + public ResponseBuilder Channel(IMessageChannel ch) + { + channel = ch; + return this; + } + + public ResponseBuilder Sanitize(bool shouldSantize = true) + { + sanitizeMentions = shouldSantize; + return this; + } + + public ResponseBuilder Context(ICommandContext context) + { + ctx = context; + return this; + } + + public ResponseBuilder Message(IUserMessage message) + { + msg = message; + return this; + } + + public ResponseBuilder User(IUser usr) + { + user = usr; + return this; + } + + public ResponseBuilder NoReply() + { + shouldReply = false; + return this; + } + + public ResponseBuilder Interaction(EllieInteractionBase? interaction) + { + inter = interaction; + return this; + } + + public ResponseBuilder Embeds(IReadOnlyCollection inputEmbeds) + { + embeds = inputEmbeds; + return this; + } + + public ResponseBuilder File(Stream stream, string name) + { + fileStream = stream; + fileName = name; + return this; + } + + public PaginatedResponseBuilder Paginated() + => new(this); +} + +public class PaginatedResponseBuilder +{ + protected readonly ResponseBuilder _builder; + + public PaginatedResponseBuilder(ResponseBuilder builder) + { + _builder = builder; + } + + public SourcedPaginatedResponseBuilder Items(IReadOnlyCollection items) + => new SourcedPaginatedResponseBuilder(_builder) + .Items(items); + + public SourcedPaginatedResponseBuilder PageItems(Func>> items) + => new SourcedPaginatedResponseBuilder(_builder) + .PageItems(items); +} + +public sealed class SourcedPaginatedResponseBuilder : PaginatedResponseBuilder +{ + private IReadOnlyCollection? items; + + public Func, int, Task> PageFunc { get; private set; } = static delegate + { + return Task.FromResult(new()); + }; + + public Func>> ItemsFunc { get; set; } = static delegate + { + return Task.FromResult>(ReadOnlyCollection.Empty); + }; + + public Func>? InteractionFunc { get; private set; } + + public int? Elems { get; private set; } = 1; + public int ItemsPerPage { get; private set; } = 9; + public bool AddPaginatedFooter { get; private set; } = true; + public bool IsEphemeral { get; private set; } + + public int InitialPage { get; set; } + + public SourcedPaginatedResponseBuilder(ResponseBuilder builder) + : base(builder) + { + } + + public SourcedPaginatedResponseBuilder Items(IReadOnlyCollection col) + { + items = col; + Elems = col.Count; + ItemsFunc = (i) => Task.FromResult(items.Skip(i * ItemsPerPage).Take(ItemsPerPage).ToArray() as IReadOnlyCollection); + return this; + } + + public SourcedPaginatedResponseBuilder TotalElements(int i) + { + Elems = i; + return this; + } + + public SourcedPaginatedResponseBuilder PageItems(Func>> func) + { + Elems = null; + ItemsFunc = func; + return this; + } + + + public SourcedPaginatedResponseBuilder PageSize(int i) + { + ItemsPerPage = i; + return this; + } + + public SourcedPaginatedResponseBuilder CurrentPage(int i) + { + InitialPage = i; + return this; + } + + + public SourcedPaginatedResponseBuilder Page(Func, int, EmbedBuilder> pageFunc) + { + PageFunc = (xs, x) => Task.FromResult(pageFunc(xs, x)); + return this; + } + + public SourcedPaginatedResponseBuilder Page(Func, int, Task> pageFunc) + { + PageFunc = pageFunc; + return this; + } + + public SourcedPaginatedResponseBuilder AddFooter(bool addFooter = true) + { + AddPaginatedFooter = addFooter; + return this; + } + + public SourcedPaginatedResponseBuilder Ephemeral() + { + IsEphemeral = true; + return this; + } + + + public Task SendAsync() + { + var paginationSender = new ResponseBuilder.PaginationSender( + this, + _builder); + + return paginationSender.SendAsync(IsEphemeral); + } + + public SourcedPaginatedResponseBuilder Interaction(Func> func) + { + InteractionFunc = func; //async (i) => await func(i); + return this; + } + + public SourcedPaginatedResponseBuilder Interaction(EllieInteractionBase inter) + { + InteractionFunc = _ => Task.FromResult(inter); + return this; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs b/src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs new file mode 100644 index 0000000..40123a6 --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs @@ -0,0 +1,28 @@ +namespace EllieBot.Extensions; + +public static class ResponseBuilderExtensions +{ + public static EmbedBuilder WithPendingColor(this EmbedBuilder eb) + { + if (eb is EllieEmbedBuilder neb) + return neb.WithPendingColor(); + + return eb; + } + + public static EmbedBuilder WithOkColor(this EmbedBuilder eb) + { + if (eb is EllieEmbedBuilder neb) + return neb.WithOkColor(); + + return eb; + } + + public static EmbedBuilder WithErrorColor(this EmbedBuilder eb) + { + if (eb is EllieEmbedBuilder neb) + return neb.WithErrorColor(); + + return eb; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/ResponseMessageModel.cs b/src/EllieBot/_common/Sender/ResponseMessageModel.cs new file mode 100644 index 0000000..675cd09 --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseMessageModel.cs @@ -0,0 +1,12 @@ +public class ResponseMessageModel +{ + public required IMessageChannel TargetChannel { get; set; } + public MessageReference? MessageReference { get; set; } + public string? Text { get; set; } + public Embed? Embed { get; set; } + public Embed[]? Embeds { get; set; } + public required AllowedMentions SanitizeMentions { get; set; } + public IUser? User { get; set; } + public bool Ephemeral { get; set; } + public EllieInteractionBase? Interaction { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/ServiceCollectionExtensions.cs b/src/EllieBot/_common/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..92a204d --- /dev/null +++ b/src/EllieBot/_common/ServiceCollectionExtensions.cs @@ -0,0 +1,133 @@ +using DryIoc; +using LinqToDB.Extensions; +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Modules.Music; +using EllieBot.Modules.Music.Resolvers; +using EllieBot.Modules.Music.Services; +using StackExchange.Redis; +using System.Net; +using System.Reflection; +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IContainer AddBotStringsServices(this IContainer svcs, BotCacheImplemenation botCache) + { + if (botCache == BotCacheImplemenation.Memory) + { + svcs.AddSingleton(); + svcs.AddSingleton(); + } + else + { + svcs.AddSingleton(); + svcs.AddSingleton(); + } + + svcs.AddSingleton(); + + return svcs; + } + + public static IContainer AddConfigServices(this IContainer svcs, Assembly a) + { + + foreach (var type in a.GetTypes() + .Where(x => !x.IsAbstract && x.IsAssignableToGenericType(typeof(ConfigServiceBase<>)))) + { + svcs.RegisterMany([type], + getServiceTypes: type => type.GetImplementedTypes(ReflectionTools.AsImplementedType.SourceType), + getImplFactory: type => ReflectionFactory.Of(type, Reuse.Singleton)); + } + + return svcs; + } + + + public static IContainer AddMusic(this IContainer svcs) + { + svcs.RegisterMany(Reuse.Singleton); + + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + + return svcs; + } + + public static IContainer AddCache(this IContainer cont, IBotCredentials creds) + { + if (creds.BotCache == BotCacheImplemenation.Redis) + { + var conf = ConfigurationOptions.Parse(creds.RedisOptions); + cont.AddSingleton(ConnectionMultiplexer.Connect(conf)); + cont.AddSingleton(); + cont.AddSingleton(); + } + else + { + cont.AddSingleton(); + cont.AddSingleton(); + } + + return cont + .AddBotStringsServices(creds.BotCache); + } + + public static IContainer AddHttpClients(this IContainer svcs) + { + IServiceCollection proxySvcs = new ServiceCollection(); + proxySvcs.AddHttpClient(); + proxySvcs.AddHttpClient("memelist") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = false + }); + + proxySvcs.AddHttpClient("google:search") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }); + + var prov = proxySvcs.BuildServiceProvider(); + + svcs.RegisterDelegate(_ => prov.GetRequiredService()); + svcs.RegisterDelegate(_ => prov.GetRequiredService()); + + return svcs; + } + + public static IContainer AddLifetimeServices(this IContainer svcs, Assembly a) + { + Type[] types = + [ + typeof(IExecOnMessage), + typeof(IExecPreCommand), + typeof(IExecPostCommand), + typeof(IExecNoCommand), + typeof(IInputTransformer), + typeof(IEService) + ]; + + foreach (var svc in a.GetTypes() + .Where(type => type.IsClass && types.Any(t => type.IsAssignableTo(t)) && !type.HasAttribute() +#if GLOBAL_NADEKO + && !type.HasAttribute() +#endif + )) + { + svcs.RegisterMany([svc], + getServiceTypes: type => type.GetImplementedTypes(ReflectionTools.AsImplementedType.SourceType), + getImplFactory: type => ReflectionFactory.Of(type, Reuse.Singleton)); + } + + return svcs; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/CommandHandler.cs b/src/EllieBot/_common/Services/CommandHandler.cs new file mode 100644 index 0000000..4a4e3cd --- /dev/null +++ b/src/EllieBot/_common/Services/CommandHandler.cs @@ -0,0 +1,432 @@ +#nullable disable +using EllieBot.Common.Configs; +using EllieBot.Common.ModuleBehaviors; +using ExecuteResult = Discord.Commands.ExecuteResult; +using PreconditionResult = Discord.Commands.PreconditionResult; + +namespace EllieBot.Services; + +public class CommandHandler : IEService, IReadyExecutor, ICommandHandler +{ + private const int GLOBAL_COMMANDS_COOLDOWN = 200; + + private const float ONE_THOUSANDTH = 1.0f / 1000; + + public event Func CommandExecuted = delegate { return Task.CompletedTask; }; + public event Func CommandErrored = delegate { return Task.CompletedTask; }; + + //userid/msg count + public ConcurrentDictionary UserMessagesSent { get; } = new(); + + public ConcurrentHashSet UsersOnShortCooldown { get; } = new(); + + private readonly DiscordSocketClient _client; + private readonly CommandService _commandService; + private readonly BotConfigService _bcs; + private readonly IBot _bot; + private readonly IBehaviorHandler _behaviorHandler; + private readonly IServiceProvider _services; + + private readonly ConcurrentDictionary _prefixes; + + private readonly DbService _db; + + private readonly BotConfig _bc; + // private readonly InteractionService _interactions; + + public CommandHandler( + DiscordSocketClient client, + DbService db, + CommandService commandService, + BotConfigService bcs, + IBot bot, + IBehaviorHandler behaviorHandler, + // InteractionService interactions, + IServiceProvider services) + { + _client = client; + _commandService = commandService; + _bc = bcs.Data; + _bcs = bcs; + _bot = bot; + _behaviorHandler = behaviorHandler; + _db = db; + _services = services; + // _interactions = interactions; + + _prefixes = bot.AllGuildConfigs.Where(x => x.Prefix is not null) + .ToDictionary(x => x.GuildId, x => x.Prefix) + .ToConcurrent(); + } + + public async Task OnReadyAsync() + { + // clear users on short cooldown every GLOBAL_COMMANDS_COOLDOWN miliseconds + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(GLOBAL_COMMANDS_COOLDOWN)); + while (await timer.WaitForNextTickAsync()) + UsersOnShortCooldown.Clear(); + } + + public string GetPrefix(IGuild guild) + => GetPrefix(guild?.Id); + + public string GetPrefix(ulong? id = null) + { + if (id is null || !_prefixes.TryGetValue(id.Value, out var prefix)) + return _bcs.Data.Prefix; + + return prefix; + } + + public string SetDefaultPrefix(string prefix) + { + if (string.IsNullOrWhiteSpace(prefix)) + throw new ArgumentNullException(nameof(prefix)); + + _bcs.ModifyConfig(bs => + { + bs.Prefix = prefix; + }); + + return prefix; + } + + public string SetPrefix(IGuild guild, string prefix) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(prefix); + ArgumentNullException.ThrowIfNull(guild); + + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guild.Id, set => set); + gc.Prefix = prefix; + uow.SaveChanges(); + } + + _prefixes[guild.Id] = prefix; + + return prefix; + } + + public async Task ExecuteExternal(ulong? guildId, ulong channelId, string commandText) + { + if (guildId is not null) + { + var guild = _client.GetGuild(guildId.Value); + if (guild?.GetChannel(channelId) is not SocketTextChannel channel) + { + Log.Warning("Channel for external execution not found"); + return; + } + + try + { + IUserMessage msg = await channel.SendMessageAsync(commandText); + msg = (IUserMessage)await channel.GetMessageAsync(msg.Id); + await TryRunCommand(guild, channel, msg); + //msg.DeleteAfter(5); + } + catch { } + } + } + + public Task StartHandling() + { + _client.MessageReceived += MessageReceivedHandler; + // _client.SlashCommandExecuted += SlashCommandExecuted; + return Task.CompletedTask; + } + + // private async Task SlashCommandExecuted(SocketSlashCommand arg) + // { + // var ctx = new SocketInteractionContext(_client, arg); + // await _interactions.ExecuteCommandAsync(ctx, _services); + // } + + private Task LogSuccessfulExecution(IUserMessage usrMsg, ITextChannel channel, params int[] execPoints) + { + if (_bcs.Data.ConsoleOutputType == ConsoleOutputType.Normal) + { + Log.Information(""" + Command Executed after {ExecTime}s + User: {User} + Server: {Server} + Channel: {Channel} + Message: {Message} + """, + string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))), + usrMsg.Author + " [" + usrMsg.Author.Id + "]", + channel is null ? "PRIVATE" : channel.Guild.Name + " [" + channel.Guild.Id + "]", + channel is null ? "PRIVATE" : channel.Name + " [" + channel.Id + "]", + usrMsg.Content); + } + else + { + Log.Information("Succ | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message}", + channel?.Guild.Id.ToString() ?? "-", + channel?.Id.ToString() ?? "-", + usrMsg.Author.Id, + usrMsg.Content.TrimTo(10)); + } + + return Task.CompletedTask; + } + + private void LogErroredExecution( + string errorMessage, + IUserMessage usrMsg, + ITextChannel channel, + params int[] execPoints) + { + if (_bcs.Data.ConsoleOutputType == ConsoleOutputType.Normal) + { + Log.Warning(""" + Command Errored after {ExecTime}s + User: {User} + Server: {Guild} + Channel: {Channel} + Message: {Message} + Error: {ErrorMessage} + """, + string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))), + usrMsg.Author + " [" + usrMsg.Author.Id + "]", + channel is null ? "DM" : channel.Guild.Name + " [" + channel.Guild.Id + "]", + channel is null ? "DM" : channel.Name + " [" + channel.Id + "]", + usrMsg.Content, + errorMessage); + } + else + { + Log.Warning(""" + Err | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message} + Err: {ErrorMessage} + """, + channel?.Guild.Id.ToString() ?? "-", + channel?.Id.ToString() ?? "-", + usrMsg.Author.Id, + usrMsg.Content.TrimTo(10), + errorMessage); + } + } + + private Task MessageReceivedHandler(SocketMessage msg) + { + if (!_bot.IsReady) + return Task.CompletedTask; + + if (_bc.IgnoreOtherBots) + { + if (msg.Author.IsBot) + return Task.CompletedTask; + } + else if (msg.Author.Id == _client.CurrentUser.Id) + return Task.CompletedTask; + + if (msg is not SocketUserMessage usrMsg) + return Task.CompletedTask; + + Task.Run(async () => + { + try + { +#if !GLOBAL_NADEKO + // track how many messages each user is sending + UserMessagesSent.AddOrUpdate(usrMsg.Author.Id, 1, (_, old) => ++old); +#endif + + var channel = msg.Channel; + var guild = (msg.Channel as SocketTextChannel)?.Guild; + + await TryRunCommand(guild, channel, usrMsg); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in CommandHandler"); + if (ex.InnerException is not null) + Log.Warning(ex.InnerException, "Inner Exception of the error in CommandHandler"); + } + }); + + return Task.CompletedTask; + } + + public async Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg) + { + var startTime = Environment.TickCount; + + var blocked = await _behaviorHandler.RunExecOnMessageAsync(guild, usrMsg); + if (blocked) + return; + + var blockTime = Environment.TickCount - startTime; + + var messageContent = await _behaviorHandler.RunInputTransformersAsync(guild, usrMsg); + + var prefix = GetPrefix(guild?.Id); + var isPrefixCommand = messageContent.StartsWith(".prefix", StringComparison.InvariantCultureIgnoreCase); + // execute the command and measure the time it took + if (isPrefixCommand || messageContent.StartsWith(prefix, StringComparison.InvariantCulture)) + { + var context = new CommandContext(_client, usrMsg); + var (success, error, info) = await ExecuteCommandAsync(context, + messageContent, + isPrefixCommand ? 1 : prefix.Length, + _services, + MultiMatchHandling.Best); + + startTime = Environment.TickCount - startTime; + + // if a command is found + if (info is not null) + { + // if it successfully executed + if (success) + { + await LogSuccessfulExecution(usrMsg, channel as ITextChannel, blockTime, startTime); + await CommandExecuted(usrMsg, info); + await _behaviorHandler.RunPostCommandAsync(context, info.Module.GetTopLevelModule().Name, info); + return; + } + + // if it errored + if (error is not null) + { + error = HumanizeError(error); + LogErroredExecution(error, usrMsg, channel as ITextChannel, blockTime, startTime); + + if (guild is not null) + await CommandErrored(info, channel as ITextChannel, error); + + return; + } + } + } + + await _behaviorHandler.RunOnNoCommandAsync(guild, usrMsg); + } + + private string HumanizeError(string error) + { + if (error.Contains("parse int", StringComparison.OrdinalIgnoreCase) + || error.Contains("parse float")) + return "Invalid number specified. Make sure you're specifying parameters in the correct order."; + + return error; + } + + public Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommandAsync( + ICommandContext context, + string input, + int argPos, + IServiceProvider serviceProvider, + MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + => ExecuteCommand(context, input[argPos..], serviceProvider, multiMatchHandling); + + + public async Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommand( + ICommandContext context, + string input, + IServiceProvider services, + MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + { + var searchResult = _commandService.Search(context, input); + if (!searchResult.IsSuccess) + return (false, null, null); + + var commands = searchResult.Commands; + var preconditionResults = new Dictionary(); + + foreach (var match in commands) + preconditionResults[match] = await match.Command.CheckPreconditionsAsync(context, services); + + var successfulPreconditions = preconditionResults.Where(x => x.Value.IsSuccess).ToArray(); + + if (successfulPreconditions.Length == 0) + { + //All preconditions failed, return the one from the highest priority command + var bestCandidate = preconditionResults.OrderByDescending(x => x.Key.Command.Priority) + .FirstOrDefault(x => !x.Value.IsSuccess); + return (false, bestCandidate.Value.ErrorReason, commands[0].Command); + } + + var parseResultsDict = new Dictionary(); + foreach (var pair in successfulPreconditions) + { + var parseResult = await pair.Key.ParseAsync(context, searchResult, pair.Value, services); + + if (parseResult.Error == CommandError.MultipleMatches) + { + IReadOnlyList argList, paramList; + switch (multiMatchHandling) + { + case MultiMatchHandling.Best: + argList = parseResult.ArgValues + .Map(x => x.Values.MaxBy(y => y.Score)); + paramList = parseResult.ParamValues + .Map(x => x.Values.MaxBy(y => y.Score)); + parseResult = ParseResult.FromSuccess(argList, paramList); + break; + } + } + + parseResultsDict[pair.Key] = parseResult; + } + + // Calculates the 'score' of a command given a parse result + float CalculateScore(CommandMatch match, ParseResult parseResult) + { + float argValuesScore = 0, paramValuesScore = 0; + + if (match.Command.Parameters.Count > 0) + { + var argValuesSum = + parseResult.ArgValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) + ?? 0; + var paramValuesSum = + parseResult.ParamValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) + ?? 0; + + argValuesScore = argValuesSum / match.Command.Parameters.Count; + paramValuesScore = paramValuesSum / match.Command.Parameters.Count; + } + + var totalArgsScore = (argValuesScore + paramValuesScore) / 2; + return match.Command.Priority + (totalArgsScore * 0.99f); + } + + //Order the parse results by their score so that we choose the most likely result to execute + var parseResults = parseResultsDict.OrderByDescending(x => CalculateScore(x.Key, x.Value)).ToList(); + + var successfulParses = parseResults.Where(x => x.Value.IsSuccess).ToArray(); + + if (successfulParses.Length == 0) + { + //All parses failed, return the one from the highest priority command, using score as a tie breaker + var bestMatch = parseResults.FirstOrDefault(x => !x.Value.IsSuccess); + return (false, bestMatch.Value.ErrorReason, commands[0].Command); + } + + var cmd = successfulParses[0].Key.Command; + + // Bot will ignore commands which are ran more often than what specified by + // GlobalCommandsCooldown constant (miliseconds) + if (!UsersOnShortCooldown.Add(context.Message.Author.Id)) + return (false, null, cmd); + //return SearchResult.FromError(CommandError.Exception, "You are on a global cooldown."); + + var blocked = await _behaviorHandler.RunPreCommandAsync(context, cmd); + if (blocked) + return (false, null, cmd); + + //If we get this far, at least one parse was successful. Execute the most likely overload. + var chosenOverload = successfulParses[0]; + var execResult = (ExecuteResult)await chosenOverload.Key.ExecuteAsync(context, chosenOverload.Value, services); + + if (execResult.Exception is not null + && (execResult.Exception is not HttpException he + || he.DiscordCode != DiscordErrorCode.InsufficientPermissions)) + Log.Warning(execResult.Exception, "Command Error"); + + return (true, null, cmd); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Currency/CurrencyService.cs b/src/EllieBot/_common/Services/Currency/CurrencyService.cs new file mode 100644 index 0000000..66029ff --- /dev/null +++ b/src/EllieBot/_common/Services/Currency/CurrencyService.cs @@ -0,0 +1,115 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; +using EllieBot.Services.Currency; + +namespace EllieBot.Services; + +public sealed class CurrencyService : ICurrencyService, IEService +{ + private readonly DbService _db; + private readonly ITxTracker _txTracker; + + public CurrencyService(DbService db, ITxTracker txTracker) + { + _db = db; + _txTracker = txTracker; + } + + public Task GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + return Task.FromResult(new DefaultWallet(userId, _db)); + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task AddBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData txData, + CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + { + foreach (var userId in userIds) + { + var wallet = await GetWalletAsync(userId); + await wallet.Add(amount, txData); + } + + return; + } + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task RemoveBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData txData, + CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + { + await using var ctx = _db.GetDbContext(); + await ctx + .GetTable() + .Where(x => userIds.Contains(x.UserId)) + .UpdateAsync(du => new() + { + CurrencyAmount = du.CurrencyAmount >= amount + ? du.CurrencyAmount - amount + : 0 + }); + await ctx.SaveChangesAsync(); + return; + } + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task AddAsync( + ulong userId, + long amount, + TxData txData) + { + var wallet = await GetWalletAsync(userId); + await wallet.Add(amount, txData); + await _txTracker.TrackAdd(amount, txData); + } + + public async Task AddAsync( + IUser user, + long amount, + TxData txData) + => await AddAsync(user.Id, amount, txData); + + public async Task RemoveAsync( + ulong userId, + long amount, + TxData txData) + { + if (amount == 0) + return true; + + var wallet = await GetWalletAsync(userId); + var result = await wallet.Take(amount, txData); + if (result) + await _txTracker.TrackRemove(amount, txData); + return result; + } + + public async Task RemoveAsync( + IUser user, + long amount, + TxData txData) + => await RemoveAsync(user.Id, amount, txData); + + public async Task> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9) + { + await using var uow = _db.GetDbContext(); + return await uow.Set().GetTopRichest(ignoreId, page, perPage); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs b/src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs new file mode 100644 index 0000000..7007ee4 --- /dev/null +++ b/src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs @@ -0,0 +1,48 @@ +using EllieBot.Services.Currency; + +namespace EllieBot.Services; + +public static class CurrencyServiceExtensions +{ + public static async Task GetBalanceAsync(this ICurrencyService cs, ulong userId) + { + var wallet = await cs.GetWalletAsync(userId); + return await wallet.GetBalance(); + } + + // FUTURE should be a transaction + public static async Task TransferAsync( + this ICurrencyService cs, + IMessageSenderService sender, + IUser from, + IUser to, + long amount, + string? note, + string formattedAmount) + { + var fromWallet = await cs.GetWalletAsync(from.Id); + var toWallet = await cs.GetWalletAsync(to.Id); + + var extra = new TxData("gift", from.ToString()!, note, from.Id); + + if (await fromWallet.Transfer(amount, toWallet, extra)) + { + try + { + await sender.Response(to) + .Confirm(string.IsNullOrWhiteSpace(note) + ? $"Received {formattedAmount} from {from} " + : $"Received {formattedAmount} from {from}: {note}") + .SendAsync(); + } + catch + { + //ignored + } + + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Currency/DefaultWallet.cs b/src/EllieBot/_common/Services/Currency/DefaultWallet.cs new file mode 100644 index 0000000..a556985 --- /dev/null +++ b/src/EllieBot/_common/Services/Currency/DefaultWallet.cs @@ -0,0 +1,108 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Services.Currency; + +public class DefaultWallet : IWallet +{ + private readonly DbService _db; + public ulong UserId { get; } + + public DefaultWallet(ulong userId, DbService db) + { + UserId = userId; + _db = db; + } + + public async Task GetBalance() + { + await using var ctx = _db.GetDbContext(); + var userId = UserId; + return await ctx + .GetTable() + .Where(x => x.UserId == userId) + .Select(x => x.CurrencyAmount) + .FirstOrDefaultAsync(); + } + + public async Task Take(long amount, TxData? txData) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount to take must be non negative."); + + await using var ctx = _db.GetDbContext(); + + var userId = UserId; + var changed = await ctx + .GetTable() + .Where(x => x.UserId == userId && x.CurrencyAmount >= amount) + .UpdateAsync(x => new() + { + CurrencyAmount = x.CurrencyAmount - amount + }); + + if (changed == 0) + return false; + + if (txData is not null) + { + await ctx + .GetTable() + .InsertAsync(() => new() + { + Amount = -amount, + Note = txData.Note, + UserId = userId, + Type = txData.Type, + Extra = txData.Extra, + OtherId = txData.OtherId, + DateAdded = DateTime.UtcNow + }); + } + + return true; + } + + public async Task Add(long amount, TxData? txData) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0."); + + await using var ctx = _db.GetDbContext(); + var userId = UserId; + + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Username = "Unknown", + Discriminator = "????", + CurrencyAmount = amount, + }, + (old) => new() + { + CurrencyAmount = old.CurrencyAmount + amount + }, + () => new() + { + UserId = userId + }); + + if (txData is not null) + { + await ctx.GetTable() + .InsertAsync(() => new() + { + Amount = amount, + UserId = userId, + Note = txData.Note, + Type = txData.Type, + Extra = txData.Extra, + OtherId = txData.OtherId, + DateAdded = DateTime.UtcNow + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs b/src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs new file mode 100644 index 0000000..5751281 --- /dev/null +++ b/src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs @@ -0,0 +1,110 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Services.Currency; +using EllieBot.Db.Models; + +namespace EllieBot.Services; + +public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor +{ + private static readonly IReadOnlySet _gamblingTypes = new HashSet(new[] + { + "lula", + "betroll", + "betflip", + "blackjack", + "betdraw", + "slot", + }); + + private ConcurrentDictionary _stats = new(); + + private readonly DbService _db; + + public GamblingTxTracker(DbService db) + { + _db = db; + } + + public async Task OnReadyAsync() + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + await using var ctx = _db.GetDbContext(); + await using var trans = await ctx.Database.BeginTransactionAsync(); + + try + { + var keys = _stats.Keys; + foreach (var key in keys) + { + if (_stats.TryRemove(key, out var stat)) + { + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + Feature = key, + Bet = stat.Bet, + PaidOut = stat.PaidOut, + DateAdded = DateTime.UtcNow + }, old => new() + { + Bet = old.Bet + stat.Bet, + PaidOut = old.PaidOut + stat.PaidOut, + }, () => new() + { + Feature = key + }); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "An error occurred in gambling tx tracker"); + } + finally + { + await trans.CommitAsync(); + } + } + } + + public Task TrackAdd(long amount, TxData? txData) + { + if (txData is null) + return Task.CompletedTask; + + if (_gamblingTypes.Contains(txData.Type)) + { + _stats.AddOrUpdate(txData.Type, + _ => (0, amount), + (_, old) => (old.Bet, old.PaidOut + amount)); + } + + return Task.CompletedTask; + } + + public Task TrackRemove(long amount, TxData? txData) + { + if (txData is null) + return Task.CompletedTask; + + if (_gamblingTypes.Contains(txData.Type)) + { + _stats.AddOrUpdate(txData.Type, + _ => (amount, 0), + (_, old) => (old.Bet + amount, old.PaidOut)); + } + + return Task.CompletedTask; + } + + public async Task> GetAllAsync() + { + await using var ctx = _db.GetDbContext(); + return await ctx.Set() + .ToListAsyncEF(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IBehaviourHandler.cs b/src/EllieBot/_common/Services/IBehaviourHandler.cs new file mode 100644 index 0000000..2f75074 --- /dev/null +++ b/src/EllieBot/_common/Services/IBehaviourHandler.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Services; + +public interface IBehaviorHandler +{ + Task AddAsync(ICustomBehavior behavior); + Task AddRangeAsync(IEnumerable behavior); + Task RemoveAsync(ICustomBehavior behavior); + Task RemoveRangeAsync(IEnumerable behs); + + Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg); + Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg); + Task RunPreCommandAsync(ICommandContext context, CommandInfo cmd); + ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd); + Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg); + void Initialize(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ICommandHandler.cs b/src/EllieBot/_common/Services/ICommandHandler.cs new file mode 100644 index 0000000..f838743 --- /dev/null +++ b/src/EllieBot/_common/Services/ICommandHandler.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Services; + +public interface ICommandHandler +{ + string GetPrefix(IGuild ctxGuild); + string GetPrefix(ulong? id = null); + string SetDefaultPrefix(string toSet); + string SetPrefix(IGuild ctxGuild, string toSet); + ConcurrentDictionary UserMessagesSent { get; } + + Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ICoordinator.cs b/src/EllieBot/_common/Services/ICoordinator.cs new file mode 100644 index 0000000..10ec4ae --- /dev/null +++ b/src/EllieBot/_common/Services/ICoordinator.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace EllieBot.Services; + +public interface ICoordinator +{ + bool RestartBot(); + void Die(bool graceful); + bool RestartShard(int shardId); + IList GetAllShardStatuses(); + int GetGuildCount(); + Task Reload(); +} + +public class ShardStatus +{ + public ConnectionState ConnectionState { get; set; } + public DateTime LastUpdate { get; set; } + public int ShardId { get; set; } + public int GuildCount { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ICustomBehavior.cs b/src/EllieBot/_common/Services/ICustomBehavior.cs new file mode 100644 index 0000000..2e4bedb --- /dev/null +++ b/src/EllieBot/_common/Services/ICustomBehavior.cs @@ -0,0 +1,13 @@ +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Services; + +public interface ICustomBehavior + : IExecOnMessage, + IInputTransformer, + IExecPreCommand, + IExecNoCommand, + IExecPostCommand +{ + +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IEService.cs b/src/EllieBot/_common/Services/IEService.cs new file mode 100644 index 0000000..944d8cc --- /dev/null +++ b/src/EllieBot/_common/Services/IEService.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// All services must implement this interface in order to be auto-discovered by the DI system +/// +public interface IEService +{ +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IGoogleApiService.cs b/src/EllieBot/_common/Services/IGoogleApiService.cs new file mode 100644 index 0000000..856bd51 --- /dev/null +++ b/src/EllieBot/_common/Services/IGoogleApiService.cs @@ -0,0 +1,18 @@ +#nullable disable +namespace EllieBot.Services; + +public interface IGoogleApiService +{ + IReadOnlyDictionary Languages { get; } + + Task> GetVideoLinksByKeywordAsync(string keywords, int count = 1); + Task> GetVideoInfosByKeywordAsync(string keywords, int count = 1); + Task> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1); + Task> GetRelatedVideosAsync(string id, int count = 1, string user = null); + Task> GetPlaylistTracksAsync(string playlistId, int count = 50); + Task> GetVideoDurationsAsync(IEnumerable videoIds); + Task Translate(string sourceText, string sourceLanguage, string targetLanguage); + + Task ShortenUrl(string url); + Task ShortenUrl(Uri url); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ILocalDataCache.cs b/src/EllieBot/_common/Services/ILocalDataCache.cs new file mode 100644 index 0000000..e6977e3 --- /dev/null +++ b/src/EllieBot/_common/Services/ILocalDataCache.cs @@ -0,0 +1,13 @@ +#nullable disable +using EllieBot.Common.Pokemon; +using EllieBot.Modules.Games.Common.Trivia; + +namespace EllieBot.Services; + +public interface ILocalDataCache +{ + Task> GetPokemonsAsync(); + Task> GetPokemonAbilitiesAsync(); + Task GetTriviaQuestionsAsync(); + Task> GetPokemonMapAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ILocalization.cs b/src/EllieBot/_common/Services/ILocalization.cs new file mode 100644 index 0000000..ab37be5 --- /dev/null +++ b/src/EllieBot/_common/Services/ILocalization.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Services; + +public interface ILocalization +{ + CultureInfo DefaultCultureInfo { get; } + IDictionary GuildCultureInfos { get; } + + CultureInfo GetCultureInfo(IGuild guild); + CultureInfo GetCultureInfo(ulong? guildId); + void RemoveGuildCulture(IGuild guild); + void RemoveGuildCulture(ulong guildId); + void ResetDefaultCulture(); + void SetDefaultCulture(CultureInfo ci); + void SetGuildCulture(IGuild guild, CultureInfo ci); + void SetGuildCulture(ulong guildId, CultureInfo ci); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IRemindService.cs b/src/EllieBot/_common/Services/IRemindService.cs new file mode 100644 index 0000000..5a057c8 --- /dev/null +++ b/src/EllieBot/_common/Services/IRemindService.cs @@ -0,0 +1,15 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility.Services; + +public interface IRemindService +{ + Task AddReminderAsync(ulong userId, + ulong targetId, + ulong? guildId, + bool isPrivate, + DateTime time, + string message, + ReminderType reminderType); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IStatsService.cs b/src/EllieBot/_common/Services/IStatsService.cs new file mode 100644 index 0000000..3dee0a6 --- /dev/null +++ b/src/EllieBot/_common/Services/IStatsService.cs @@ -0,0 +1,70 @@ +#nullable disable +namespace EllieBot.Services; + +public interface IStatsService +{ + /// + /// The author of the bot. + /// + string Author { get; } + + /// + /// The total amount of commands ran since startup. + /// + long CommandsRan { get; } + + /// + /// The amount of messages seen by the bot since startup. + /// + long MessageCounter { get; } + + /// + /// The rate of messages the bot sees every second. + /// + double MessagesPerSecond { get; } + + /// + /// The total amount of text channels the bot can see. + /// + long TextChannels { get; } + + /// + /// The total amount of voice channels the bot can see. + /// + long VoiceChannels { get; } + + /// + /// Gets for how long the bot has been up since startup. + /// + TimeSpan GetUptime(); + + /// + /// Gets a formatted string of how long the bot has been up since startup. + /// + /// The formatting separator. + string GetUptimeString(string separator = ", "); + + /// + /// Gets total amount of private memory currently in use by the bot, in Megabytes. + /// + double GetPrivateMemoryMegabytes(); + + GuildInfo GetGuildInfo(string name); + GuildInfo GetGuildInfo(ulong id); +} + +public record struct GuildInfo +{ + public required string Name { get; init; } + public required string IconUrl { get; init; } + public required string Owner { get; init; } + public required ulong OwnerId { get; init; } + public required ulong Id { get; init; } + public required int TextChannels { get; init; } + public required int VoiceChannels { get; init; } + public required DateTime CreatedAt { get; init; } + public required IReadOnlyList Features { get; init; } + public required IReadOnlyList Emojis { get; init; } + public required IReadOnlyList Roles { get; init; } + public int MemberCount { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ITimezoneService.cs b/src/EllieBot/_common/Services/ITimezoneService.cs new file mode 100644 index 0000000..e70b985 --- /dev/null +++ b/src/EllieBot/_common/Services/ITimezoneService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Common; + +public interface ITimezoneService +{ + TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs b/src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs new file mode 100644 index 0000000..7e471a0 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs @@ -0,0 +1,302 @@ +#nullable disable +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Services; + +// should be renamed to handler as it's not only executing +public sealed class BehaviorHandler : IBehaviorHandler +{ + private readonly IServiceProvider _services; + + private IReadOnlyCollection noCommandExecs; + private IReadOnlyCollection preCommandExecs; + private IReadOnlyCollection onMessageExecs; + private IReadOnlyCollection inputTransformers; + + private readonly SemaphoreSlim _customLock = new(1, 1); + private readonly List _customExecs = new(); + + public BehaviorHandler(IServiceProvider services) + { + _services = services; + } + + public void Initialize() + { + noCommandExecs = _services.GetServices().ToArray(); + preCommandExecs = _services.GetServices().OrderByDescending(x => x.Priority).ToArray(); + onMessageExecs = _services.GetServices().OrderByDescending(x => x.Priority).ToArray(); + inputTransformers = _services.GetServices().ToArray(); + } + + #region Add/Remove + + public async Task AddRangeAsync(IEnumerable execs) + { + await _customLock.WaitAsync(); + try + { + foreach (var exe in execs) + { + if (_customExecs.Contains(exe)) + continue; + + _customExecs.Add(exe); + } + } + finally + { + _customLock.Release(); + } + } + + public async Task AddAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + if (_customExecs.Contains(behavior)) + return false; + + _customExecs.Add(behavior); + return true; + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + return _customExecs.Remove(behavior); + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveRangeAsync(IEnumerable behs) + { + await _customLock.WaitAsync(); + try + { + foreach(var beh in behs) + _customExecs.Remove(beh); + } + finally + { + _customLock.Release(); + } + } + + #endregion + + #region Running + + public async Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs) + where T : IExecOnMessage + { + foreach (var exec in execs) + { + try + { + if (await exec.ExecOnMessageAsync(guild, usrMsg)) + { + Log.Information("{TypeName} blocked message g:{GuildId} u:{UserId} c:{ChannelId} msg:{Message}", + GetExecName(exec), + guild?.Id, + usrMsg.Author.Id, + usrMsg.Channel.Id, + usrMsg.Content?.TrimTo(10)); + + return true; + } + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} late blocker: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + + return false; + } + + if (await Exec(onMessageExecs)) + { + return true; + } + + await _customLock.WaitAsync(); + try + { + if (await Exec(_customExecs)) + return true; + } + finally + { + _customLock.Release(); + } + + return false; + } + + private string GetExecName(IBehavior exec) + => exec.Name; + + public async Task RunPreCommandAsync(ICommandContext ctx, CommandInfo cmd) + { + async Task Exec(IReadOnlyCollection execs) where T: IExecPreCommand + { + foreach (var exec in execs) + { + try + { + if (await exec.ExecPreCommandAsync(ctx, cmd.Module.GetTopLevelModule().Name, cmd)) + { + Log.Information("{TypeName} Pre-Command blocked [{User}] Command: [{Command}]", + GetExecName(exec), + ctx.User, + cmd.Aliases[0]); + return true; + } + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} PreCommand: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + + return false; + } + + if (await Exec(preCommandExecs)) + return true; + + await _customLock.WaitAsync(); + try + { + if (await Exec(_customExecs)) + return true; + } + finally + { + _customLock.Release(); + } + + return false; + } + + public async Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs) where T : IExecNoCommand + { + foreach (var exec in execs) + { + try + { + await exec.ExecOnNoCommandAsync(guild, usrMsg); + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} OnNoCommand: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + } + + await Exec(noCommandExecs); + + await _customLock.WaitAsync(); + try + { + await Exec(_customExecs); + } + finally + { + _customLock.Release(); + } + } + + public async Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs, string content) + where T : IInputTransformer + { + foreach (var exec in execs) + { + try + { + var newContent = await exec.TransformInput(guild, usrMsg.Channel, usrMsg.Author, content); + if (newContent is not null) + { + Log.Information("{ExecName} transformed content {OldContent} -> {NewContent}", + GetExecName(exec), + content, + newContent); + return newContent; + } + } + catch (Exception ex) + { + Log.Warning(ex, "An error occured during InputTransform handling: {ErrorMessage}", ex.Message); + } + } + + return null; + } + + var newContent = await Exec(inputTransformers, usrMsg.Content); + if (newContent is not null) + return newContent; + + await _customLock.WaitAsync(); + try + { + newContent = await Exec(_customExecs, usrMsg.Content); + if (newContent is not null) + return newContent; + } + finally + { + _customLock.Release(); + } + + return usrMsg.Content; + } + + public async ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd) + { + foreach (var exec in _customExecs) + { + try + { + await exec.ExecPostCommandAsync(ctx, moduleName, cmd.Name); + } + catch (Exception ex) + { + Log.Warning(ex, + "An error occured during PostCommand handling in {ExecName}: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + } + + #endregion +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/BlacklistService.cs b/src/EllieBot/_common/Services/Impl/BlacklistService.cs new file mode 100644 index 0000000..ac01491 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/BlacklistService.cs @@ -0,0 +1,141 @@ +#nullable disable +using LinqToDB; +using LinqToDB.Data; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Services; + +public sealed class BlacklistService : IExecOnMessage +{ + public int Priority + => int.MaxValue; + + private readonly DbService _db; + private readonly IPubSub _pubSub; + private readonly IBotCredentials _creds; + private IReadOnlyList blacklist; + + private readonly TypedKey _blPubKey = new("blacklist.reload"); + + public BlacklistService(DbService db, IPubSub pubSub, IBotCredentials creds) + { + _db = db; + _pubSub = pubSub; + _creds = creds; + + Reload(false); + _pubSub.Sub(_blPubKey, OnReload); + } + + private ValueTask OnReload(BlacklistEntry[] newBlacklist) + { + blacklist = newBlacklist; + return default; + } + + public Task ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg) + { + foreach (var bl in blacklist) + { + if (guild is not null && bl.Type == BlacklistType.Server && bl.ItemId == guild.Id) + { + Log.Information("Blocked input from blacklisted guild: {GuildName} [{GuildId}]", guild.Name, guild.Id); + + return Task.FromResult(true); + } + + if (bl.Type == BlacklistType.Channel && bl.ItemId == usrMsg.Channel.Id) + { + Log.Information("Blocked input from blacklisted channel: {ChannelName} [{ChannelId}]", + usrMsg.Channel.Name, + usrMsg.Channel.Id); + + return Task.FromResult(true); + } + + if (bl.Type == BlacklistType.User && bl.ItemId == usrMsg.Author.Id) + { + Log.Information("Blocked input from blacklisted user: {UserName} [{UserId}]", + usrMsg.Author.ToString(), + usrMsg.Author.Id); + + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + public IReadOnlyList GetBlacklist() + => blacklist; + + public void Reload(bool publish = true) + { + using var uow = _db.GetDbContext(); + var toPublish = uow.GetTable().ToArray(); + blacklist = toPublish; + if (publish) + _pubSub.Pub(_blPubKey, toPublish); + } + + public async Task Blacklist(BlacklistType type, ulong id) + { + if (_creds.OwnerIds.Contains(id)) + return; + + await using var uow = _db.GetDbContext(); + + await uow + .GetTable() + .InsertAsync(() => new() + { + ItemId = id, + Type = type, + }); + + if (type == BlacklistType.User) + { + await uow.GetTable() + .Where(x => x.UserId == id) + .UpdateAsync(_ => new() + { + CurrencyAmount = 0 + }); + } + + Reload(); + } + + public async Task UnBlacklist(BlacklistType type, ulong id) + { + await using var uow = _db.GetDbContext(); + await uow.GetTable() + .Where(bi => bi.ItemId == id && bi.Type == type) + .DeleteAsync(); + + Reload(); + } + + public async Task BlacklistUsers(IReadOnlyCollection toBlacklist) + { + await using var uow = _db.GetDbContext(); + var bc = uow.GetTable(); + await bc.BulkCopyAsync(toBlacklist.Select(uid => new BlacklistEntry + { + ItemId = uid, + Type = BlacklistType.User + })); + + var blList = toBlacklist.ToList(); + await uow.GetTable() + .Where(x => blList.Contains(x.UserId)) + .UpdateAsync(_ => new() + { + CurrencyAmount = 0 + }); + + Reload(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs b/src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs new file mode 100644 index 0000000..ed57096 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs @@ -0,0 +1,184 @@ +using CommandLine; +using Ellie.Common.Marmalade; + +namespace EllieBot.Common; + +public sealed class CommandsUtilityService : ICommandsUtilityService, IEService +{ + private readonly CommandHandler _ch; + private readonly IBotStrings _strings; + private readonly DiscordPermOverrideService _dpos; + private readonly IMessageSenderService _sender; + private readonly ILocalization _loc; + private readonly IMarmaladeLoaderService _marmalades; + + public CommandsUtilityService( + CommandHandler ch, + IBotStrings strings, + DiscordPermOverrideService dpos, + IMessageSenderService sender, + ILocalization loc, + IMarmaladeLoaderService marmalades) + { + _ch = ch; + _strings = strings; + _dpos = dpos; + _sender = sender; + _loc = loc; + _marmalades = marmalades; + } + + public EmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild) + { + var prefix = _ch.GetPrefix(guild); + + var str = $"**`{prefix + com.Aliases.First()}`**"; + var alias = com.Aliases.Skip(1).FirstOrDefault(); + if (alias is not null) + str += $" **| `{prefix + alias}`**"; + + var culture = _loc.GetCultureInfo(guild); + + var em = _sender.CreateEmbed() + .AddField(str, $"{com.RealSummary(_strings, _marmalades, culture, prefix)}", true); + + _dpos.TryGetOverrides(guild?.Id ?? 0, com.Name, out var overrides); + var reqs = GetCommandRequirements(com, (GuildPermission?)overrides); + if (reqs.Any()) + em.AddField(GetText(strs.requires, guild), string.Join("\n", reqs)); + + var paramList = _strings.GetCommandStrings(com.Name, culture)?.Params; + em + .WithOkColor() + .AddField(_strings.GetText(strs.usage), + string.Join("\n", com.RealRemarksArr(_strings, _marmalades, culture, prefix).Map(arg => Format.Code(arg)))) + .WithFooter(GetText(strs.module(com.Module.GetTopLevelModule().Name), guild)); + + if (paramList is not null and not []) + { + var pl = paramList + .Select(x => Format.Code($"{prefix}{com.Name} {x.Keys.Select(y => $"<{y}>").Join(' ')}")) + .Join('\n'); + + em.AddField(GetText(strs.overloads, guild), pl); + } + + var opt = GetEllieOptionType(com.Attributes); + if (opt is not null) + { + var hs = GetCommandOptionHelp(opt); + if (!string.IsNullOrWhiteSpace(hs)) + em.AddField(GetText(strs.options, guild), hs); + } + + return em; + } + + public static string GetCommandOptionHelp(Type opt) + { + var strs = GetCommandOptionHelpList(opt); + + return string.Join("\n", strs); + } + + public static List GetCommandOptionHelpList(Type opt) + { + var strs = opt.GetProperties() + .Select(x => x.GetCustomAttributes(true).FirstOrDefault(a => a is OptionAttribute)) + .Where(x => x is not null) + .Cast() + .Select(x => + { + var toReturn = $"`--{x.LongName}`"; + + if (!string.IsNullOrWhiteSpace(x.ShortName)) + toReturn += $" (`-{x.ShortName}`)"; + + toReturn += $" {x.HelpText} "; + return toReturn; + }) + .ToList(); + + return strs; + } + + public static Type? GetEllieOptionType(IEnumerable attributes) + => attributes + .Select(a => a.GetType()) + .Where(a => a.IsGenericType + && a.GetGenericTypeDefinition() == typeof(EllieOptionsAttribute<>)) + .Select(a => a.GenericTypeArguments[0]) + .FirstOrDefault(); + + public static string[] GetCommandRequirements(CommandInfo cmd, GuildPerm? overrides = null) + { + var toReturn = new List(); + + if (cmd.Preconditions.Any(x => x is OwnerOnlyAttribute)) + toReturn.Add("Bot Owner Only"); + + if (cmd.Preconditions.Any(x => x is NoPublicBotAttribute) + || cmd.Module + .Preconditions + .Any(x => x is NoPublicBotAttribute) + || cmd.Module.GetTopLevelModule() + .Preconditions + .Any(x => x is NoPublicBotAttribute)) + toReturn.Add("No Public Bot"); + + if (cmd.Preconditions + .Any(x => x is OnlyPublicBotAttribute) + || cmd.Module + .Preconditions + .Any(x => x is OnlyPublicBotAttribute) + || cmd.Module.GetTopLevelModule() + .Preconditions + .Any(x => x is OnlyPublicBotAttribute)) + toReturn.Add("Only Public Bot"); + + var userPermString = cmd.Preconditions + .Where(ca => ca is UserPermAttribute) + .Cast() + .Select(userPerm => + { + if (userPerm.ChannelPermission is { } cPerm) + return GetPreconditionString(cPerm); + + if (userPerm.GuildPermission is { } gPerm) + return GetPreconditionString(gPerm); + + return string.Empty; + }) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Join('\n'); + + if (overrides is null) + { + if (!string.IsNullOrWhiteSpace(userPermString)) + toReturn.Add(userPermString); + } + else + { + if (!string.IsNullOrWhiteSpace(userPermString)) + toReturn.Add(Format.Strikethrough(userPermString)); + + toReturn.Add(GetPreconditionString(overrides.Value)); + } + + return toReturn.ToArray(); + } + + public static string GetPreconditionString(ChannelPerm perm) + => (perm + " Channel Permission").Replace("Guild", "Server"); + + public static string GetPreconditionString(GuildPerm perm) + => (perm + " Server Permission").Replace("Guild", "Server"); + + public string GetText(LocStr str, IGuild? guild) + => _strings.GetText(str, guild?.Id); +} + +public interface ICommandsUtilityService +{ + EmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs b/src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs new file mode 100644 index 0000000..2eb7093 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs @@ -0,0 +1,136 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Services; + +public class DiscordPermOverrideService : IEService, IExecPreCommand, IDiscordPermOverrideService +{ + public int Priority { get; } = int.MaxValue; + private readonly DbService _db; + private readonly IServiceProvider _services; + + private readonly ConcurrentDictionary<(ulong, string), DiscordPermOverride> _overrides; + + public DiscordPermOverrideService(DbService db, IServiceProvider services) + { + _db = db; + _services = services; + using var uow = _db.GetDbContext(); + _overrides = uow.Set() + .AsNoTracking() + .AsEnumerable() + .ToDictionary(o => (o.GuildId ?? 0, o.Command), o => o) + .ToConcurrent(); + } + + public bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm) + { + commandName = commandName.ToLowerInvariant(); + if (_overrides.TryGetValue((guildId, commandName), out var dpo)) + { + perm = dpo.Perm; + return true; + } + + perm = null; + return false; + } + + public Task ExecuteOverrides( + ICommandContext ctx, + CommandInfo command, + GuildPerm perms, + IServiceProvider services) + { + var rupa = new RequireUserPermissionAttribute(perms); + return rupa.CheckPermissionsAsync(ctx, command, services); + } + + public async Task AddOverride(ulong guildId, string commandName, GuildPerm perm) + { + commandName = commandName.ToLowerInvariant(); + await using var uow = _db.GetDbContext(); + var over = await uow.Set() + .AsQueryable() + .FirstOrDefaultAsync(x => x.GuildId == guildId && commandName == x.Command); + + if (over is null) + { + uow.Set() + .Add(over = new() + { + Command = commandName, + Perm = (EllieBot.Db.GuildPerm)perm, + GuildId = guildId + }); + } + else + over.Perm = (EllieBot.Db.GuildPerm)perm; + + _overrides[(guildId, commandName)] = over; + + await uow.SaveChangesAsync(); + } + + public async Task ClearAllOverrides(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var overrides = await uow.Set() + .AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + + uow.RemoveRange(overrides); + await uow.SaveChangesAsync(); + + foreach (var over in overrides) + _overrides.TryRemove((guildId, over.Command), out _); + } + + public async Task RemoveOverride(ulong guildId, string commandName) + { + commandName = commandName.ToLowerInvariant(); + + await using var uow = _db.GetDbContext(); + var over = await uow.Set() + .AsQueryable() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.GuildId == guildId && x.Command == commandName); + + if (over is null) + return; + + uow.Remove(over); + await uow.SaveChangesAsync(); + + _overrides.TryRemove((guildId, commandName), out _); + } + + public async Task> GetAllOverrides(ulong guildId) + { + await using var uow = _db.GetDbContext(); + return await uow.Set() + .AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + } + + public async Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + { + if (TryGetOverrides(context.Guild?.Id ?? 0, command.Name, out var perm) && perm is not null) + { + var result = + await new RequireUserPermissionAttribute((GuildPermission)perm).CheckPermissionsAsync(context, + command, + _services); + + return !result.IsSuccess; + } + + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/FontProvider.cs b/src/EllieBot/_common/Services/Impl/FontProvider.cs new file mode 100644 index 0000000..2bead90 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/FontProvider.cs @@ -0,0 +1,54 @@ +#nullable disable +using SixLabors.Fonts; + +namespace EllieBot.Services; + +public class FontProvider : IEService +{ + public FontFamily DottyFont { get; } + + public FontFamily UniSans { get; } + + public FontFamily NotoSans { get; } + //public FontFamily Emojis { get; } + + public List FallBackFonts { get; } + private readonly FontCollection _fonts; + + public FontProvider() + { + _fonts = new(); + + NotoSans = _fonts.Add("data/fonts/NotoSans-Bold.ttf"); + UniSans = _fonts.Add("data/fonts/Uni Sans.ttf"); + + FallBackFonts = new(); + + //FallBackFonts.Add(_fonts.Install("data/fonts/OpenSansEmoji.ttf")); + + // try loading some emoji and jap fonts on windows as fallback fonts + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + try + { + var fontsfolder = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + FallBackFonts.Add(_fonts.Add(Path.Combine(fontsfolder, "seguiemj.ttf"))); + FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "msgothic.ttc"))); + FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "segoe.ttc"))); + } + catch { } + } + + // any fonts present in data/fonts should be added as fallback fonts + // this will allow support for special characters when drawing text + foreach (var font in Directory.GetFiles(@"data/fonts")) + { + if (font.EndsWith(".ttf")) + FallBackFonts.Add(_fonts.Add(font)); + else if (font.EndsWith(".ttc")) + FallBackFonts.AddRange(_fonts.AddCollection(font)); + } + + DottyFont = FallBackFonts.First(x => x.Name == "dotty"); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/IImageCache.cs b/src/EllieBot/_common/Services/Impl/IImageCache.cs new file mode 100644 index 0000000..5a7e01c --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/IImageCache.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Services; + +public interface IImageCache +{ + Task GetHeadsImageAsync(); + Task GetTailsImageAsync(); + Task GetCurrencyImageAsync(); + Task GetXpBackgroundImageAsync(); + Task GetDiceAsync(int num); + Task GetSlotEmojiAsync(int number); + Task GetSlotBgAsync(); + Task GetImageDataAsync(Uri url); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/ImagesConfig.cs b/src/EllieBot/_common/Services/Impl/ImagesConfig.cs new file mode 100644 index 0000000..a730a94 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/ImagesConfig.cs @@ -0,0 +1,31 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Services; + +public sealed class ImagesConfig : ConfigServiceBase +{ + private const string PATH = "data/images.yml"; + + private static readonly TypedKey _changeKey = + new("config.images.updated"); + + public override string Name + => "images"; + + public ImagesConfig(IConfigSeria serializer, IPubSub pubSub) + : base(PATH, serializer, pubSub, _changeKey) + { + Migrate(); + } + + private void Migrate() + { + if (data.Version < 5) + { + ModifyConfig(c => + { + c.Version = 5; + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs b/src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs new file mode 100644 index 0000000..79d501b --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Services; + +public static class RedisImageExtensions +{ + private const string OLD_CDN_URL = "nadeko-pictures.nyc3.digitaloceanspaces.com"; + private const string NEW_CDN_URL = "cdn.nadeko.bot"; + + public static Uri ToNewCdn(this Uri uri) + => new(uri.ToString().Replace(OLD_CDN_URL, NEW_CDN_URL)); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs b/src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs new file mode 100644 index 0000000..0784fc7 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs @@ -0,0 +1,58 @@ +#nullable disable +using System.Diagnostics; + +namespace EllieBot.Services; + +public class SingleProcessCoordinator : ICoordinator +{ + private readonly IBotCredentials _creds; + private readonly DiscordSocketClient _client; + + public SingleProcessCoordinator(IBotCredentials creds, DiscordSocketClient client) + { + _creds = creds; + _client = client; + } + + public bool RestartBot() + { + if (string.IsNullOrWhiteSpace(_creds.RestartCommand?.Cmd) + || string.IsNullOrWhiteSpace(_creds.RestartCommand?.Args)) + { + Log.Error("You must set RestartCommand.Cmd and RestartCommand.Args in creds.yml"); + return false; + } + + Process.Start(_creds.RestartCommand.Cmd, _creds.RestartCommand.Args); + _ = Task.Run(async () => + { + await Task.Delay(2000); + Die(); + }); + return true; + } + + public void Die(bool graceful = false) + => Environment.Exit(5); + + public bool RestartShard(int shardId) + => RestartBot(); + + public IList GetAllShardStatuses() + => new[] + { + new ShardStatus + { + ConnectionState = _client.ConnectionState, + GuildCount = _client.Guilds.Count, + LastUpdate = DateTime.UtcNow, + ShardId = _client.ShardId + } + }; + + public int GetGuildCount() + => _client.Guilds.Count; + + public Task Reload() + => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs b/src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs new file mode 100644 index 0000000..3d102cc --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs @@ -0,0 +1,18 @@ +#nullable disable +using System.Collections; + +namespace EllieBot.Services; + +public class StartingGuildsService : IEnumerable, IEService +{ + private readonly IReadOnlyList _guilds; + + public StartingGuildsService(DiscordSocketClient client) + => _guilds = client.Guilds.Select(x => x.Id).ToList(); + + public IEnumerator GetEnumerator() + => _guilds.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => _guilds.GetEnumerator(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/StatsService.cs b/src/EllieBot/_common/Services/Impl/StatsService.cs new file mode 100644 index 0000000..9387e51 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/StatsService.cs @@ -0,0 +1,206 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using System.Diagnostics; + +namespace EllieBot.Services; + +public sealed class StatsService : IStatsService, IReadyExecutor, IEService +{ + public static string BotVersion + => typeof(Bot).Assembly.GetName().Version?.ToString(3) ?? "Custom"; + + public string Author + => "toastie_t0ast"; + + public double MessagesPerSecond + => MessageCounter / GetUptime().TotalSeconds; + + public long TextChannels + => Interlocked.Read(ref textChannels); + + public long VoiceChannels + => Interlocked.Read(ref voiceChannels); + + public long MessageCounter + => Interlocked.Read(ref messageCounter); + + public long CommandsRan + => Interlocked.Read(ref commandsRan); + + private readonly Process _currentProcess = Process.GetCurrentProcess(); + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + private readonly DateTime _started; + + private long textChannels; + private long voiceChannels; + private long messageCounter; + private long commandsRan; + + private readonly IHttpClientFactory _httpFactory; + + public StatsService( + DiscordSocketClient client, + CommandHandler cmdHandler, + IBotCredentials creds, + IHttpClientFactory factory) + { + _client = client; + _creds = creds; + _httpFactory = factory; + + _started = DateTime.UtcNow; + _client.MessageReceived += _ => Task.FromResult(Interlocked.Increment(ref messageCounter)); + cmdHandler.CommandExecuted += (_, _) => Task.FromResult(Interlocked.Increment(ref commandsRan)); + + _client.ChannelCreated += c => + { + if (c is IVoiceChannel) + Interlocked.Increment(ref voiceChannels); + else if (c is ITextChannel) + Interlocked.Increment(ref textChannels); + + return Task.CompletedTask; + }; + + _client.ChannelDestroyed += c => + { + if (c is IVoiceChannel) + Interlocked.Decrement(ref voiceChannels); + else if (c is ITextChannel) + Interlocked.Decrement(ref textChannels); + + return Task.CompletedTask; + }; + + _client.GuildAvailable += g => + { + var tc = g.Channels.Count(cx => cx is ITextChannel and not IVoiceChannel); + var vc = g.Channels.Count(cx => cx is IVoiceChannel); + Interlocked.Add(ref textChannels, tc); + Interlocked.Add(ref voiceChannels, vc); + + return Task.CompletedTask; + }; + + _client.JoinedGuild += g => + { + var tc = g.Channels.Count(cx => cx is ITextChannel and not IVoiceChannel); + var vc = g.Channels.Count(cx => cx is IVoiceChannel); + Interlocked.Add(ref textChannels, tc); + Interlocked.Add(ref voiceChannels, vc); + + return Task.CompletedTask; + }; + + _client.GuildUnavailable += g => + { + var tc = g.Channels.Count(cx => cx is ITextChannel and not IVoiceChannel); + var vc = g.Channels.Count(cx => cx is IVoiceChannel); + Interlocked.Add(ref textChannels, -tc); + Interlocked.Add(ref voiceChannels, -vc); + + return Task.CompletedTask; + }; + + _client.LeftGuild += g => + { + var tc = g.Channels.Count(cx => cx is ITextChannel and not IVoiceChannel); + var vc = g.Channels.Count(cx => cx is IVoiceChannel); + Interlocked.Add(ref textChannels, -tc); + Interlocked.Add(ref voiceChannels, -vc); + + return Task.CompletedTask; + }; + } + + private void InitializeChannelCount() + { + var guilds = _client.Guilds; + textChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is ITextChannel and not IVoiceChannel)); + voiceChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is IVoiceChannel)); + } + + public async Task OnReadyAsync() + { + InitializeChannelCount(); + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + do + { + if (string.IsNullOrWhiteSpace(_creds.BotListToken)) + continue; + + try + { + using var http = _httpFactory.CreateClient(); + using var content = new FormUrlEncodedContent(new Dictionary + { + { "shard_count", _creds.TotalShards.ToString() }, + { "shard_id", _client.ShardId.ToString() }, + { "server_count", _client.Guilds.Count().ToString() } + }); + content.Headers.Clear(); + content.Headers.Add("Content-Type", "application/x-www-form-urlencoded"); + http.DefaultRequestHeaders.Add("Authorization", _creds.BotListToken); + + using var res = await http.PostAsync( + new Uri($"https://discordbots.org/api/bots/{_client.CurrentUser.Id}/stats"), + content); + } + catch (Exception ex) + { + Log.Error(ex, "Error in botlist post"); + } + } while (await timer.WaitForNextTickAsync()); + } + + public TimeSpan GetUptime() + => DateTime.UtcNow - _started; + + public string GetUptimeString(string separator = ", ") + { + var time = GetUptime(); + + if (time.Days > 0) + return $"{time.Days}d {time.Hours}h {time.Minutes}m"; + + if (time.Hours > 0) + return $"{time.Hours}h {time.Minutes}m"; + + if (time.Minutes > 0) + return $"{time.Minutes}m {time.Seconds}s"; + + return $"{time.Seconds}s"; + } + + public double GetPrivateMemoryMegabytes() + { + _currentProcess.Refresh(); + return _currentProcess.PrivateMemorySize64 / 1.Megabytes(); + } + + public GuildInfo GetGuildInfo(string name) + => throw new NotImplementedException(); + + public GuildInfo GetGuildInfo(ulong id) + { + var g = _client.GetGuild(id); + + return new GuildInfo() + { + Id = g.Id, + IconUrl = g.IconUrl, + Name = g.Name, + Owner = g.Owner.Username, + OwnerId = g.OwnerId, + CreatedAt = g.CreatedAt.UtcDateTime, + VoiceChannels = g.VoiceChannels.Count, + TextChannels = g.TextChannels.Count, + Features = g.Features.Value.ToString().Split(","), + Emojis = g.Emotes.ToArray(), + Roles = g.Roles.OrderByDescending(x => x.Position).ToArray(), + MemberCount = g.MemberCount, + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/YtdlOperation.cs b/src/EllieBot/_common/Services/Impl/YtdlOperation.cs new file mode 100644 index 0000000..3813b80 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/YtdlOperation.cs @@ -0,0 +1,77 @@ +#nullable disable +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace EllieBot.Services; + +public class YtdlOperation +{ + private readonly string _baseArgString; + private readonly bool _isYtDlp; + + public YtdlOperation(string baseArgString, bool isYtDlp = false) + { + _baseArgString = baseArgString; + _isYtDlp = isYtDlp; + } + + private Process CreateProcess(string[] args) + { + var newArgs = args.Map(arg => (object)arg.Replace("\"", "")); + return new() + { + StartInfo = new() + { + FileName = _isYtDlp ? "yt-dlp" : "youtube-dl", + Arguments = string.Format(_baseArgString, newArgs), + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true + } + }; + } + + public async Task GetDataAsync(params string[] args) + { + try + { + using var process = CreateProcess(args); + + Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + process.Start(); + + var str = await process.StandardOutput.ReadToEndAsync(); + var err = await process.StandardError.ReadToEndAsync(); + if (!string.IsNullOrEmpty(err)) + Log.Warning("YTDL warning: {YtdlWarning}", err); + + return str; + } + catch (Win32Exception) + { + Log.Error("youtube-dl is likely not installed. Please install it before running the command again"); + return default; + } + catch (Exception ex) + { + Log.Error(ex, "Exception running youtube-dl: {ErrorMessage}", ex.Message); + return default; + } + } + + public async IAsyncEnumerable EnumerateDataAsync(params string[] args) + { + using var process = CreateProcess(args); + + Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + process.Start(); + + string line; + while ((line = await process.StandardOutput.ReadLineAsync()) is not null) + yield return line; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/strings/impl/BotStrings.cs b/src/EllieBot/_common/Services/strings/impl/BotStrings.cs new file mode 100644 index 0000000..45bbd6e --- /dev/null +++ b/src/EllieBot/_common/Services/strings/impl/BotStrings.cs @@ -0,0 +1,102 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Services; + +public class BotStrings : IBotStrings +{ + /// + /// Used as failsafe in case response key doesn't exist in the selected or default language. + /// + private readonly CultureInfo _usCultureInfo = new("en-US"); + + private readonly ILocalization _localization; + private readonly IBotStringsProvider _stringsProvider; + + public BotStrings(ILocalization loc, IBotStringsProvider stringsProvider) + { + _localization = loc; + _stringsProvider = stringsProvider; + } + + private string GetString(string key, CultureInfo cultureInfo) + => _stringsProvider.GetText(cultureInfo.Name, key); + + public string GetText(string key, ulong? guildId = null, params object[] data) + => GetText(key, _localization.GetCultureInfo(guildId), data); + + public string GetText(string key, CultureInfo cultureInfo) + { + var text = GetString(key, cultureInfo); + + if (string.IsNullOrWhiteSpace(text)) + { + Log.Warning("'{Key}' key is missing from '{LanguageName}' response strings. You may ignore this message", + key, + cultureInfo.Name); + text = GetString(key, _usCultureInfo) ?? $"Error: dkey {key} not found!"; + if (string.IsNullOrWhiteSpace(text)) + { + return + "I can't tell you if the command is executed, because there was an error printing out the response." + + $" Key '{key}' is missing from resources. You may ignore this message."; + } + } + + return text; + } + + public string GetText(string key, CultureInfo cultureInfo, params object[] data) + { + try + { + return string.Format(GetText(key, cultureInfo), data); + } + catch (FormatException) + { + Log.Warning( + " Key '{Key}' is not properly formatted in '{LanguageName}' response strings. Please report this", + key, + cultureInfo.Name); + if (cultureInfo.Name != _usCultureInfo.Name) + return GetText(key, _usCultureInfo, data); + return + "I can't tell you if the command is executed, because there was an error printing out the response.\n" + + $"Key '{key}' is not properly formatted. Please report this."; + } + } + + public CommandStrings GetCommandStrings(string commandName, ulong? guildId = null) + => GetCommandStrings(commandName, _localization.GetCultureInfo(guildId)); + + public CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo) + { + var cmdStrings = _stringsProvider.GetCommandStrings(cultureInfo.Name, commandName); + if (cmdStrings is null) + { + if (cultureInfo.Name == _usCultureInfo.Name) + { + Log.Warning("'{CommandName}' doesn't exist in 'en-US' command strings. Please report this", + commandName); + + return new CommandStrings() + { + Examples = [""], + Desc = "?", + Params = [] + }; + } + +// Log.Warning(@"'{CommandName}' command strings don't exist in '{LanguageName}' culture. +// This message is safe to ignore, however you can ask in Ellie support server how you can contribute command translations", +// commandName, cultureInfo.Name); + + return GetCommandStrings(commandName, _usCultureInfo); + } + + return cmdStrings; + } + + public void Reload() + => _stringsProvider.Reload(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs b/src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs new file mode 100644 index 0000000..12b6ba9 --- /dev/null +++ b/src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs @@ -0,0 +1,73 @@ +#nullable disable +using Newtonsoft.Json; +using YamlDotNet.Serialization; + +namespace EllieBot.Services; + +/// +/// Loads strings from the local default filepath +/// +public class LocalFileStringsSource : IStringsSource +{ + private readonly string _responsesPath = "data/strings/responses"; + private readonly string _commandsPath = "data/strings/commands"; + + public LocalFileStringsSource( + string responsesPath = "data/strings/responses", + string commandsPath = "data/strings/commands") + { + _responsesPath = responsesPath; + _commandsPath = commandsPath; + } + + public Dictionary> GetResponseStrings() + { + var outputDict = new Dictionary>(); + foreach (var file in Directory.GetFiles(_responsesPath)) + { + try + { + var langDict = JsonConvert.DeserializeObject>(File.ReadAllText(file)); + var localeName = GetLocaleName(file); + outputDict[localeName] = langDict; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} response strings: {ErrorMessage}", file, ex.Message); + } + } + + return outputDict; + } + + public Dictionary> GetCommandStrings() + { + var deserializer = new DeserializerBuilder().Build(); + + var outputDict = new Dictionary>(); + foreach (var file in Directory.GetFiles(_commandsPath)) + { + try + { + var text = File.ReadAllText(file); + var langDict = deserializer.Deserialize>(text); + var localeName = GetLocaleName(file); + outputDict[localeName] = langDict; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} command strings: {ErrorMessage}", file, ex.Message); + } + } + + return outputDict; + } + + private static string GetLocaleName(string fileName) + { + fileName = Path.GetFileName(fileName); + var dotIndex = fileName.IndexOf('.') + 1; + var secondDotIndex = fileName.LastIndexOf('.'); + return fileName.Substring(dotIndex, secondDotIndex - dotIndex); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs b/src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs new file mode 100644 index 0000000..6676dd8 --- /dev/null +++ b/src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs @@ -0,0 +1,38 @@ +#nullable disable +namespace EllieBot.Services; + +public class MemoryBotStringsProvider : IBotStringsProvider +{ + private readonly IStringsSource _source; + private IReadOnlyDictionary> responseStrings; + private IReadOnlyDictionary> commandStrings; + + public MemoryBotStringsProvider(IStringsSource source) + { + _source = source; + Reload(); + } + + public string GetText(string localeName, string key) + { + if (responseStrings.TryGetValue(localeName, out var langStrings) && langStrings.TryGetValue(key, out var text)) + return text; + + return null; + } + + public void Reload() + { + responseStrings = _source.GetResponseStrings(); + commandStrings = _source.GetCommandStrings(); + } + + public CommandStrings GetCommandStrings(string localeName, string commandName) + { + if (commandStrings.TryGetValue(localeName, out var langStrings) + && langStrings.TryGetValue(commandName, out var strings)) + return strings; + + return null; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/BotConfigService.cs b/src/EllieBot/_common/Settings/BotConfigService.cs new file mode 100644 index 0000000..5e98049 --- /dev/null +++ b/src/EllieBot/_common/Settings/BotConfigService.cs @@ -0,0 +1,79 @@ +#nullable disable +using EllieBot.Common.Configs; +using SixLabors.ImageSharp.PixelFormats; + +namespace EllieBot.Services; + +/// +/// Settings service for bot-wide configuration. +/// +public sealed class BotConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/bot.yml"; + private static readonly TypedKey _changeKey = new("config.bot.updated"); + public override string Name { get; } = "bot"; + + public BotConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("color.ok", bs => bs.Color.Ok, Rgba32.TryParseHex, ConfigPrinters.Color); + AddParsedProp("color.error", bs => bs.Color.Error, Rgba32.TryParseHex, ConfigPrinters.Color); + AddParsedProp("color.pending", bs => bs.Color.Pending, Rgba32.TryParseHex, ConfigPrinters.Color); + AddParsedProp("help.text", bs => bs.HelpText, ConfigParsers.String, ConfigPrinters.ToString); + AddParsedProp("help.dmtext", bs => bs.DmHelpText, ConfigParsers.String, ConfigPrinters.ToString); + AddParsedProp("console.type", bs => bs.ConsoleOutputType, Enum.TryParse, ConfigPrinters.ToString); + AddParsedProp("locale", bs => bs.DefaultLocale, ConfigParsers.Culture, ConfigPrinters.Culture); + AddParsedProp("prefix", bs => bs.Prefix, ConfigParsers.String, ConfigPrinters.ToString); + AddParsedProp("checkforupdates", bs => bs.CheckForUpdates, bool.TryParse, ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 2) + ModifyConfig(c => c.Version = 2); + + if (data.Version < 3) + { + ModifyConfig(c => + { + c.Version = 3; + c.Blocked.Modules = c.Blocked.Modules?.Select(static x + => string.Equals(x, + "ActualCustomReactions", + StringComparison.InvariantCultureIgnoreCase) + ? "ACTUALEXPRESSIONS" + : x) + .Distinct() + .ToHashSet(); + }); + } + + if (data.Version < 4) + ModifyConfig(c => + { + c.Version = 4; + c.CheckForUpdates = true; + }); + + if(data.Version < 5) + ModifyConfig(c => + { + c.Version = 5; + }); + + if(data.Version < 7) + ModifyConfig(c => + { + c.Version = 7; + c.IgnoreOtherBots = true; + }); + + if(data.Version < 8) + ModifyConfig(c => + { + c.Version = 8; + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/ConfigParsers.cs b/src/EllieBot/_common/Settings/ConfigParsers.cs new file mode 100644 index 0000000..8eda144 --- /dev/null +++ b/src/EllieBot/_common/Settings/ConfigParsers.cs @@ -0,0 +1,50 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; + +namespace EllieBot.Services; + +/// +/// Custom setting value parsers for types which don't have them by default +/// +public static class ConfigParsers +{ + /// + /// Default string parser. Passes input to output and returns true. + /// + public static bool String(string input, out string output) + { + output = input; + return true; + } + + public static bool Culture(string input, out CultureInfo output) + { + try + { + output = new(input); + return true; + } + catch + { + output = null; + return false; + } + } + + public static bool InsensitiveEnum(string input, out T output) + where T: struct + => Enum.TryParse(input, true, out output); +} + +public static class ConfigPrinters +{ + public static string ToString(TAny input) + => input.ToString(); + + public static string Culture(CultureInfo culture) + => culture.Name; + + public static string Color(Rgba32 color) + => ((uint)((color.B << 0) | (color.G << 8) | (color.R << 16))).ToString("X6"); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/ConfigServiceBase.cs b/src/EllieBot/_common/Settings/ConfigServiceBase.cs new file mode 100644 index 0000000..df6a0b9 --- /dev/null +++ b/src/EllieBot/_common/Settings/ConfigServiceBase.cs @@ -0,0 +1,201 @@ +using EllieBot.Common.Configs; +using EllieBot.Common.Yml; +using System.Linq.Expressions; +using System.Reflection; + +namespace EllieBot.Services; + +/// +/// Base service for all settings services +/// +/// Type of the settings +public abstract class ConfigServiceBase : IConfigService + where TSettings : ICloneable, new() +{ + // FUTURE config arrays are not copied - they're not protected from mutations + public TSettings Data + => data.Clone(); + + public abstract string Name { get; } + protected readonly string _filePath; + protected readonly IConfigSeria _serializer; + protected readonly IPubSub _pubSub; + private readonly TypedKey _changeKey; + + protected TSettings data; + + private readonly Dictionary> _propSetters = new(); + private readonly Dictionary> _propSelectors = new(); + private readonly Dictionary> _propPrinters = new(); + private readonly Dictionary _propComments = new(); + + /// + /// Initialized an instance of + /// + /// Path to the file where the settings are serialized/deserialized to and from + /// Serializer which will be used + /// Pubsub implementation for signaling when settings are updated + /// Key used to signal changed event + protected ConfigServiceBase( + string filePath, + IConfigSeria serializer, + IPubSub pubSub, + TypedKey changeKey) + { + _filePath = filePath; + _serializer = serializer; + _pubSub = pubSub; + _changeKey = changeKey; + + data = new(); + Load(); + _pubSub.Sub(_changeKey, OnChangePublished); + } + + private void PublishChange() + => _pubSub.Pub(_changeKey, data); + + private ValueTask OnChangePublished(TSettings newData) + { + data = newData; + OnStateUpdate(); + return default; + } + + /// + /// Loads data from disk. If file doesn't exist, it will be created with default values + /// + protected void Load() + { + // if file is deleted, regenerate it with default values + if (!File.Exists(_filePath)) + { + data = new(); + Save(); + } + + data = _serializer.Deserialize(File.ReadAllText(_filePath)); + } + + /// + /// Loads new data and publishes the new state + /// + public void Reload() + { + Load(); + _pubSub.Pub(_changeKey, data); + } + + /// + /// Doesn't do anything by default. This method will be executed after + /// is reloaded from or new data is recieved + /// from the publish event + /// + protected virtual void OnStateUpdate() + { + } + + private void Save() + { + var strData = _serializer.Serialize(data); + File.WriteAllText(_filePath, strData); + } + + protected void AddParsedProp( + string key, + Expression> selector, + SettingParser parser, + Func printer, + Func? checker = null) + { + checker ??= _ => true; + key = key.ToLowerInvariant(); + _propPrinters[key] = obj => printer((TProp)obj); + _propSelectors[key] = () => selector.Compile()(data)!; + _propSetters[key] = Magic(selector, parser, checker); + _propComments[key] = ((MemberExpression)selector.Body).Member.GetCustomAttribute()?.Comment; + } + + private Func Magic( + Expression> selector, + SettingParser parser, + Func checker) + => (target, input) => + { + if (!parser(input, out var value)) + return false; + + if (!checker(value)) + return false; + + object targetObject = target; + var expr = (MemberExpression)selector.Body; + var prop = (PropertyInfo)expr.Member; + + var expressions = new List(); + + while (true) + { + expr = expr.Expression as MemberExpression; + if (expr is null) + break; + + expressions.Add(expr); + } + + foreach (var memberExpression in expressions.AsEnumerable().Reverse()) + { + var localProp = (PropertyInfo)memberExpression.Member; + targetObject = localProp.GetValue(targetObject)!; + } + + prop.SetValue(targetObject, value, null); + return true; + }; + + public IReadOnlyList GetSettableProps() + => _propSetters.Keys.ToList(); + + public string? GetSetting(string prop) + { + prop = prop.ToLowerInvariant(); + if (!_propSelectors.TryGetValue(prop, out var selector) || !_propPrinters.TryGetValue(prop, out var printer)) + return null; + + return printer(selector()); + } + + public string? GetComment(string prop) + { + if (_propComments.TryGetValue(prop, out var comment)) + return comment; + + return null; + } + + private bool SetProperty(TSettings target, string key, string value) + => _propSetters.TryGetValue(key.ToLowerInvariant(), out var magic) && magic(target, value); + + public bool SetSetting(string prop, string newValue) + { + var success = true; + ModifyConfig(bs => + { + success = SetProperty(bs, prop, newValue); + }); + + if (success) + PublishChange(); + + return success; + } + + public void ModifyConfig(Action action) + { + var copy = Data; + action(copy); + data = copy; + Save(); + PublishChange(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/IConfigService.cs b/src/EllieBot/_common/Settings/IConfigService.cs new file mode 100644 index 0000000..ae97198 --- /dev/null +++ b/src/EllieBot/_common/Settings/IConfigService.cs @@ -0,0 +1,46 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// Interface that all services which deal with configs should implement +/// +public interface IConfigService +{ + /// + /// Name of the config + /// + public string Name { get; } + + /// + /// Loads new data and publishes the new state + /// + void Reload(); + + /// + /// Gets the list of props you can set + /// + /// List of props + IReadOnlyList GetSettableProps(); + + /// + /// Gets the value of the specified property + /// + /// Prop name + /// Value of the prop + string GetSetting(string prop); + + /// + /// Gets the value of the specified property + /// + /// Prop name + /// Value of the prop + string GetComment(string prop); + + /// + /// Sets the value of the specified property + /// + /// Property to set + /// Value to set the property to + /// Success + bool SetSetting(string prop, string newValue); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/SettingParser.cs b/src/EllieBot/_common/Settings/SettingParser.cs new file mode 100644 index 0000000..1437591 --- /dev/null +++ b/src/EllieBot/_common/Settings/SettingParser.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// Delegate which describes a parser which can convert string input into given data type +/// +/// Data type to convert string to +public delegate bool SettingParser(string input, out TData output); \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartEmbedText.cs b/src/EllieBot/_common/SmartText/SmartEmbedText.cs new file mode 100644 index 0000000..b52c0cb --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartEmbedText.cs @@ -0,0 +1,184 @@ +#nullable disable warnings +using SixLabors.ImageSharp.PixelFormats; +using System.Text.Json.Serialization; + +namespace EllieBot; + +public sealed record SmartEmbedArrayElementText : SmartEmbedTextBase +{ + public string Color { get; init; } = string.Empty; + + public SmartEmbedArrayElementText() + { + + } + + public SmartEmbedArrayElementText(IEmbed eb) : base(eb) + { + Color = eb.Color is { } c ? new Rgba32(c.R, c.G, c.B).ToHex() : string.Empty; + } + + protected override EmbedBuilder GetEmbedInternal() + { + var embed = base.GetEmbedInternal(); + if (Rgba32.TryParseHex(Color, out var color)) + return embed.WithColor(color.ToDiscordColor()); + + return embed; + } +} + +public sealed record SmartEmbedText : SmartEmbedTextBase +{ + public string? PlainText { get; init; } + + public uint Color { get; init; } = 7458112; + + public SmartEmbedText() + { + } + + private SmartEmbedText(IEmbed eb, string? plainText = null) + : base(eb) + => (PlainText, Color) = (plainText, eb.Color?.RawValue ?? 0); + + public static SmartEmbedText FromEmbed(IEmbed eb, string? plainText = null) + => new(eb, plainText); + + protected override EmbedBuilder GetEmbedInternal() + { + var embed = base.GetEmbedInternal(); + return embed.WithColor(Color); + } +} + +public abstract record SmartEmbedTextBase : SmartText +{ + public string? Title { get; init; } + public string? Description { get; init; } + public string? Url { get; init; } + public string? Thumbnail { get; init; } + public string? Image { get; init; } + + public SmartTextEmbedAuthor? Author { get; init; } + public SmartTextEmbedFooter? Footer { get; init; } + public SmartTextEmbedField[]? Fields { get; init; } + + [JsonIgnore] + public bool IsValid + => !string.IsNullOrWhiteSpace(Title) + || !string.IsNullOrWhiteSpace(Description) + || !string.IsNullOrWhiteSpace(Url) + || !string.IsNullOrWhiteSpace(Thumbnail) + || !string.IsNullOrWhiteSpace(Image) + || (Footer is not null + && (!string.IsNullOrWhiteSpace(Footer.Text) || !string.IsNullOrWhiteSpace(Footer.IconUrl))) + || Fields is { Length: > 0 }; + + protected SmartEmbedTextBase() + { + + } + + protected SmartEmbedTextBase(IEmbed eb) + { + Title = eb.Title; + Description = eb.Description; + Url = eb.Url; + Thumbnail = eb.Thumbnail?.Url; + Image = eb.Image?.Url; + Author = eb.Author is { } ea + ? new() + { + Name = ea.Name, + Url = ea.Url, + IconUrl = ea.IconUrl + } + : null; + Footer = eb.Footer is { } ef + ? new() + { + Text = ef.Text, + IconUrl = ef.IconUrl + } + : null; + + if (eb.Fields.Length > 0) + { + Fields = eb.Fields.Select(field + => new SmartTextEmbedField + { + Inline = field.Inline, + Name = field.Name, + Value = field.Value + }) + .ToArray(); + } + } + + public EmbedBuilder GetEmbed() + => GetEmbedInternal(); + + protected virtual EmbedBuilder GetEmbedInternal() + { + var embed = new EmbedBuilder(); + + if (!string.IsNullOrWhiteSpace(Title)) + embed.WithTitle(Title); + + if (!string.IsNullOrWhiteSpace(Description)) + embed.WithDescription(Description); + + if (Url is not null && Uri.IsWellFormedUriString(Url, UriKind.Absolute)) + embed.WithUrl(Url); + + if (Footer is not null) + { + embed.WithFooter(efb => + { + efb.WithText(Footer.Text); + if (Uri.IsWellFormedUriString(Footer.IconUrl, UriKind.Absolute)) + efb.WithIconUrl(Footer.IconUrl); + }); + } + + if (Thumbnail is not null && Uri.IsWellFormedUriString(Thumbnail, UriKind.Absolute)) + embed.WithThumbnailUrl(Thumbnail); + + if (Image is not null && Uri.IsWellFormedUriString(Image, UriKind.Absolute)) + embed.WithImageUrl(Image); + + if (Author is not null && !string.IsNullOrWhiteSpace(Author.Name)) + { + if (!Uri.IsWellFormedUriString(Author.IconUrl, UriKind.Absolute)) + Author.IconUrl = null; + if (!Uri.IsWellFormedUriString(Author.Url, UriKind.Absolute)) + Author.Url = null; + + embed.WithAuthor(Author.Name, Author.IconUrl, Author.Url); + } + + if (Fields is not null) + { + foreach (var f in Fields) + { + if (!string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value)) + embed.AddField(f.Name, f.Value, f.Inline); + } + } + + return embed; + } + + public void NormalizeFields() + { + if (Fields is { Length: > 0 }) + { + foreach (var f in Fields) + { + f.Name = f.Name.TrimTo(256); + f.Value = f.Value.TrimTo(1024); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs b/src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs new file mode 100644 index 0000000..3147132 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs @@ -0,0 +1,34 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot; + +public sealed record SmartEmbedTextArray : SmartText +{ + public string Content { get; set; } + public SmartEmbedArrayElementText[] Embeds { get; set; } + + [JsonIgnore] + public bool IsValid + => Embeds?.All(x => x.IsValid) ?? false; + + public EmbedBuilder[] GetEmbedBuilders() + { + if (Embeds is null) + return Array.Empty(); + + return Embeds + .Where(x => x.IsValid) + .Select(em => em.GetEmbed()) + .ToArray(); + } + + public void NormalizeFields() + { + if (Embeds is null) + return; + + foreach(var eb in Embeds) + eb.NormalizeFields(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartPlainText.cs b/src/EllieBot/_common/SmartText/SmartPlainText.cs new file mode 100644 index 0000000..0da4a35 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartPlainText.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot; + +public sealed record SmartPlainText : SmartText +{ + public string Text { get; init; } + + public SmartPlainText(string text) + => Text = text; + + public static implicit operator SmartPlainText(string input) + => new(input); + + public static implicit operator string(SmartPlainText input) + => input.Text; + + public override string ToString() + => Text; +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartText.cs b/src/EllieBot/_common/SmartText/SmartText.cs new file mode 100644 index 0000000..74aee76 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartText.cs @@ -0,0 +1,92 @@ +#nullable disable +using Newtonsoft.Json.Linq; +using System.Text.Json.Serialization; + +namespace EllieBot; + +public abstract record SmartText +{ + [JsonIgnore] + public bool IsEmbed + => this is SmartEmbedText; + + [JsonIgnore] + public bool IsPlainText + => this is SmartPlainText; + + [JsonIgnore] + public bool IsEmbedArray + => this is SmartEmbedTextArray; + + public static implicit operator SmartText(string input) + => new SmartPlainText(input); + + public static SmartText operator +(SmartText text, string input) + => text switch + { + SmartEmbedText set => set with + { + PlainText = set.PlainText + input + }, + SmartPlainText spt => new SmartPlainText(spt.Text + input), + SmartEmbedTextArray arr => arr with + { + Content = arr.Content + input + }, + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static SmartText operator +(string input, SmartText text) + => text switch + { + SmartEmbedText set => set with + { + PlainText = input + set.PlainText + }, + SmartPlainText spt => new SmartPlainText(input + spt.Text), + SmartEmbedTextArray arr => arr with + { + Content = input + arr.Content + }, + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static SmartText CreateFrom(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return new SmartPlainText(input); + + try + { + var doc = JObject.Parse(input); + var root = doc.Root; + if (root.Type == JTokenType.Object) + { + if (((JObject)root).TryGetValue("embeds", out _)) + { + var arr = root.ToObject(); + + if (arr is null) + return new SmartPlainText(input); + + arr!.NormalizeFields(); + return arr; + } + + var obj = root.ToObject(); + + if (obj is null || !(obj.IsValid || !string.IsNullOrWhiteSpace(obj.PlainText))) + return new SmartPlainText(input); + + obj.NormalizeFields(); + return obj; + } + + return new SmartPlainText(input); + } + catch + { + return new SmartPlainText(input); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs b/src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs new file mode 100644 index 0000000..9d3cc1c --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs @@ -0,0 +1,16 @@ +#nullable disable +using Newtonsoft.Json; +using System.Text.Json.Serialization; + +namespace EllieBot; + +public class SmartTextEmbedAuthor +{ + public string Name { get; set; } + + [JsonProperty("icon_url")] + [JsonPropertyName("icon_url")] + public string IconUrl { get; set; } + + public string Url { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartTextEmbedField.cs b/src/EllieBot/_common/SmartText/SmartTextEmbedField.cs new file mode 100644 index 0000000..7989b32 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartTextEmbedField.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot; + +public class SmartTextEmbedField +{ + public string Name { get; set; } + public string Value { get; set; } + public bool Inline { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs b/src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs new file mode 100644 index 0000000..6b06223 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs @@ -0,0 +1,14 @@ +#nullable disable +using Newtonsoft.Json; +using System.Text.Json.Serialization; + +namespace EllieBot; + +public class SmartTextEmbedFooter +{ + public string Text { get; set; } + + [JsonProperty("icon_url")] + [JsonPropertyName("icon_url")] + public string IconUrl { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TriviaQuestionModel.cs b/src/EllieBot/_common/TriviaQuestionModel.cs new file mode 100644 index 0000000..ddd92d3 --- /dev/null +++ b/src/EllieBot/_common/TriviaQuestionModel.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common.Trivia; + +public sealed class TriviaQuestionModel +{ + public string Category { get; init; } + public string Question { get; init; } + public string ImageUrl { get; init; } + public string AnswerImageUrl { get; init; } + public string Answer { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaderResult.cs b/src/EllieBot/_common/TypeReaderResult.cs new file mode 100644 index 0000000..309ed7b --- /dev/null +++ b/src/EllieBot/_common/TypeReaderResult.cs @@ -0,0 +1,30 @@ +namespace EllieBot.Common.TypeReaders; + +public readonly struct TypeReaderResult +{ + public bool IsSuccess + => _result.IsSuccess; + + public IReadOnlyCollection Values + => _result.Values; + + private readonly Discord.Commands.TypeReaderResult _result; + + private TypeReaderResult(in Discord.Commands.TypeReaderResult result) + => _result = result; + + public static implicit operator TypeReaderResult(in Discord.Commands.TypeReaderResult result) + => new(result); + + public static implicit operator Discord.Commands.TypeReaderResult(in TypeReaderResult wrapper) + => wrapper._result; +} + +public static class TypeReaderResult +{ + public static TypeReaderResult FromError(CommandError error, string reason) + => Discord.Commands.TypeReaderResult.FromError(error, reason); + + public static TypeReaderResult FromSuccess(in T value) + => Discord.Commands.TypeReaderResult.FromSuccess(value); +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs b/src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs new file mode 100644 index 0000000..18d9184 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs @@ -0,0 +1,23 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public class CommandOrExprInfo +{ + public enum Type + { + Normal, + Custom + } + + public string Name { get; set; } + public Type CmdType { get; set; } + + public bool IsCustom + => CmdType == Type.Custom; + + public CommandOrExprInfo(string input, Type type) + { + Name = input; + CmdType = type; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs b/src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs new file mode 100644 index 0000000..e473e49 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class EmoteTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (!Emote.TryParse(input, out var emote)) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "Input is not a valid emote")); + + return new(TypeReaderResult.FromSuccess(emote)); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs b/src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs new file mode 100644 index 0000000..97f9eb4 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs @@ -0,0 +1,49 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class GuildDateTimeTypeReader : EllieTypeReader +{ + private readonly ITimezoneService _gts; + + public GuildDateTimeTypeReader(ITimezoneService gts) + => _gts = gts; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + var gdt = Parse(context.Guild.Id, input); + if (gdt is null) + { + return new(TypeReaderResult.FromError(CommandError.ParseFailed, + "Input string is in an incorrect format.")); + } + + return new(TypeReaderResult.FromSuccess(gdt)); + } + + private GuildDateTime Parse(ulong guildId, string input) + { + if (!DateTime.TryParse(input, out var dt)) + return null; + + var tz = _gts.GetTimeZoneOrUtc(guildId); + + return new(tz, dt); + } +} + +public class GuildDateTime +{ + public TimeZoneInfo Timezone { get; } + public DateTime CurrentGuildTime { get; } + public DateTime InputTime { get; } + public DateTime InputTimeUtc { get; } + + public GuildDateTime(TimeZoneInfo guildTimezone, DateTime inputTime) + { + var now = DateTime.UtcNow; + Timezone = guildTimezone; + CurrentGuildTime = TimeZoneInfo.ConvertTime(now, TimeZoneInfo.Utc, Timezone); + InputTime = inputTime; + InputTimeUtc = TimeZoneInfo.ConvertTime(inputTime, Timezone, TimeZoneInfo.Utc); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/GuildTypeReader.cs b/src/EllieBot/_common/TypeReaders/GuildTypeReader.cs new file mode 100644 index 0000000..9a29f95 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/GuildTypeReader.cs @@ -0,0 +1,24 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class GuildTypeReader : EllieTypeReader +{ + private readonly DiscordSocketClient _client; + + public GuildTypeReader(DiscordSocketClient client) + => _client = client; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.Trim().ToUpperInvariant(); + var guilds = _client.Guilds; + IGuild guild = guilds.FirstOrDefault(g => g.Id.ToString().Trim().ToUpperInvariant() == input) //by id + ?? guilds.FirstOrDefault(g => g.Name.Trim().ToUpperInvariant() == input); //by name + + if (guild is not null) + return new(TypeReaderResult.FromSuccess(guild)); + + return new( + TypeReaderResult.FromError(CommandError.ParseFailed, "No guild by that name or Id found")); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs b/src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs new file mode 100644 index 0000000..6c4b9d6 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs @@ -0,0 +1,33 @@ +namespace EllieBot.Common.TypeReaders; + +public sealed class GuildUserTypeReader : EllieTypeReader +{ + public override async ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (ctx.Guild is null) + return TypeReaderResult.FromError(CommandError.Unsuccessful, "Must be in a guild."); + + input = input.Trim(); + IGuildUser? user = null; + if (MentionUtils.TryParseUser(input, out var id)) + user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload); + + if (ulong.TryParse(input, out id)) + user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload); + + if (user is null) + { + var users = await ctx.Guild.GetUsersAsync(CacheMode.CacheOnly); + user = users.FirstOrDefault(x => x.Username == input) + ?? users.FirstOrDefault(x => + string.Equals(x.ToString(), input, StringComparison.InvariantCultureIgnoreCase)) + ?? users.FirstOrDefault(x => + string.Equals(x.Username, input, StringComparison.InvariantCultureIgnoreCase)); + } + + if (user is null) + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "User not found."); + + return TypeReaderResult.FromSuccess(user); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/KwumTypeReader.cs b/src/EllieBot/_common/TypeReaders/KwumTypeReader.cs new file mode 100644 index 0000000..608a852 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/KwumTypeReader.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class KwumTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + if (kwum.TryParse(input, out var val)) + return new(TypeReaderResult.FromSuccess(val)); + + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "Input is not a valid kwum")); + } +} + +public sealed class SmartTextTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + => new(TypeReaderResult.FromSuccess(SmartText.CreateFrom(input))); +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs b/src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs new file mode 100644 index 0000000..1aec85b --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs @@ -0,0 +1,27 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders.Models; + +public class PermissionAction +{ + public static PermissionAction Enable + => new(true); + + public static PermissionAction Disable + => new(false); + + public bool Value { get; } + + public PermissionAction(bool value) + => Value = value; + + public override bool Equals(object obj) + { + if (obj is null || GetType() != obj.GetType()) + return false; + + return Value == ((PermissionAction)obj).Value; + } + + public override int GetHashCode() + => Value.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs b/src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs new file mode 100644 index 0000000..08c69b8 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs @@ -0,0 +1,55 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace EllieBot.Common.TypeReaders.Models; + +public class StoopidTime +{ + private static readonly Regex _regex = new( + @"^(?:(?\d)mo)?(?:(?\d{1,2})w)?(?:(?\d{1,2})d)?(?:(?\d{1,4})h)?(?:(?\d{1,5})m)?(?:(?\d{1,6})s)?$", + RegexOptions.Compiled | RegexOptions.Multiline); + + public string Input { get; set; } + public TimeSpan Time { get; set; } + + private StoopidTime() { } + + public static StoopidTime FromInput(string input) + { + var m = _regex.Match(input); + + if (m.Length == 0) + throw new ArgumentException("Invalid string input format."); + + var namesAndValues = new Dictionary(); + + foreach (var groupName in _regex.GetGroupNames()) + { + if (groupName == "0") + continue; + if (!int.TryParse(m.Groups[groupName].Value, out var value)) + { + namesAndValues[groupName] = 0; + continue; + } + + if (value < 1) + throw new ArgumentException($"Invalid {groupName} value."); + + namesAndValues[groupName] = value; + } + + var ts = new TimeSpan((30 * namesAndValues["months"]) + (7 * namesAndValues["weeks"]) + namesAndValues["days"], + namesAndValues["hours"], + namesAndValues["minutes"], + namesAndValues["seconds"]); + if (ts > TimeSpan.FromDays(90)) + throw new ArgumentException("Time is too long."); + + return new() + { + Input = input, + Time = ts + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs b/src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs new file mode 100644 index 0000000..fbbaff0 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs @@ -0,0 +1,52 @@ +#nullable disable +using EllieBot.Modules.Permissions; + +namespace EllieBot.Common.TypeReaders; + +public sealed class ModuleTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + + public ModuleTypeReader(CommandService cmds) + => _cmds = cmds; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.ToUpperInvariant(); + var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule()) + .FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input) + ?.Key; + if (module is null) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such module found.")); + + return new(TypeReaderResult.FromSuccess(module)); + } +} + +public sealed class ModuleOrExprTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + + public ModuleOrExprTypeReader(CommandService cmds) + => _cmds = cmds; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.ToUpperInvariant(); + var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule()) + .FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input) + ?.Key; + if (module is null && input != "ACTUALEXPRESSIONS" && input != CleverBotResponseStr.CLEVERBOT_RESPONSE) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such module found.")); + + return new(TypeReaderResult.FromSuccess(new ModuleOrExpr + { + Name = input + })); + } +} + +public sealed class ModuleOrExpr +{ + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs b/src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs new file mode 100644 index 0000000..168cceb --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs @@ -0,0 +1,39 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; + +namespace EllieBot.Common.TypeReaders; + +/// +/// Used instead of bool for more flexible keywords for true/false only in the permission module +/// +public sealed class PermissionActionTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.ToUpperInvariant(); + switch (input) + { + case "1": + case "T": + case "TRUE": + case "ENABLE": + case "ENABLED": + case "ALLOW": + case "PERMIT": + case "UNBAN": + return new(TypeReaderResult.FromSuccess(PermissionAction.Enable)); + case "0": + case "F": + case "FALSE": + case "DENY": + case "DISABLE": + case "DISABLED": + case "DISALLOW": + case "BAN": + return new(TypeReaderResult.FromSuccess(PermissionAction.Disable)); + default: + return new(TypeReaderResult.FromError(CommandError.ParseFailed, + "Did not receive a valid boolean value")); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs b/src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs new file mode 100644 index 0000000..77e7c6a --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs @@ -0,0 +1,20 @@ +using Color = SixLabors.ImageSharp.Color; + +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class Rgba32TypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.Replace("#", "", StringComparison.InvariantCulture); + try + { + return ValueTask.FromResult(TypeReaderResult.FromSuccess(Color.ParseHex(input))); + } + catch + { + return ValueTask.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Parameter is not a valid color hex.")); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs b/src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs new file mode 100644 index 0000000..c5b9418 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs @@ -0,0 +1,22 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; + +namespace EllieBot.Common.TypeReaders; + +public sealed class StoopidTimeTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + if (string.IsNullOrWhiteSpace(input)) + return new(TypeReaderResult.FromError(CommandError.Unsuccessful, "Input is empty.")); + try + { + var time = StoopidTime.FromInput(input); + return new(TypeReaderResult.FromSuccess(time)); + } + catch (Exception ex) + { + return new(TypeReaderResult.FromError(CommandError.Exception, ex.Message)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/CommentAttribute.cs b/src/EllieBot/_common/Yml/CommentAttribute.cs new file mode 100644 index 0000000..1c1ad89 --- /dev/null +++ b/src/EllieBot/_common/Yml/CommentAttribute.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Common.Yml; + +[AttributeUsage(AttributeTargets.Property)] +public class CommentAttribute : Attribute +{ + public string Comment { get; } + + public CommentAttribute(string comment) + => Comment = comment; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs b/src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs new file mode 100644 index 0000000..f9f5739 --- /dev/null +++ b/src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs @@ -0,0 +1,65 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.TypeInspectors; + +namespace EllieBot.Common.Yml; + +public class CommentGatheringTypeInspector : TypeInspectorSkeleton +{ + private readonly ITypeInspector _innerTypeDescriptor; + + public CommentGatheringTypeInspector(ITypeInspector innerTypeDescriptor) + => _innerTypeDescriptor = innerTypeDescriptor ?? throw new ArgumentNullException(nameof(innerTypeDescriptor)); + + public override IEnumerable GetProperties(Type type, object container) + => _innerTypeDescriptor.GetProperties(type, container).Select(d => new CommentsPropertyDescriptor(d)); + + private sealed class CommentsPropertyDescriptor : IPropertyDescriptor + { + public string Name { get; } + + public Type Type + => _baseDescriptor.Type; + + public Type TypeOverride + { + get => _baseDescriptor.TypeOverride; + set => _baseDescriptor.TypeOverride = value; + } + + public int Order { get; set; } + + public ScalarStyle ScalarStyle + { + get => _baseDescriptor.ScalarStyle; + set => _baseDescriptor.ScalarStyle = value; + } + + public bool CanWrite + => _baseDescriptor.CanWrite; + + private readonly IPropertyDescriptor _baseDescriptor; + + public CommentsPropertyDescriptor(IPropertyDescriptor baseDescriptor) + { + _baseDescriptor = baseDescriptor; + Name = baseDescriptor.Name; + } + + public void Write(object target, object value) + => _baseDescriptor.Write(target, value); + + public T GetCustomAttribute() + where T : Attribute + => _baseDescriptor.GetCustomAttribute(); + + public IObjectDescriptor Read(object target) + { + var comment = _baseDescriptor.GetCustomAttribute(); + return comment is not null + ? new CommentsObjectDescriptor(_baseDescriptor.Read(target), comment.Comment) + : _baseDescriptor.Read(target); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs b/src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs new file mode 100644 index 0000000..ce54758 --- /dev/null +++ b/src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs @@ -0,0 +1,30 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Yml; + +public sealed class CommentsObjectDescriptor : IObjectDescriptor +{ + public string Comment { get; } + + public object Value + => _innerDescriptor.Value; + + public Type Type + => _innerDescriptor.Type; + + public Type StaticType + => _innerDescriptor.StaticType; + + public ScalarStyle ScalarStyle + => _innerDescriptor.ScalarStyle; + + private readonly IObjectDescriptor _innerDescriptor; + + public CommentsObjectDescriptor(IObjectDescriptor innerDescriptor, string comment) + { + _innerDescriptor = innerDescriptor; + Comment = comment; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs b/src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs new file mode 100644 index 0000000..1c89a95 --- /dev/null +++ b/src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs @@ -0,0 +1,29 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.ObjectGraphVisitors; + +namespace EllieBot.Common.Yml; + +public class CommentsObjectGraphVisitor : ChainedObjectGraphVisitor +{ + public CommentsObjectGraphVisitor(IObjectGraphVisitor nextVisitor) + : base(nextVisitor) + { + } + + public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context) + { + if (value is CommentsObjectDescriptor commentsDescriptor + && !string.IsNullOrWhiteSpace(commentsDescriptor.Comment)) + { + var parts = commentsDescriptor.Comment.Split('\n'); + + foreach (var part in parts) + context.Emit(new Comment(part.Trim(), false)); + } + + return base.EnterMapping(key, value, context); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs b/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs new file mode 100644 index 0000000..c63dcab --- /dev/null +++ b/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs @@ -0,0 +1,35 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.EventEmitters; + +namespace EllieBot.Common.Yml; + +public class MultilineScalarFlowStyleEmitter : ChainedEventEmitter +{ + public MultilineScalarFlowStyleEmitter(IEventEmitter nextEmitter) + : base(nextEmitter) + { + } + + public override void Emit(ScalarEventInfo eventInfo, IEmitter emitter) + { + if (typeof(string).IsAssignableFrom(eventInfo.Source.Type)) + { + var value = eventInfo.Source.Value as string; + if (!string.IsNullOrEmpty(value)) + { + var isMultiLine = value.IndexOfAny(['\r', '\n', '\x85', '\x2028', '\x2029']) >= 0; + if (isMultiLine) + { + eventInfo = new(eventInfo.Source) + { + Style = ScalarStyle.Literal + }; + } + } + } + + nextEmitter.Emit(eventInfo, emitter); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/Rgba32Converter.cs b/src/EllieBot/_common/Yml/Rgba32Converter.cs new file mode 100644 index 0000000..12f6cf9 --- /dev/null +++ b/src/EllieBot/_common/Yml/Rgba32Converter.cs @@ -0,0 +1,47 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Yml; + +public class Rgba32Converter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(Rgba32); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = Rgba32.ParseHex(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var color = (Rgba32)value; + var val = (uint)((color.B << 0) | (color.G << 8) | (color.R << 16)); + emitter.Emit(new Scalar(val.ToString("X6").ToLower())); + } +} + +public class CultureInfoConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(CultureInfo); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = new CultureInfo(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var ci = (CultureInfo)value; + emitter.Emit(new Scalar(ci.Name)); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/UriConverter.cs b/src/EllieBot/_common/Yml/UriConverter.cs new file mode 100644 index 0000000..66e2ca0 --- /dev/null +++ b/src/EllieBot/_common/Yml/UriConverter.cs @@ -0,0 +1,25 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Yml; + +public class UriConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(Uri); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = new Uri(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var uri = (Uri)value; + emitter.Emit(new Scalar(uri.ToString())); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/Yaml.cs b/src/EllieBot/_common/Yml/Yaml.cs new file mode 100644 index 0000000..c4779cc --- /dev/null +++ b/src/EllieBot/_common/Yml/Yaml.cs @@ -0,0 +1,30 @@ +#nullable disable +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace EllieBot.Common.Yml; + +public class Yaml +{ + public static ISerializer Serializer + => new SerializerBuilder() + .WithTypeInspector(inner => new CommentGatheringTypeInspector(inner)) + .DisableAliases() + .WithEmissionPhaseObjectGraphVisitor(args + => new CommentsObjectGraphVisitor(args.InnerVisitor)) + .WithEventEmitter(args => new MultilineScalarFlowStyleEmitter(args)) + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithIndentedSequences() + .WithTypeConverter(new Rgba32Converter()) + .WithTypeConverter(new CultureInfoConverter()) + .WithTypeConverter(new UriConverter()) + .Build(); + + public static IDeserializer Deserializer + => new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new Rgba32Converter()) + .WithTypeConverter(new CultureInfoConverter()) + .WithTypeConverter(new UriConverter()) + .IgnoreUnmatchedProperties() + .Build(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs b/src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs new file mode 100644 index 0000000..890bab3 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Extensions; + +public static class BotCredentialsExtensions +{ + public static bool IsOwner(this IBotCredentials creds, IUser user) + => creds.IsOwner(user.Id); + + public static bool IsOwner(this IBotCredentials creds, ulong userId) + => creds.OwnerIds.Contains(userId); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs b/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs new file mode 100644 index 0000000..ebb019b --- /dev/null +++ b/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs @@ -0,0 +1,40 @@ +namespace EllieBot.Extensions; + +public static class CommandContextExtensions +{ + private static readonly Emoji _okEmoji = new Emoji("✅"); + private static readonly Emoji _warnEmoji = new Emoji("⚠️"); + private static readonly Emoji _errorEmoji = new Emoji("❌"); + + public static Task ReactAsync(this ICommandContext ctx, MsgType type) + { + var emoji = type switch + { + MsgType.Error => _errorEmoji, + MsgType.Pending => _warnEmoji, + MsgType.Ok => _okEmoji, + _ => throw new ArgumentOutOfRangeException(nameof(type)), + }; + + return ctx.Message.AddReactionAsync(emoji); + } + + public static Task OkAsync(this ICommandContext ctx) + => ctx.ReactAsync(MsgType.Ok); + + public static Task ErrorAsync(this ICommandContext ctx) + => ctx.ReactAsync(MsgType.Error); + + public static Task WarningAsync(this ICommandContext ctx) + => ctx.ReactAsync(MsgType.Pending); + + + public static Task OkAsync(this IUserMessage msg) + => msg.AddReactionAsync(_okEmoji); + + public static Task ErrorAsync(this IUserMessage msg) + => msg.AddReactionAsync(_errorEmoji); + + public static Task WarningAsync(this IUserMessage msg) + => msg.AddReactionAsync(_warnEmoji); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/DbExtensions.cs b/src/EllieBot/_common/_Extensions/DbExtensions.cs new file mode 100644 index 0000000..0975a1f --- /dev/null +++ b/src/EllieBot/_common/_Extensions/DbExtensions.cs @@ -0,0 +1,10 @@ +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Extensions; + +public static class DbExtensions +{ + public static DiscordUser GetOrCreateUser(this DbContext ctx, IUser original, Func, IQueryable>? includes = null) + => ctx.GetOrCreateUser(original.Id, original.Username, original.Discriminator, original.AvatarId, includes); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/Extensions.cs b/src/EllieBot/_common/_Extensions/Extensions.cs new file mode 100644 index 0000000..dca195d --- /dev/null +++ b/src/EllieBot/_common/_Extensions/Extensions.cs @@ -0,0 +1,231 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using Ellie.Common.Marmalade; + +namespace EllieBot.Extensions; + +public static class Extensions +{ + private static readonly Regex _urlRegex = + new(@"^(https?|ftp)://(?[^\s/$.?#].[^\s]*)$", RegexOptions.Compiled); + + /// + /// Converts to + /// + /// The to convert. + /// The . + public static DateOnly ToDateOnly(this DateTime dateTime) + => DateOnly.FromDateTime(dateTime); + + /// + /// Determines if is before today + /// + /// The to check. + /// True if is before today. + public static bool IsBeforeToday(this DateTime date) + => date < DateTime.UtcNow.Date; + + public static Task EditAsync(this IUserMessage msg, SmartText text) + => text switch + { + SmartEmbedText set => msg.ModifyAsync(x => + { + x.Embed = set.IsValid ? set.GetEmbed().Build() : null; + x.Content = set.PlainText?.SanitizeMentions() ?? ""; + }), + SmartEmbedTextArray set => msg.ModifyAsync(x => + { + x.Embeds = set.GetEmbedBuilders().Map(eb => eb.Build()); + x.Content = set.Content?.SanitizeMentions() ?? ""; + }), + SmartPlainText spt => msg.ModifyAsync(x => + { + x.Content = spt.Text.SanitizeMentions(); + x.Embed = null; + }), + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static ulong[] GetGuildIds(this DiscordSocketClient client) + => client.Guilds + .Map(x => x.Id); + + /// + /// Generates a string in the format HHH:mm if timespan is >= 2m. + /// Generates a string in the format 00:mm:ss if timespan is less than 2m. + /// + /// Timespan to convert to string + /// Formatted duration string + public static string ToPrettyStringHm(this TimeSpan span) + { + if(span > TimeSpan.FromHours(24)) + return $"{span.Days:00}d:{span.Hours:00}h"; + + if (span > TimeSpan.FromMinutes(2)) + return $"{span.Hours:00}h:{span.Minutes:00}m"; + + return $"{span.Minutes:00}m:{span.Seconds:00}s"; + } + + public static double Megabytes(this int mb) + => mb * 1024d * 1024; + + public static TimeSpan Hours(this int hours) + => TimeSpan.FromHours(hours); + + public static TimeSpan Minutes(this int minutes) + => TimeSpan.FromMinutes(minutes); + + public static TimeSpan Days(this int days) + => TimeSpan.FromDays(days); + + public static TimeSpan Seconds(this int seconds) + => TimeSpan.FromSeconds(seconds); + + public static bool TryGetUrlPath(this string input, out string path) + { + var match = _urlRegex.Match(input); + if (match.Success) + { + path = match.Groups["path"].Value; + return true; + } + + path = string.Empty; + return false; + } + + public static IEmote ToIEmote(this string emojiStr) + => Emote.TryParse(emojiStr, out var maybeEmote) ? maybeEmote : new Emoji(emojiStr); + + + /// + /// First 10 characters of teh bot token. + /// + public static string RedisKey(this IBotCredentials bc) + => bc.Token[..10]; + + public static bool IsAuthor(this IMessage msg, IDiscordClient client) + => msg.Author?.Id == client.CurrentUser.Id; + + public static string RealSummary( + this CommandInfo cmd, + IBotStrings strings, + IMarmaladeLoaderService marmalades, + CultureInfo culture, + string prefix) + { + string description; + if (cmd.Remarks?.StartsWith("marmalade///") ?? false) + { + // command method name is kept in Summary + // marmalade/// is kept in remarks + // this way I can find the name of the marmalade, and then name of the command for which + // the description should be loaded + var marmaladeName = cmd.Remarks.Split("///")[1]; + description = marmalades.GetCommandDescription(marmaladeName, cmd.Summary, culture); + } + else + { + description = strings.GetCommandStrings(cmd.Summary, culture).Desc; + } + + return string.Format(description, prefix); + } + + public static string[] RealRemarksArr( + this CommandInfo cmd, + IBotStrings strings, + IMarmaladeLoaderService marmalades, + CultureInfo culture, + string prefix) + { + string[] args; + if (cmd.Remarks?.StartsWith("marmalade///") ?? false) + { + // command method name is kept in Summary + // marmalade/// is kept in remarks + // this way I can find the name of the marmalade, + // and command for which data should be loaded + var marmaladeName = cmd.Remarks.Split("///")[1]; + args = marmalades.GetCommandExampleArgs(marmaladeName, cmd.Summary, culture); + } + else + { + args = strings.GetCommandStrings(cmd.Summary, culture).Examples; + } + + return args.Map(arg => GetFullUsage(cmd.Aliases.First(), arg, prefix)); + } + + private static string GetFullUsage(string commandName, string args, string prefix) + => $"{prefix}{commandName} {string.Format(args, prefix)}".TrimEnd(); + + public static EmbedBuilder AddPaginatedFooter(this EmbedBuilder embed, int curPage, int? lastPage) + { + if (lastPage is not null) + return embed.WithFooter($"{curPage + 1} / {lastPage + 1}"); + + return embed.WithFooter((curPage + 1).ToString()); + } + + // public static EmbedBuilder WithOkColor(this EmbedBuilder eb) + // => eb.WithColor(EmbedColor.Ok); + // + // public static EmbedBuilder WithPendingColor(this EmbedBuilder eb) + // => eb.WithColor(EmbedColor.Pending); + // + // public static EmbedBuilder WithErrorColor(this EmbedBuilder eb) + // => eb.WithColor(EmbedColor.Error); + // + public static IMessage DeleteAfter(this IUserMessage msg, float seconds, ILogCommandService? logService = null) + { + Task.Run(async () => + { + await Task.Delay((int)(seconds * 1000)); + if (logService is not null) + logService.AddDeleteIgnore(msg.Id); + + try + { + await msg.DeleteAsync(); + } + catch + { + } + }); + return msg; + } + + public static ModuleInfo GetTopLevelModule(this ModuleInfo module) + { + while (module.Parent is not null) + module = module.Parent; + + return module; + } + + public static string GetGroupName(this ModuleInfo module) + => module.Name.Replace("Commands", "", StringComparison.InvariantCulture); + + public static async Task> GetMembersAsync(this IRole role) + { + var users = await role.Guild.GetUsersAsync(CacheMode.CacheOnly); + return users.Where(u => u.RoleIds.Contains(role.Id)); + } + + public static string ToJson(this T any, JsonSerializerOptions? options = null) + => JsonSerializer.Serialize(any, options); + + public static Stream ToStream(this IEnumerable bytes, bool canWrite = false) + { + var ms = new MemoryStream(bytes as byte[] ?? bytes.ToArray(), canWrite); + ms.Seek(0, SeekOrigin.Begin); + return ms; + } + + public static IEnumerable GetRoles(this IGuildUser user) + => user.RoleIds.Select(r => user.Guild.GetRole(r)).Where(r => r is not null); + +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs b/src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs new file mode 100644 index 0000000..e8414dd --- /dev/null +++ b/src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs @@ -0,0 +1,97 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Color = Discord.Color; + +namespace EllieBot.Extensions; + +public static class ImagesharpExtensions +{ + // https://github.com/SixLabors/Samples/blob/master/ImageSharp/AvatarWithRoundedCorner/Program.cs + public static IImageProcessingContext ApplyRoundedCorners(this IImageProcessingContext ctx, float cornerRadius) + { + var size = ctx.GetCurrentSize(); + var corners = BuildCorners(size.Width, size.Height, cornerRadius); + + ctx.SetGraphicsOptions(new GraphicsOptions + { + Antialias = true, + // enforces that any part of this shape that has color is punched out of the background + AlphaCompositionMode = PixelAlphaCompositionMode.DestOut + }); + + foreach (var c in corners) + ctx = ctx.Fill(SixLabors.ImageSharp.Color.Red, c); + + return ctx; + } + + private static IPathCollection BuildCorners(int imageWidth, int imageHeight, float cornerRadius) + { + // first create a square + var rect = new RectangularPolygon(-0.5f, -0.5f, cornerRadius, cornerRadius); + + // then cut out of the square a circle so we are left with a corner + var cornerTopLeft = rect.Clip(new EllipsePolygon(cornerRadius - 0.5f, cornerRadius - 0.5f, cornerRadius)); + + // corner is now a corner shape positions top left + //lets make 3 more positioned correctly, we can do that by translating the original around the center of the image + + var rightPos = imageWidth - cornerTopLeft.Bounds.Width + 1; + var bottomPos = imageHeight - cornerTopLeft.Bounds.Height + 1; + + // move it across the width of the image - the width of the shape + var cornerTopRight = cornerTopLeft.RotateDegree(90).Translate(rightPos, 0); + var cornerBottomLeft = cornerTopLeft.RotateDegree(-90).Translate(0, bottomPos); + var cornerBottomRight = cornerTopLeft.RotateDegree(180).Translate(rightPos, bottomPos); + + return new PathCollection(cornerTopLeft, cornerBottomLeft, cornerTopRight, cornerBottomRight); + } + + public static Color ToDiscordColor(this Rgba32 color) + => new(color.R, color.G, color.B); + + public static MemoryStream ToStream(this Image img, IImageFormat? format = null) + { + var imageStream = new MemoryStream(); + if (format?.Name == "GIF") + img.SaveAsGif(imageStream); + else + { + img.SaveAsPng(imageStream, + new() + { + ColorType = PngColorType.RgbWithAlpha, + CompressionLevel = PngCompressionLevel.DefaultCompression + }); + } + + imageStream.Position = 0; + return imageStream; + } + + public static async Task ToStreamAsync(this Image img, IImageFormat? format = null) + { + var imageStream = new MemoryStream(); + if (format?.Name == "GIF") + { + await img.SaveAsGifAsync(imageStream); + } + else + { + await img.SaveAsPngAsync(imageStream, + new PngEncoder() + { + ColorType = PngColorType.RgbWithAlpha, + CompressionLevel = PngCompressionLevel.DefaultCompression + }); + } + + imageStream.Position = 0; + return imageStream; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/LinkedListExtensions.cs b/src/EllieBot/_common/_Extensions/LinkedListExtensions.cs new file mode 100644 index 0000000..018359e --- /dev/null +++ b/src/EllieBot/_common/_Extensions/LinkedListExtensions.cs @@ -0,0 +1,18 @@ +namespace EllieBot.Extensions; + +public static class LinkedListExtensions +{ + public static LinkedListNode? FindNode(this LinkedList list, Func predicate) + { + var node = list.First; + while (node is not null) + { + if (predicate(node.Value)) + return node; + + node = node.Next; + } + + return null; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/NumberExtensions.cs b/src/EllieBot/_common/_Extensions/NumberExtensions.cs new file mode 100644 index 0000000..3e28588 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/NumberExtensions.cs @@ -0,0 +1,30 @@ +using System.Globalization; + +namespace EllieBot.Extensions; + +public static class NumberExtensions +{ + public static DateTimeOffset ToUnixTimestamp(this double number) + => new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(number); + + public static string ToShortString(this decimal value) + { + if (value <= 1_000) + return Math.Round(value, 2).ToString(CultureInfo.InvariantCulture); + if (value <= 1_000_000) + return Math.Round(value, 1).ToString(CultureInfo.InvariantCulture); + var tokens = " MBtq"; + var i = 2; + while (true) + { + var num = (decimal)Math.Pow(1000, i); + if (num > value) + { + var num2 = (decimal)Math.Pow(1000, i - 1); + return $"{Math.Round((value / num2), 1)}{tokens[i - 1]}".Trim(); + } + + i++; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/ReflectionExtensions.cs b/src/EllieBot/_common/_Extensions/ReflectionExtensions.cs new file mode 100644 index 0000000..49be90e --- /dev/null +++ b/src/EllieBot/_common/_Extensions/ReflectionExtensions.cs @@ -0,0 +1,23 @@ +namespace EllieBot.Extensions; + +public static class ReflectionExtensions +{ + public static bool IsAssignableToGenericType(this Type givenType, Type genericType) + { + var interfaceTypes = givenType.GetInterfaces(); + + foreach (var it in interfaceTypes) + { + if (it.IsGenericType && it.GetGenericTypeDefinition() == genericType) + return true; + } + + if (givenType.IsGenericType && givenType.GetGenericTypeDefinition() == genericType) + return true; + + var baseType = givenType.BaseType; + if (baseType == null) return false; + + return IsAssignableToGenericType(baseType, genericType); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/Rgba32Extensions.cs b/src/EllieBot/_common/_Extensions/Rgba32Extensions.cs new file mode 100644 index 0000000..6fb4c7c --- /dev/null +++ b/src/EllieBot/_common/_Extensions/Rgba32Extensions.cs @@ -0,0 +1,57 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace EllieBot.Extensions; + +public static class Rgba32Extensions +{ + public static Image Merge(this IEnumerable> images) + => images.Merge(out _); + + public static Image Merge(this IEnumerable> images, out IImageFormat format) + { + format = PngFormat.Instance; + + void DrawFrame(IList> imgArray, Image imgFrame, int frameNumber) + { + var xOffset = 0; + for (var i = 0; i < imgArray.Count; i++) + { + using var frame = imgArray[i].Frames.CloneFrame(frameNumber % imgArray[i].Frames.Count); + var offset = xOffset; + imgFrame.Mutate(x => x.DrawImage(frame, new Point(offset, 0), new GraphicsOptions())); + xOffset += imgArray[i].Bounds.Width; + } + } + + var imgs = images.ToList(); + var frames = imgs.Max(x => x.Frames.Count); + + var width = imgs.Sum(img => img.Width); + var height = imgs.Max(img => img.Height); + var canvas = new Image(width, height); + if (frames == 1) + { + DrawFrame(imgs, canvas, 0); + return canvas; + } + + format = GifFormat.Instance; + for (var j = 0; j < frames; j++) + { + using var imgFrame = new Image(width, height); + DrawFrame(imgs, imgFrame, j); + + var frameToAdd = imgFrame.Frames[0]; + frameToAdd.Metadata.GetGifMetadata().DisposalMethod = GifDisposalMethod.RestoreToBackground; + canvas.Frames.AddFrame(frameToAdd); + } + + canvas.Frames.RemoveFrame(0); + return canvas; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs b/src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs new file mode 100644 index 0000000..1b99fa7 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs @@ -0,0 +1,33 @@ +namespace EllieBot.Extensions; + +public static class SocketMessageComponentExtensions +{ + public static async Task RespondAsync( + this SocketMessageComponent ch, + IMessageSenderService sender, + string text, + MsgType type, + bool ephemeral = false) + { + var embed = sender.CreateEmbed().WithDescription(text); + + embed = (type switch + { + MsgType.Error => embed.WithErrorColor(), + MsgType.Ok => embed.WithOkColor(), + MsgType.Pending => embed.WithPendingColor(), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }); + + await ch.RespondAsync(embeds: [embed.Build()], ephemeral: ephemeral); + } + + // embed title and optional footer overloads + + public static Task RespondConfirmAsync( + this SocketMessageComponent smc, + IMessageSenderService sender, + string text, + bool ephemeral = false) + => smc.RespondAsync(sender, text, MsgType.Ok, ephemeral); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/UserExtensions.cs b/src/EllieBot/_common/_Extensions/UserExtensions.cs new file mode 100644 index 0000000..8f81f61 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/UserExtensions.cs @@ -0,0 +1,21 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Extensions; + +public static class UserExtensions +{ + // This method is used by everything that fetches the avatar from a user + public static Uri RealAvatarUrl(this IUser usr, ushort size = 256) + => usr.AvatarId is null ? new(usr.GetDefaultAvatarUrl()) : new Uri(usr.GetAvatarUrl(ImageFormat.Auto, size)); + + // This method is only used for the xp card + public static Uri? RealAvatarUrl(this DiscordUser usr) + { + if (!string.IsNullOrWhiteSpace(usr.AvatarId)) + return new Uri(CDN.GetUserAvatarUrl(usr.UserId, usr.AvatarId, 128, ImageFormat.Png)); + + return Uri.TryCreate(CDN.GetDefaultUserAvatarUrl(usr.UserId), UriKind.Absolute, out var uri) + ? uri + : null; + } +} \ No newline at end of file