diff --git a/Ellie.sln b/Ellie.sln index 0048726..62167f6 100644 --- a/Ellie.sln +++ b/Ellie.sln @@ -26,10 +26,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Coordinator", "src\El EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Generators.Strings", "src\Ellie.Bot.Generators.Strings\Ellie.Bot.Generators.Strings.csproj", "{11DE9EB6-2793-4540-BE66-701D2D02903A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.VotesApi", "src\Ellie.VotesApi\Ellie.VotesApi.csproj", "{8D996036-52D1-4B11-B7D7-6F853A907EDD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.VotesApi", "src\Ellie.VotesApi\Ellie.VotesApi.csproj", "{8D996036-52D1-4B11-B7D7-6F853A907EDD}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Marmalade", "src\Ellie.Marmalade\Ellie.Marmalade.csproj", "{D6CF9ABE-205E-4699-90CA-0F18ED236490}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellise.Common", "src\Ellise.Common\Ellise.Common.csproj", "{227F78CC-633E-4B1F-A12B-DF8BFF30549C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,10 +50,6 @@ Global {6A8CE149-3808-474F-A2E6-B89825BB5DC2}.Debug|Any CPU.Build.0 = Debug|Any CPU {6A8CE149-3808-474F-A2E6-B89825BB5DC2}.Release|Any CPU.ActiveCfg = Release|Any CPU {6A8CE149-3808-474F-A2E6-B89825BB5DC2}.Release|Any CPU.Build.0 = Release|Any CPU - {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Release|Any CPU.Build.0 = Release|Any CPU {44BE7271-BABE-46BE-BB41-A5B6F1116C21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {44BE7271-BABE-46BE-BB41-A5B6F1116C21}.Debug|Any CPU.Build.0 = Debug|Any CPU {44BE7271-BABE-46BE-BB41-A5B6F1116C21}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -64,6 +62,14 @@ Global {8D996036-52D1-4B11-B7D7-6F853A907EDD}.Debug|Any CPU.Build.0 = Debug|Any CPU {8D996036-52D1-4B11-B7D7-6F853A907EDD}.Release|Any CPU.ActiveCfg = Release|Any CPU {8D996036-52D1-4B11-B7D7-6F853A907EDD}.Release|Any CPU.Build.0 = Release|Any CPU + {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6CF9ABE-205E-4699-90CA-0F18ED236490}.Release|Any CPU.Build.0 = Release|Any CPU + {227F78CC-633E-4B1F-A12B-DF8BFF30549C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {227F78CC-633E-4B1F-A12B-DF8BFF30549C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {227F78CC-633E-4B1F-A12B-DF8BFF30549C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {227F78CC-633E-4B1F-A12B-DF8BFF30549C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -73,10 +79,11 @@ Global {5284415D-A43F-4539-9483-410124199743} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {34E6D136-B151-4B6E-A8E7-7A2FB7B06CA3} = {5284415D-A43F-4539-9483-410124199743} {6A8CE149-3808-474F-A2E6-B89825BB5DC2} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} - {D6CF9ABE-205E-4699-90CA-0F18ED236490} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {44BE7271-BABE-46BE-BB41-A5B6F1116C21} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {11DE9EB6-2793-4540-BE66-701D2D02903A} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {8D996036-52D1-4B11-B7D7-6F853A907EDD} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} + {D6CF9ABE-205E-4699-90CA-0F18ED236490} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} + {227F78CC-633E-4B1F-A12B-DF8BFF30549C} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {878761F1-C7B5-4D38-A00D-3377D703EBBA} diff --git a/src/Ellise.Common/AsyncLazy.cs b/src/Ellise.Common/AsyncLazy.cs new file mode 100644 index 0000000..d75da4e --- /dev/null +++ b/src/Ellise.Common/AsyncLazy.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; + +namespace Ellise.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/Ellise.Common/Cache/BotCacheExtensions.cs b/src/Ellise.Common/Cache/BotCacheExtensions.cs new file mode 100644 index 0000000..087f149 --- /dev/null +++ b/src/Ellise.Common/Cache/BotCacheExtensions.cs @@ -0,0 +1,46 @@ +using OneOf; +using OneOf.Types; + +namespace Ellise.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; + } +} diff --git a/src/Ellise.Common/Cache/IBotCache.cs b/src/Ellise.Common/Cache/IBotCache.cs new file mode 100644 index 0000000..c442ea5 --- /dev/null +++ b/src/Ellise.Common/Cache/IBotCache.cs @@ -0,0 +1,47 @@ +using OneOf; +using OneOf.Types; + +namespace Ellise.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); +} diff --git a/src/Ellise.Common/Cache/MemoryBotCache.cs b/src/Ellise.Common/Cache/MemoryBotCache.cs new file mode 100644 index 0000000..6c47280 --- /dev/null +++ b/src/Ellise.Common/Cache/MemoryBotCache.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Caching.Memory; +using OneOf; +using OneOf.Types; + +// ReSharper disable InconsistentlySynchronizedField + +namespace Ellise.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); + } + } +} diff --git a/src/Ellise.Common/Collections/ConcurrentHashSet.cs b/src/Ellise.Common/Collections/ConcurrentHashSet.cs new file mode 100644 index 0000000..19986be --- /dev/null +++ b/src/Ellise.Common/Collections/ConcurrentHashSet.cs @@ -0,0 +1,88 @@ +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); + + if (arrayIndex < 0) + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + + if (arrayIndex >= array.Length) + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + + 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/Ellise.Common/Collections/IndexedCollection.cs b/src/Ellise.Common/Collections/IndexedCollection.cs new file mode 100644 index 0000000..172639f --- /dev/null +++ b/src/Ellise.Common/Collections/IndexedCollection.cs @@ -0,0 +1,148 @@ +using System.Collections; + +namespace Ellise.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(); +} diff --git a/src/Ellise.Common/EllieRandom.cs b/src/Ellise.Common/EllieRandom.cs new file mode 100644 index 0000000..d144947 --- /dev/null +++ b/src/Ellise.Common/EllieRandom.cs @@ -0,0 +1,69 @@ +#nullable disable +using System.Security.Cryptography; + +namespace Ellise.Common; + +public 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)); + } + + public override int Next(int maxValue) + { + if (maxValue <= 0) + throw new ArgumentOutOfRangeException(nameof(maxValue)); + var bytes = new byte[sizeof(int)]; + _rng.GetBytes(bytes); + return Math.Abs(BitConverter.ToInt32(bytes, 0)) % maxValue; + } + + public override int Next(int minValue, int maxValue) + { + if (minValue > maxValue) + throw new ArgumentOutOfRangeException(nameof(maxValue)); + if (minValue == maxValue) + return minValue; + var bytes = new byte[sizeof(int)]; + _rng.GetBytes(bytes); + var sign = Math.Sign(BitConverter.ToInt32(bytes, 0)); + return (sign * BitConverter.ToInt32(bytes, 0) % (maxValue - minValue)) + minValue; + } + + public long NextLong(long minValue, long maxValue) + { + if (minValue > maxValue) + throw new ArgumentOutOfRangeException(nameof(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/Ellise.Common/Ellise.Common.csproj b/src/Ellise.Common/Ellise.Common.csproj new file mode 100644 index 0000000..6044303 --- /dev/null +++ b/src/Ellise.Common/Ellise.Common.csproj @@ -0,0 +1,17 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + diff --git a/src/Ellise.Common/Extensions/ArrayExtensions.cs b/src/Ellise.Common/Extensions/ArrayExtensions.cs new file mode 100644 index 0000000..8125903 --- /dev/null +++ b/src/Ellise.Common/Extensions/ArrayExtensions.cs @@ -0,0 +1,51 @@ +namespace Ellise.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; + } +} diff --git a/src/Ellise.Common/Extensions/EnumerableExtensions.cs b/src/Ellise.Common/Extensions/EnumerableExtensions.cs new file mode 100644 index 0000000..45fef87 --- /dev/null +++ b/src/Ellise.Common/Extensions/EnumerableExtensions.cs @@ -0,0 +1,109 @@ +using System.Security.Cryptography; + +namespace Ellise.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))); + + + // todo have 2 different shuffles + /// + /// Randomize element order by performing the Fisher-Yates shuffle + /// + /// Item type + /// Items to shuffle + public static IReadOnlyList Shuffle(this IEnumerable items) + { + using var provider = RandomNumberGenerator.Create(); + var list = items.ToList(); + var n = list.Count; + while (n > 1) + { + var box = new byte[(n / byte.MaxValue) + 1]; + int boxSum; + do + { + provider.GetBytes(box); + boxSum = box.Sum(b => b); + } while (!(boxSum < n * (byte.MaxValue * box.Length / n))); + + var k = boxSum % n; + 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); + + 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); +} diff --git a/src/Ellise.Common/Extensions/Extensions.cs b/src/Ellise.Common/Extensions/Extensions.cs new file mode 100644 index 0000000..6861b3e --- /dev/null +++ b/src/Ellise.Common/Extensions/Extensions.cs @@ -0,0 +1,7 @@ +namespace Ellise.Common; + +public static class Extensions +{ + public static long ToTimestamp(this in DateTime value) + => (value.Ticks - 621355968000000000) / 10000000; +} diff --git a/src/Ellise.Common/Extensions/HttpClientExtensions.cs b/src/Ellise.Common/Extensions/HttpClientExtensions.cs new file mode 100644 index 0000000..0d22ad4 --- /dev/null +++ b/src/Ellise.Common/Extensions/HttpClientExtensions.cs @@ -0,0 +1,35 @@ +using System.Net.Http.Headers; + +namespace Ellise.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; +} diff --git a/src/Ellise.Common/Extensions/OneOfExtensions.cs b/src/Ellise.Common/Extensions/OneOfExtensions.cs new file mode 100644 index 0000000..09d66e9 --- /dev/null +++ b/src/Ellise.Common/Extensions/OneOfExtensions.cs @@ -0,0 +1,10 @@ +using OneOf.Types; +using OneOf; + +namespace Ellise.Common; + +public static class OneOfExtensions +{ + public static bool TryGetValue(this OneOf oneOf, out T value) + => oneOf.TryPickT0(out value, out _); +} diff --git a/src/Ellise.Common/Extensions/PipeExtensions.cs b/src/Ellise.Common/Extensions/PipeExtensions.cs new file mode 100644 index 0000000..2ef6a17 --- /dev/null +++ b/src/Ellise.Common/Extensions/PipeExtensions.cs @@ -0,0 +1,22 @@ +namespace Ellise.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); +} diff --git a/src/Ellise.Common/Extensions/StringExtensions.cs b/src/Ellise.Common/Extensions/StringExtensions.cs new file mode 100644 index 0000000..6154bbb --- /dev/null +++ b/src/Ellise.Common/Extensions/StringExtensions.cs @@ -0,0 +1,139 @@ +using Ellie.Common.Yml; +using System.Text; +using System.Text.RegularExpressions; +using Humanizer; +using Ellise.Common; + +namespace Ellie.Extensions; + +public static class StringExtensions +{ + private static readonly HashSet _lettersAndDigits = new(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) + => hideDots ? str?.Truncate(maxLength, string.Empty) : str?.Truncate(maxLength); + + public static string ToTitleCase(this string str) + { + var tokens = str.Split(new[] { " " }, 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 = YamlHelper.UnescapeUnicodeCodePoint(str); + return newString; + }); +} diff --git a/src/Ellise.Common/GlobalUsings.cs b/src/Ellise.Common/GlobalUsings.cs new file mode 100644 index 0000000..3635c8a --- /dev/null +++ b/src/Ellise.Common/GlobalUsings.cs @@ -0,0 +1 @@ +global using NonBlocking; \ No newline at end of file diff --git a/src/Ellise.Common/Helpers/LogSetup.cs b/src/Ellise.Common/Helpers/LogSetup.cs new file mode 100644 index 0000000..4139c65 --- /dev/null +++ b/src/Ellise.Common/Helpers/LogSetup.cs @@ -0,0 +1,36 @@ +using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; +using System.Text; +using Serilog; + +namespace Ellise.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/Ellise.Common/Helpers/StandardConversions.cs b/src/Ellise.Common/Helpers/StandardConversions.cs new file mode 100644 index 0000000..0c9b5dd --- /dev/null +++ b/src/Ellise.Common/Helpers/StandardConversions.cs @@ -0,0 +1,7 @@ +namespace Ellise.Common; + +public static class StandardConversions +{ + public static double CelsiusToFahrenheit(double cel) + => (cel * 1.8f) + 32; +} diff --git a/src/Ellise.Common/Kwum.cs b/src/Ellise.Common/Kwum.cs new file mode 100644 index 0000000..7e76263 --- /dev/null +++ b/src/Ellise.Common/Kwum.cs @@ -0,0 +1,100 @@ +using System.Runtime.CompilerServices; + +namespace Ellise.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(); +} diff --git a/src/Ellise.Common/PubSub/EventPubSub.cs b/src/Ellise.Common/PubSub/EventPubSub.cs new file mode 100644 index 0000000..630c703 --- /dev/null +++ b/src/Ellise.Common/PubSub/EventPubSub.cs @@ -0,0 +1,80 @@ +namespace Ellise.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/Ellise.Common/PubSub/IPubSub.cs b/src/Ellise.Common/PubSub/IPubSub.cs new file mode 100644 index 0000000..c5bcefd --- /dev/null +++ b/src/Ellise.Common/PubSub/IPubSub.cs @@ -0,0 +1,10 @@ +namespace Ellise.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/Ellise.Common/PubSub/ISeria.cs b/src/Ellise.Common/PubSub/ISeria.cs new file mode 100644 index 0000000..d1bfa6b --- /dev/null +++ b/src/Ellise.Common/PubSub/ISeria.cs @@ -0,0 +1,7 @@ +namespace Ellise.Common; + +public interface ISeria +{ + byte[] Serialize(T data); + T? Deserialize(byte[] data); +} \ No newline at end of file diff --git a/src/Ellise.Common/QueueRunner.cs b/src/Ellise.Common/QueueRunner.cs new file mode 100644 index 0000000..7c365d5 --- /dev/null +++ b/src/Ellise.Common/QueueRunner.cs @@ -0,0 +1,63 @@ +using System.Threading.Channels; +using Serilog; + +namespace Ellise.Common; + +public sealed class QueueRunner +{ + private readonly Channel> _channel; + private readonly int _delayMs; + + public QueueRunner(int delayMs = 0, int maxCapacity = -1) + { + if (delayMs < 0) + throw new ArgumentOutOfRangeException(nameof(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); +} diff --git a/src/Ellise.Common/TypedKey.cs b/src/Ellise.Common/TypedKey.cs new file mode 100644 index 0000000..fe4d7f6 --- /dev/null +++ b/src/Ellise.Common/TypedKey.cs @@ -0,0 +1,30 @@ +namespace Ellise.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/Ellise.Common/YamlHelper.cs b/src/Ellise.Common/YamlHelper.cs new file mode 100644 index 0000000..b0642af --- /dev/null +++ b/src/Ellise.Common/YamlHelper.cs @@ -0,0 +1,48 @@ +#nullable disable +namespace Ellie.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; + } +}