Added the removed common files
This commit is contained in:
parent
6b1d961642
commit
f18808fb1c
194 changed files with 9929 additions and 0 deletions
19
src/EllieBot/_common/Abstractions/AsyncLazy.cs
Normal file
19
src/EllieBot/_common/Abstractions/AsyncLazy.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Ellie.Common;
|
||||
|
||||
public class AsyncLazy<T> : Lazy<Task<T>>
|
||||
{
|
||||
public AsyncLazy(Func<T> valueFactory)
|
||||
: base(() => Task.Run(valueFactory))
|
||||
{
|
||||
}
|
||||
|
||||
public AsyncLazy(Func<Task<T>> taskFactory)
|
||||
: base(() => Task.Run(taskFactory))
|
||||
{
|
||||
}
|
||||
|
||||
public TaskAwaiter<T> GetAwaiter()
|
||||
=> Value.GetAwaiter();
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace Ellie.Common;
|
||||
|
||||
public static class BotCacheExtensions
|
||||
{
|
||||
public static async ValueTask<T?> GetOrDefaultAsync<T>(this IBotCache cache, TypedKey<T> key)
|
||||
{
|
||||
var result = await cache.GetAsync(key);
|
||||
if (result.TryGetValue(out var val))
|
||||
return val;
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
private static TypedKey<byte[]> GetImgKey(Uri uri)
|
||||
=> new($"image:{uri}");
|
||||
|
||||
public static ValueTask SetImageDataAsync(this IBotCache c, string key, byte[] data)
|
||||
=> c.SetImageDataAsync(new Uri(key), data);
|
||||
public static async ValueTask SetImageDataAsync(this IBotCache c, Uri key, byte[] data)
|
||||
=> await c.AddAsync(GetImgKey(key), data, expiry: TimeSpan.FromHours(48));
|
||||
|
||||
public static async ValueTask<OneOf<byte[], None>> GetImageDataAsync(this IBotCache c, Uri key)
|
||||
=> await c.GetAsync(GetImgKey(key));
|
||||
|
||||
public static async Task<TimeSpan?> GetRatelimitAsync(
|
||||
this IBotCache c,
|
||||
TypedKey<long> key,
|
||||
TimeSpan length)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var nowB = now.ToBinary();
|
||||
|
||||
var cachedValue = await c.GetOrAddAsync(key,
|
||||
() => Task.FromResult(now.ToBinary()),
|
||||
expiry: length);
|
||||
|
||||
if (cachedValue == nowB)
|
||||
return null;
|
||||
|
||||
var diff = now - DateTime.FromBinary(cachedValue);
|
||||
return length - diff;
|
||||
}
|
||||
}
|
47
src/EllieBot/_common/Abstractions/Cache/IBotCache.cs
Normal file
47
src/EllieBot/_common/Abstractions/Cache/IBotCache.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace Ellie.Common;
|
||||
|
||||
public interface IBotCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds an item to the cache
|
||||
/// </summary>
|
||||
/// <param name="key">Key to add</param>
|
||||
/// <param name="value">Value to add to the cache</param>
|
||||
/// <param name="expiry">Optional expiry</param>
|
||||
/// <param name="overwrite">Whether old value should be overwritten</param>
|
||||
/// <typeparam name="T">Type of the value</typeparam>
|
||||
/// <returns>Returns whether add was sucessful. Always true unless ovewrite = false</returns>
|
||||
ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true);
|
||||
|
||||
/// <summary>
|
||||
/// Get an element from the cache
|
||||
/// </summary>
|
||||
/// <param name="key">Key</param>
|
||||
/// <typeparam name="T">Type of the value</typeparam>
|
||||
/// <returns>Either a value or <see cref="None"/></returns>
|
||||
ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key);
|
||||
|
||||
/// <summary>
|
||||
/// Remove a key from the cache
|
||||
/// </summary>
|
||||
/// <param name="key">Key to remove</param>
|
||||
/// <typeparam name="T">Type of the value</typeparam>
|
||||
/// <returns>Whether there was item</returns>
|
||||
ValueTask<bool> RemoveAsync<T>(TypedKey<T> key);
|
||||
|
||||
/// <summary>
|
||||
/// Get the key if it exists or add a new one
|
||||
/// </summary>
|
||||
/// <param name="key">Key to get and potentially add</param>
|
||||
/// <param name="createFactory">Value creation factory</param>
|
||||
/// <param name="expiry">Optional expiry</param>
|
||||
/// <typeparam name="T">Type of the value</typeparam>
|
||||
/// <returns>The retrieved or newly added value</returns>
|
||||
ValueTask<T?> GetOrAddAsync<T>(
|
||||
TypedKey<T> key,
|
||||
Func<Task<T?>> createFactory,
|
||||
TimeSpan? expiry = null);
|
||||
}
|
71
src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs
Normal file
71
src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs
Normal file
|
@ -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<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true)
|
||||
{
|
||||
if (overwrite)
|
||||
{
|
||||
using var item = _cache.CreateEntry(key.Key);
|
||||
item.Value = value;
|
||||
item.AbsoluteExpirationRelativeToNow = expiry;
|
||||
return new(true);
|
||||
}
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_cache.TryGetValue(key.Key, out var old) && old is not null)
|
||||
return new(false);
|
||||
|
||||
using var item = _cache.CreateEntry(key.Key);
|
||||
item.Value = value;
|
||||
item.AbsoluteExpirationRelativeToNow = expiry;
|
||||
return new(true);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<T?> GetOrAddAsync<T>(
|
||||
TypedKey<T> key,
|
||||
Func<Task<T?>> createFactory,
|
||||
TimeSpan? expiry = null)
|
||||
=> await _cache.GetOrCreateAsync(key.Key,
|
||||
async ce =>
|
||||
{
|
||||
ce.AbsoluteExpirationRelativeToNow = expiry;
|
||||
var val = await createFactory();
|
||||
return val;
|
||||
});
|
||||
|
||||
public ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key)
|
||||
{
|
||||
if (!_cache.TryGetValue(key.Key, out var val) || val is null)
|
||||
return new(new None());
|
||||
|
||||
return new((T)val);
|
||||
}
|
||||
|
||||
public ValueTask<bool> RemoveAsync<T>(TypedKey<T> key)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
var toReturn = _cache.TryGetValue(key.Key, out var old ) && old is not null;
|
||||
_cache.Remove(key.Key);
|
||||
return new(toReturn);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace System.Collections.Generic;
|
||||
|
||||
[DebuggerDisplay("{_backingStore.Count}")]
|
||||
public sealed class ConcurrentHashSet<T> : IReadOnlyCollection<T>, ICollection<T> where T : notnull
|
||||
{
|
||||
private readonly ConcurrentDictionary<T, bool> _backingStore;
|
||||
|
||||
public ConcurrentHashSet()
|
||||
=> _backingStore = new();
|
||||
|
||||
public ConcurrentHashSet(IEnumerable<T> values, IEqualityComparer<T>? comparer = null)
|
||||
=> _backingStore = new(values.Select(x => new KeyValuePair<T, bool>(x, true)), comparer);
|
||||
|
||||
public IEnumerator<T> GetEnumerator()
|
||||
=> _backingStore.Keys.GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
=> GetEnumerator();
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified item to the <see cref="ConcurrentHashSet{T}" />.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to add.</param>
|
||||
/// <returns>
|
||||
/// true if the items was added to the <see cref="ConcurrentHashSet{T}" />
|
||||
/// successfully; false if it already exists.
|
||||
/// </returns>
|
||||
/// <exception cref="T:System.OverflowException">
|
||||
/// The <see cref="ConcurrentHashSet{T}" />
|
||||
/// contains too many items.
|
||||
/// </exception>
|
||||
public bool Add(T item)
|
||||
=> _backingStore.TryAdd(item, true);
|
||||
|
||||
void ICollection<T>.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<T>.Remove(T item)
|
||||
=> TryRemove(item);
|
||||
|
||||
public bool TryRemove(T item)
|
||||
=> _backingStore.TryRemove(item, out _);
|
||||
|
||||
public void RemoveWhere(Func<T, bool> predicate)
|
||||
{
|
||||
foreach (var elem in this.Where(predicate))
|
||||
TryRemove(elem);
|
||||
}
|
||||
|
||||
public int Count
|
||||
=> _backingStore.Count;
|
||||
|
||||
public bool IsReadOnly
|
||||
=> false;
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
using System.Collections;
|
||||
|
||||
namespace Ellie.Common;
|
||||
|
||||
public interface IIndexed
|
||||
{
|
||||
int Index { get; set; }
|
||||
}
|
||||
|
||||
public class IndexedCollection<T> : IList<T>
|
||||
where T : class, IIndexed
|
||||
{
|
||||
public List<T> 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<T> source)
|
||||
{
|
||||
lock (_locker)
|
||||
{
|
||||
Source = source.OrderBy(x => x.Index).ToList();
|
||||
UpdateIndexes();
|
||||
}
|
||||
}
|
||||
|
||||
public int IndexOf(T item)
|
||||
=> item?.Index ?? -1;
|
||||
|
||||
public IEnumerator<T> 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<T>(IndexedCollection<T> x)
|
||||
=> x.Source;
|
||||
|
||||
public List<T> ToList()
|
||||
=> Source.ToList();
|
||||
}
|
66
src/EllieBot/_common/Abstractions/EllieRandom.cs
Normal file
66
src/EllieBot/_common/Abstractions/EllieRandom.cs
Normal file
|
@ -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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random integer between 0 (inclusive) and
|
||||
/// a specified exclusive upper bound using a cryptographically strong random number generator.
|
||||
/// </summary>
|
||||
/// <param name="maxValue">Exclusive max value</param>
|
||||
/// <returns>A random number</returns>
|
||||
public override int Next(int maxValue)
|
||||
=> RandomNumberGenerator.GetInt32(maxValue);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random integer between a specified inclusive lower bound and a
|
||||
/// specified exclusive upper bound using a cryptographically strong random number generator.
|
||||
/// </summary>
|
||||
/// <param name="minValue">Inclusive min value</param>
|
||||
/// <param name="maxValue">Exclusive max value</param>
|
||||
/// <returns>A random number</returns>
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new array from the old array + new element at the end
|
||||
/// </summary>
|
||||
/// <param name="input">Input array</param>
|
||||
/// <param name="added">Item to add to the end of the output array</param>
|
||||
/// <typeparam name="T">Type of the array</typeparam>
|
||||
/// <returns>A new array with the new element at the end</returns>
|
||||
public static T[] With<T>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new array by applying the specified function to every element in the input array
|
||||
/// </summary>
|
||||
/// <param name="arr">Array to modify</param>
|
||||
/// <param name="f">Function to apply</param>
|
||||
/// <typeparam name="TIn">Orignal type of the elements in the array</typeparam>
|
||||
/// <typeparam name="TOut">Output type of the elements of the array</typeparam>
|
||||
/// <returns>New array with updated elements</returns>
|
||||
public static TOut[] Map<TIn, TOut>(this TIn[] arr, Func<TIn, TOut> f)
|
||||
=> Array.ConvertAll(arr, x => f(x));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new array by applying the specified function to every element in the input array
|
||||
/// </summary>
|
||||
/// <param name="col">Array to modify</param>
|
||||
/// <param name="f">Function to apply</param>
|
||||
/// <typeparam name="TIn">Orignal type of the elements in the array</typeparam>
|
||||
/// <typeparam name="TOut">Output type of the elements of the array</typeparam>
|
||||
/// <returns>New array with updated elements</returns>
|
||||
public static TOut[] Map<TIn, TOut>(this IReadOnlyCollection<TIn> col, Func<TIn, TOut> 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<T>(this T[] data)
|
||||
{
|
||||
if (data.Length == 0)
|
||||
return default;
|
||||
|
||||
var index = RandomNumberGenerator.GetInt32(0, data.Length);
|
||||
return data[index];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
using System.Security.Cryptography;
|
||||
|
||||
namespace Ellie.Common;
|
||||
|
||||
public static class EnumerableExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Concatenates the members of a collection, using the specified separator between each member.
|
||||
/// </summary>
|
||||
/// <param name="data">Collection to join</param>
|
||||
/// <param name="separator">
|
||||
/// The character to use as a separator. separator is included in the returned string only if
|
||||
/// values has more than one element.
|
||||
/// </param>
|
||||
/// <param name="func">Optional transformation to apply to each element before concatenation.</param>
|
||||
/// <typeparam name="T">The type of the members of values.</typeparam>
|
||||
/// <returns>
|
||||
/// A string that consists of the members of values delimited by the separator character. -or- Empty if values has
|
||||
/// no elements.
|
||||
/// </returns>
|
||||
public static string Join<T>(this IEnumerable<T> data, char separator, Func<T, string>? func = null)
|
||||
=> string.Join(separator, data.Select(func ?? (x => x?.ToString() ?? string.Empty)));
|
||||
|
||||
/// <summary>
|
||||
/// Concatenates the members of a collection, using the specified separator between each member.
|
||||
/// </summary>
|
||||
/// <param name="data">Collection to join</param>
|
||||
/// <param name="separator">
|
||||
/// The string to use as a separator.separator is included in the returned string only if values
|
||||
/// has more than one element.
|
||||
/// </param>
|
||||
/// <param name="func">Optional transformation to apply to each element before concatenation.</param>
|
||||
/// <typeparam name="T">The type of the members of values.</typeparam>
|
||||
/// <returns>
|
||||
/// A string that consists of the members of values delimited by the separator character. -or- Empty if values has
|
||||
/// no elements.
|
||||
/// </returns>
|
||||
public static string Join<T>(this IEnumerable<T> data, string separator, Func<T, string>? func = null)
|
||||
=> string.Join(separator, data.Select(func ?? (x => x?.ToString() ?? string.Empty)));
|
||||
|
||||
/// <summary>
|
||||
/// Randomize element order by performing the Fisher-Yates shuffle
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Item type</typeparam>
|
||||
/// <param name="items">Items to shuffle</param>
|
||||
public static IReadOnlyList<T> Shuffle<T>(this IEnumerable<T> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConcurrentDictionary{TKey,TValue}" /> class
|
||||
/// that contains elements copied from the specified <see cref="IEnumerable{T}" />
|
||||
/// has the default concurrency level, has the default initial capacity,
|
||||
/// and uses the default comparer for the key type.
|
||||
/// </summary>
|
||||
/// <param name="dict">
|
||||
/// The <see cref="IEnumerable{T}" /> whose elements are copied to the new
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}" />.
|
||||
/// </param>
|
||||
/// <returns>A new instance of the <see cref="ConcurrentDictionary{TKey,TValue}" /> class</returns>
|
||||
public static ConcurrentDictionary<TKey, TValue> ToConcurrent<TKey, TValue>(
|
||||
this IEnumerable<KeyValuePair<TKey, TValue>> dict)
|
||||
where TKey : notnull
|
||||
=> new(dict);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConcurrentDictionary{TKey,TValue}" /> class
|
||||
/// that contains elements copied from the specified <see cref="IEnumerable{T}" />
|
||||
/// has the default concurrency level, has the default initial capacity,
|
||||
/// and uses the default comparer for the key type.
|
||||
/// </summary>
|
||||
/// <param name="dict">
|
||||
/// The <see cref="IEnumerable{T}" /> whose elements are copied to the new
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}" />.
|
||||
/// </param>
|
||||
/// <returns>A new instance of the <see cref="ConcurrentDictionary{TKey,TValue}" /> class</returns>
|
||||
public static ConcurrentHashSet<TValue> ToConcurrentSet<TValue>(
|
||||
this IReadOnlyCollection<TValue> dict)
|
||||
where TValue : notnull
|
||||
=> new(dict);
|
||||
|
||||
public static IndexedCollection<T> ToIndexed<T>(this IEnumerable<T> enumerable)
|
||||
where T : class, IIndexed
|
||||
=> new(enumerable);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a task that will complete when all of the <see cref="Task{TResult}" /> objects in an enumerable
|
||||
/// collection have completed
|
||||
/// </summary>
|
||||
/// <param name="tasks">The tasks to wait on for completion.</param>
|
||||
/// <typeparam name="TResult">The type of the completed task.</typeparam>
|
||||
/// <returns>A task that represents the completion of all of the supplied tasks.</returns>
|
||||
public static Task<TResult[]> WhenAll<TResult>(this IEnumerable<Task<TResult>> tasks)
|
||||
=> Task.WhenAll(tasks);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a task that will complete when all of the <see cref="Task" /> objects in an enumerable
|
||||
/// collection have completed
|
||||
/// </summary>
|
||||
/// <param name="tasks">The tasks to wait on for completion.</param>
|
||||
/// <returns>A task that represents the completion of all of the supplied tasks.</returns>
|
||||
public static Task WhenAll(this IEnumerable<Task> tasks)
|
||||
=> Task.WhenAll(tasks);
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace Ellie.Common;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
public static long ToTimestamp(this in DateTime value)
|
||||
=> (value.Ticks - 621355968000000000) / 10000000;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
using OneOf.Types;
|
||||
using OneOf;
|
||||
|
||||
namespace Ellie.Common;
|
||||
|
||||
public static class OneOfExtensions
|
||||
{
|
||||
public static bool TryGetValue<T>(this OneOf<T, None> oneOf, out T value)
|
||||
=> oneOf.TryPickT0(out value, out _);
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
namespace Ellie.Common;
|
||||
|
||||
public delegate TOut PipeFunc<TIn, out TOut>(in TIn a);
|
||||
public delegate TOut PipeFunc<TIn1, TIn2, out TOut>(in TIn1 a, in TIn2 b);
|
||||
|
||||
public static class PipeExtensions
|
||||
{
|
||||
public static TOut Pipe<TIn, TOut>(this TIn a, Func<TIn, TOut> fn)
|
||||
=> fn(a);
|
||||
|
||||
public static TOut Pipe<TIn, TOut>(this TIn a, PipeFunc<TIn, TOut> fn)
|
||||
=> fn(a);
|
||||
|
||||
public static TOut Pipe<TIn1, TIn2, TOut>(this (TIn1, TIn2) a, PipeFunc<TIn1, TIn2, TOut> fn)
|
||||
=> fn(a.Item1, a.Item2);
|
||||
|
||||
public static (TIn, TExtra) With<TIn, TExtra>(this TIn a, TExtra b)
|
||||
=> (a, b);
|
||||
|
||||
public static async Task<TOut> Pipe<TIn, TOut>(this Task<TIn> a, Func<TIn, TOut> fn)
|
||||
=> fn(await a);
|
||||
}
|
151
src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs
Normal file
151
src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs
Normal file
|
@ -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<char> _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(?<code>[a-zA-Z0-9]{8})|\\u(?<code>[a-zA-Z0-9]{4})|\\x(?<code>[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<Stream> 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;
|
||||
});
|
||||
|
||||
}
|
35
src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs
Normal file
35
src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs
Normal file
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace Ellie.Common;
|
||||
|
||||
public static class StandardConversions
|
||||
{
|
||||
public static double CelsiusToFahrenheit(double cel)
|
||||
=> (cel * 1.8f) + 32;
|
||||
}
|
100
src/EllieBot/_common/Abstractions/Kwum.cs
Normal file
100
src/EllieBot/_common/Abstractions/Kwum.cs
Normal file
|
@ -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<kwum>
|
||||
#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<char> 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<char> 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<char> 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();
|
||||
}
|
80
src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs
Normal file
80
src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs
Normal file
|
@ -0,0 +1,80 @@
|
|||
namespace Ellie.Common;
|
||||
|
||||
public class EventPubSub : IPubSub
|
||||
{
|
||||
private readonly Dictionary<string, Dictionary<Delegate, List<Func<object, ValueTask>>>> _actions = new();
|
||||
private readonly object _locker = new();
|
||||
|
||||
public Task Sub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
|
||||
where TData : notnull
|
||||
{
|
||||
Func<object, ValueTask> localAction = obj => action((TData)obj);
|
||||
lock (_locker)
|
||||
{
|
||||
if (!_actions.TryGetValue(key.Key, out var keyActions))
|
||||
{
|
||||
keyActions = new();
|
||||
_actions[key.Key] = keyActions;
|
||||
}
|
||||
|
||||
if (!keyActions.TryGetValue(action, out var sameActions))
|
||||
{
|
||||
sameActions = new();
|
||||
keyActions[action] = sameActions;
|
||||
}
|
||||
|
||||
sameActions.Add(localAction);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task Pub<TData>(in TypedKey<TData> key, TData data)
|
||||
where TData : notnull
|
||||
{
|
||||
lock (_locker)
|
||||
{
|
||||
if (_actions.TryGetValue(key.Key, out var actions))
|
||||
// if this class ever gets used, this needs to be properly implemented
|
||||
// 1. ignore all valuetasks which are completed
|
||||
// 2. run all other tasks in parallel
|
||||
return actions.SelectMany(kvp => kvp.Value).Select(action => action(data).AsTask()).WhenAll();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task Unsub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
|
||||
{
|
||||
lock (_locker)
|
||||
{
|
||||
// get subscriptions for this action
|
||||
if (_actions.TryGetValue(key.Key, out var actions))
|
||||
// get subscriptions which have the same action hash code
|
||||
// note: having this as a list allows for multiple subscriptions of
|
||||
// the same insance's/static method
|
||||
{
|
||||
if (actions.TryGetValue(action, out var sameActions))
|
||||
{
|
||||
// remove last subscription
|
||||
sameActions.RemoveAt(sameActions.Count - 1);
|
||||
|
||||
// if the last subscription was the only subscription
|
||||
// we can safely remove this action's dictionary entry
|
||||
if (sameActions.Count == 0)
|
||||
{
|
||||
actions.Remove(action);
|
||||
|
||||
// if our dictionary has no more elements after
|
||||
// removing the entry
|
||||
// it's safe to remove it from the key's subscriptions
|
||||
if (actions.Count == 0)
|
||||
_actions.Remove(key.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
10
src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs
Normal file
10
src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace Ellie.Common;
|
||||
|
||||
public interface IPubSub
|
||||
{
|
||||
public Task Pub<TData>(in TypedKey<TData> key, TData data)
|
||||
where TData : notnull;
|
||||
|
||||
public Task Sub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
|
||||
where TData : notnull;
|
||||
}
|
7
src/EllieBot/_common/Abstractions/PubSub/ISeria.cs
Normal file
7
src/EllieBot/_common/Abstractions/PubSub/ISeria.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace Ellie.Common;
|
||||
|
||||
public interface ISeria
|
||||
{
|
||||
byte[] Serialize<T>(T data);
|
||||
T? Deserialize<T>(byte[]? data);
|
||||
}
|
61
src/EllieBot/_common/Abstractions/QueueRunner.cs
Normal file
61
src/EllieBot/_common/Abstractions/QueueRunner.cs
Normal file
|
@ -0,0 +1,61 @@
|
|||
using System.Threading.Channels;
|
||||
|
||||
namespace Ellie.Common;
|
||||
|
||||
public sealed class QueueRunner
|
||||
{
|
||||
private readonly Channel<Func<Task>> _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<Func<Task>>(new UnboundedChannelOptions()
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
AllowSynchronousContinuations = true,
|
||||
}),
|
||||
_ => Channel.CreateBounded<Func<Task>>(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<Task> action)
|
||||
=> _channel.Writer.WriteAsync(action);
|
||||
}
|
30
src/EllieBot/_common/Abstractions/TypedKey.cs
Normal file
30
src/EllieBot/_common/Abstractions/TypedKey.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
namespace Ellie.Common;
|
||||
|
||||
public readonly struct TypedKey<TData>
|
||||
{
|
||||
public string Key { get; }
|
||||
|
||||
public TypedKey(in string key)
|
||||
=> Key = key;
|
||||
|
||||
public static implicit operator TypedKey<TData>(in string input)
|
||||
=> new(input);
|
||||
|
||||
public static implicit operator string(in TypedKey<TData> input)
|
||||
=> input.Key;
|
||||
|
||||
public static bool operator ==(in TypedKey<TData> left, in TypedKey<TData> right)
|
||||
=> left.Key == right.Key;
|
||||
|
||||
public static bool operator !=(in TypedKey<TData> left, in TypedKey<TData> right)
|
||||
=> !(left == right);
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
=> obj is TypedKey<TData> o && o == this;
|
||||
|
||||
public override int GetHashCode()
|
||||
=> Key?.GetHashCode() ?? 0;
|
||||
|
||||
public override string ToString()
|
||||
=> Key;
|
||||
}
|
48
src/EllieBot/_common/Abstractions/YamlHelper.cs
Normal file
48
src/EllieBot/_common/Abstractions/YamlHelper.cs
Normal file
|
@ -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
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <param name="point">Unicode code point</param>
|
||||
/// <returns>Actual character</returns>
|
||||
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;
|
||||
}
|
||||
}
|
79
src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs
Normal file
79
src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs
Normal file
|
@ -0,0 +1,79 @@
|
|||
#nullable disable
|
||||
namespace EllieBot;
|
||||
|
||||
public interface IBotCredentials
|
||||
{
|
||||
string Token { get; }
|
||||
string EllieAiToken { get; }
|
||||
ICollection<ulong> 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; }
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public interface IBotCredsProvider
|
||||
{
|
||||
public void Reload();
|
||||
public IBotCredentials GetCreds();
|
||||
public void ModifyCredsFile(Action<IBotCredentials> func);
|
||||
}
|
35
src/EllieBot/_common/Abstractions/strings/CommandStrings.cs
Normal file
35
src/EllieBot/_common/Abstractions/strings/CommandStrings.cs
Normal file
|
@ -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<string, CommandStringParam>[] 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; }
|
||||
}
|
16
src/EllieBot/_common/Abstractions/strings/IBotStrings.cs
Normal file
16
src/EllieBot/_common/Abstractions/strings/IBotStrings.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
#nullable disable
|
||||
using System.Globalization;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Defines methods to retrieve and reload bot strings
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implemented by classes which provide localized strings in their own ways
|
||||
/// </summary>
|
||||
public interface IBotStringsProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets localized string
|
||||
/// </summary>
|
||||
/// <param name="localeName">Language name</param>
|
||||
/// <param name="key">String key</param>
|
||||
/// <returns>Localized string</returns>
|
||||
string GetText(string localeName, string key);
|
||||
|
||||
/// <summary>
|
||||
/// Reloads string cache
|
||||
/// </summary>
|
||||
void Reload();
|
||||
|
||||
/// <summary>
|
||||
/// Gets command arg examples and description
|
||||
/// </summary>
|
||||
/// <param name="localeName">Language name</param>
|
||||
/// <param name="commandName">Command name</param>
|
||||
CommandStrings GetCommandStrings(string localeName, string commandName);
|
||||
}
|
16
src/EllieBot/_common/Abstractions/strings/IStringsSource.cs
Normal file
16
src/EllieBot/_common/Abstractions/strings/IStringsSource.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Basic interface used for classes implementing strings loading mechanism
|
||||
/// </summary>
|
||||
public interface IStringsSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all response strings
|
||||
/// </summary>
|
||||
/// <returns>Dictionary(localename, Dictionary(key, response))</returns>
|
||||
Dictionary<string, Dictionary<string, string>> GetResponseStrings();
|
||||
|
||||
Dictionary<string, Dictionary<string, CommandStrings>> GetCommandStrings();
|
||||
}
|
13
src/EllieBot/_common/Abstractions/strings/LocStr.cs
Normal file
13
src/EllieBot/_common/Abstractions/strings/LocStr.cs
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
10
src/EllieBot/_common/AddRemove.cs
Normal file
10
src/EllieBot/_common/AddRemove.cs
Normal file
|
@ -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
|
||||
}
|
12
src/EllieBot/_common/Attributes/AliasesAttribute.cs
Normal file
12
src/EllieBot/_common/Attributes/AliasesAttribute.cs
Normal file
|
@ -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))
|
||||
{
|
||||
}
|
||||
}
|
18
src/EllieBot/_common/Attributes/CmdAttribute.cs
Normal file
18
src/EllieBot/_common/Attributes/CmdAttribute.cs
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
11
src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs
Normal file
11
src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Classed marked with this attribute will not be added to the service provider
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public class DIIgnoreAttribute : Attribute
|
||||
{
|
||||
|
||||
}
|
7
src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs
Normal file
7
src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace EllieBot.Common.Attributes;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class EllieOptionsAttribute<TOption> : Attribute
|
||||
where TOption: IEllieCommandOptions
|
||||
{
|
||||
}
|
18
src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs
Normal file
18
src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public sealed class NoPublicBotAttribute : PreconditionAttribute
|
||||
{
|
||||
public override Task<PreconditionResult> 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
|
||||
}
|
||||
}
|
21
src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs
Normal file
21
src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs
Normal file
|
@ -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<PreconditionResult> 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
|
||||
}
|
||||
}
|
19
src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs
Normal file
19
src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs
Normal file
|
@ -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<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
var creds = services.GetRequiredService<IBotCredsProvider>().GetCreds();
|
||||
|
||||
return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id
|
||||
? PreconditionResult.FromSuccess()
|
||||
: PreconditionResult.FromError("Not owner"));
|
||||
}
|
||||
}
|
37
src/EllieBot/_common/Attributes/RatelimitAttribute.cs
Normal file
37
src/EllieBot/_common/Attributes/RatelimitAttribute.cs
Normal file
|
@ -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<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
if (Seconds == 0)
|
||||
return PreconditionResult.FromSuccess();
|
||||
|
||||
var cache = services.GetRequiredService<IBotCache>();
|
||||
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);
|
||||
}
|
||||
}
|
29
src/EllieBot/_common/Attributes/UserPermAttribute.cs
Normal file
29
src/EllieBot/_common/Attributes/UserPermAttribute.cs
Normal file
|
@ -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<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
var permService = services.GetRequiredService<IDiscordPermOverrideService>();
|
||||
if (permService.TryGetOverrides(context.Guild?.Id ?? 0, command.Name.ToUpperInvariant(), out _))
|
||||
return Task.FromResult(PreconditionResult.FromSuccess());
|
||||
|
||||
return base.CheckPermissionsAsync(context, command, services);
|
||||
}
|
||||
}
|
30
src/EllieBot/_common/BotCommandTypeReader.cs
Normal file
30
src/EllieBot/_common/BotCommandTypeReader.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common.TypeReaders;
|
||||
|
||||
public sealed class CommandTypeReader : EllieTypeReader<CommandInfo>
|
||||
{
|
||||
private readonly CommandService _cmds;
|
||||
private readonly ICommandHandler _handler;
|
||||
|
||||
public CommandTypeReader(ICommandHandler handler, CommandService cmds)
|
||||
{
|
||||
_handler = handler;
|
||||
_cmds = cmds;
|
||||
}
|
||||
|
||||
public override ValueTask<TypeReaderResult<CommandInfo>> 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<CommandInfo>(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<CommandInfo>(CommandError.ParseFailed, "No such command found."));
|
||||
|
||||
return new(TypeReaderResult.FromSuccess(cmd));
|
||||
}
|
||||
}
|
25
src/EllieBot/_common/CleanupModuleBase.cs
Normal file
25
src/EllieBot/_common/CleanupModuleBase.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public abstract class CleanupModuleBase : EllieModule
|
||||
{
|
||||
protected async Task ConfirmActionInternalAsync(string name, Func<Task> 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();
|
||||
}
|
||||
}
|
||||
}
|
10
src/EllieBot/_common/CleverBotResponseStr.cs
Normal file
10
src/EllieBot/_common/CleverBotResponseStr.cs
Normal file
|
@ -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";
|
||||
}
|
17
src/EllieBot/_common/CmdStrings.cs
Normal file
17
src/EllieBot/_common/CmdStrings.cs
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
9
src/EllieBot/_common/CommandData.cs
Normal file
9
src/EllieBot/_common/CommandData.cs
Normal file
|
@ -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; }
|
||||
}
|
40
src/EllieBot/_common/CommandNameLoadHelper.cs
Normal file
40
src/EllieBot/_common/CommandNameLoadHelper.cs
Normal file
|
@ -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<Dictionary<string, string[]>> _lazyCommandAliases
|
||||
= new(() => LoadAliases());
|
||||
|
||||
public static Dictionary<string, string[]> LoadAliases(string aliasesFilePath = "data/aliases.yml")
|
||||
{
|
||||
var text = File.ReadAllText(aliasesFilePath);
|
||||
return _deserializer.Deserialize<Dictionary<string, string[]>>(text);
|
||||
}
|
||||
|
||||
public static Dictionary<string, CommandStrings> LoadCommandStrings(
|
||||
string commandsFilePath = "data/strings/commands.yml")
|
||||
{
|
||||
var text = File.ReadAllText(commandsFilePath);
|
||||
|
||||
return Yaml.Deserializer.Deserialize<Dictionary<string, CommandStrings>>(text);
|
||||
}
|
||||
|
||||
public static string[] GetAliasesFor(string methodName)
|
||||
=> _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1
|
||||
? aliases.ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
196
src/EllieBot/_common/Configs/BotConfig.cs
Normal file
196
src/EllieBot/_common/Configs/BotConfig.cs
Normal file
|
@ -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<BotConfig>
|
||||
{
|
||||
[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<string> 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<string> Commands { get; set; }
|
||||
public HashSet<string> 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
|
||||
}
|
18
src/EllieBot/_common/Configs/IConfigSeria.cs
Normal file
18
src/EllieBot/_common/Configs/IConfigSeria.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
namespace EllieBot.Common.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// Base interface for available config serializers
|
||||
/// </summary>
|
||||
public interface IConfigSeria
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialize the object to string
|
||||
/// </summary>
|
||||
public string Serialize<T>(T obj)
|
||||
where T : notnull;
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize string data into an object of the specified type
|
||||
/// </summary>
|
||||
public T Deserialize<T>(string data);
|
||||
}
|
285
src/EllieBot/_common/Creds.cs
Normal file
285
src/EllieBot/_common/Creds.cs
Normal file
|
@ -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<ulong> 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<ulong>();
|
||||
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; }
|
||||
}
|
||||
|
||||
|
||||
|
16
src/EllieBot/_common/DbService.cs
Normal file
16
src/EllieBot/_common/DbService.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
#nullable disable
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EllieBot.Services;
|
||||
|
||||
public abstract class DbService
|
||||
{
|
||||
/// <summary>
|
||||
/// Call this to apply all migrations
|
||||
/// </summary>
|
||||
public abstract Task SetupAsync();
|
||||
|
||||
public abstract DbContext CreateRawDbContext(string dbType, string connString);
|
||||
public abstract EllieContext GetDbContext();
|
||||
}
|
215
src/EllieBot/_common/DoAsUserMessage.cs
Normal file
215
src/EllieBot/_common/DoAsUserMessage.cs
Normal file
|
@ -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<IReadOnlyCollection<IUser>> GetReactionUsersAsync(
|
||||
IEmote emoji,
|
||||
int limit,
|
||||
RequestOptions? options = null,
|
||||
ReactionType type = ReactionType.Normal)
|
||||
=> _msg.GetReactionUsersAsync(emoji, limit, options, type);
|
||||
|
||||
public IAsyncEnumerable<IReadOnlyCollection<IUser>> 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<IAttachment> Attachments
|
||||
=> _msg.Attachments;
|
||||
|
||||
public IReadOnlyCollection<IEmbed> Embeds
|
||||
=> _msg.Embeds;
|
||||
|
||||
public IReadOnlyCollection<ITag> Tags
|
||||
=> _msg.Tags;
|
||||
|
||||
public IReadOnlyCollection<ulong> MentionedChannelIds
|
||||
=> _msg.MentionedChannelIds;
|
||||
|
||||
public IReadOnlyCollection<ulong> MentionedRoleIds
|
||||
=> _msg.MentionedRoleIds;
|
||||
|
||||
public IReadOnlyCollection<ulong> MentionedUserIds
|
||||
=> _msg.MentionedUserIds;
|
||||
|
||||
public MessageActivity Activity
|
||||
=> _msg.Activity;
|
||||
|
||||
public MessageApplication Application
|
||||
=> _msg.Application;
|
||||
|
||||
public MessageReference Reference
|
||||
=> _msg.Reference;
|
||||
|
||||
public IReadOnlyDictionary<IEmote, ReactionMetadata> Reactions
|
||||
=> _msg.Reactions;
|
||||
|
||||
public IReadOnlyCollection<IMessageComponent> Components
|
||||
=> _msg.Components;
|
||||
|
||||
public IReadOnlyCollection<IStickerItem> 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<MessageProperties> 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<IReadOnlyCollection<IUser>> 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;
|
||||
}
|
38
src/EllieBot/_common/DownloadTracker.cs
Normal file
38
src/EllieBot/_common/DownloadTracker.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public class DownloadTracker : IEService
|
||||
{
|
||||
private ConcurrentDictionary<ulong, DateTime> LastDownloads { get; } = new();
|
||||
private readonly SemaphoreSlim _downloadUsersSemaphore = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures all users on the specified guild were downloaded within the last hour.
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild to check and potentially download users from</param>
|
||||
/// <returns>Task representing download state</returns>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
43
src/EllieBot/_common/DryIocExtensions.cs
Normal file
43
src/EllieBot/_common/DryIocExtensions.cs
Normal file
|
@ -0,0 +1,43 @@
|
|||
using DryIoc;
|
||||
|
||||
namespace EllieBot.Extensions;
|
||||
|
||||
public static class DryIocExtensions
|
||||
{
|
||||
public static IContainer AddSingleton<TSvc, TImpl>(this IContainer container)
|
||||
where TImpl : TSvc
|
||||
{
|
||||
container.Register<TSvc, TImpl>(Reuse.Singleton);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
public static IContainer AddSingleton<TSvc, TImpl>(this IContainer container, TImpl obj)
|
||||
where TImpl : TSvc
|
||||
{
|
||||
container.RegisterInstance<TSvc>(obj);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
public static IContainer AddSingleton<TImpl>(this IContainer container)
|
||||
{
|
||||
container.Register<TImpl>(Reuse.Singleton);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
public static IContainer AddSingleton<TImpl>(this IContainer container, TImpl obj)
|
||||
{
|
||||
container.RegisterInstance<TImpl>(obj);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
public static IContainer AddSingleton<TImpl>(this IContainer container, Func<IResolverContext, TImpl> factory)
|
||||
{
|
||||
container.RegisterDelegate(factory);
|
||||
|
||||
return container;
|
||||
}
|
||||
}
|
108
src/EllieBot/_common/EllieModule.cs
Normal file
108
src/EllieBot/_common/EllieModule.cs
Normal file
|
@ -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<bool> 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<string> GetUserInputAsync(ulong userId, ulong channelId, Func<string, bool> validate = null)
|
||||
{
|
||||
var userInputTask = new TaskCompletionSource<string>();
|
||||
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<TService> : EllieModule
|
||||
{
|
||||
public TService _service { get; set; }
|
||||
}
|
15
src/EllieBot/_common/EllieTypeReader.cs
Normal file
15
src/EllieBot/_common/EllieTypeReader.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
#nullable disable
|
||||
|
||||
namespace EllieBot.Common.TypeReaders;
|
||||
|
||||
[MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)]
|
||||
public abstract class EllieTypeReader<T> : TypeReader
|
||||
{
|
||||
public abstract ValueTask<TypeReaderResult<T>> ReadAsync(ICommandContext ctx, string input);
|
||||
|
||||
public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
|
||||
ICommandContext ctx,
|
||||
string input,
|
||||
IServiceProvider services)
|
||||
=> await ReadAsync(ctx, input);
|
||||
}
|
13
src/EllieBot/_common/Helpers.cs
Normal file
13
src/EllieBot/_common/Helpers.cs
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
12
src/EllieBot/_common/IBot.cs
Normal file
12
src/EllieBot/_common/IBot.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
#nullable disable
|
||||
using EllieBot.Db.Models;
|
||||
|
||||
namespace EllieBot;
|
||||
|
||||
public interface IBot
|
||||
{
|
||||
IReadOnlyList<ulong> GetCurrentGuildIds();
|
||||
event Func<GuildConfig, Task> JoinedGuild;
|
||||
IReadOnlyCollection<GuildConfig> AllGuildConfigs { get; }
|
||||
bool IsReady { get; }
|
||||
}
|
8
src/EllieBot/_common/ICloneable.cs
Normal file
8
src/EllieBot/_common/ICloneable.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface ICloneable<T>
|
||||
where T : new()
|
||||
{
|
||||
public T Clone();
|
||||
}
|
29
src/EllieBot/_common/ICurrencyProvider.cs
Normal file
29
src/EllieBot/_common/ICurrencyProvider.cs
Normal file
|
@ -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>(T cur, IFormatProvider format)
|
||||
where T : INumber<T>
|
||||
=> cur.ToString("C0", format);
|
||||
|
||||
public static string N<T>(T cur, CultureInfo culture, string currencySign)
|
||||
where T : INumber<T>
|
||||
=> 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;
|
||||
}
|
||||
}
|
7
src/EllieBot/_common/IDiscordPermOverrideService.cs
Normal file
7
src/EllieBot/_common/IDiscordPermOverrideService.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Common;
|
||||
|
||||
public interface IDiscordPermOverrideService
|
||||
{
|
||||
bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm);
|
||||
}
|
7
src/EllieBot/_common/IEllieCommandOptions.cs
Normal file
7
src/EllieBot/_common/IEllieCommandOptions.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface IEllieCommandOptions
|
||||
{
|
||||
void NormalizeOptions();
|
||||
}
|
34
src/EllieBot/_common/ILogCommandService.cs
Normal file
34
src/EllieBot/_common/ILogCommandService.cs
Normal file
|
@ -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
|
||||
}
|
37
src/EllieBot/_common/IPermissionChecker.cs
Normal file
37
src/EllieBot/_common/IPermissionChecker.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
using OneOf;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface IPermissionChecker
|
||||
{
|
||||
Task<PermCheckResult> CheckPermsAsync(IGuild guild,
|
||||
IMessageChannel channel,
|
||||
IUser author,
|
||||
string module,
|
||||
string? cmd);
|
||||
}
|
||||
|
||||
[GenerateOneOf]
|
||||
public partial class PermCheckResult
|
||||
: OneOfBase<PermAllowed, PermCooldown, PermGlobalBlock, PermDisallowed>
|
||||
{
|
||||
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);
|
7
src/EllieBot/_common/IPlaceholderProvider.cs
Normal file
7
src/EllieBot/_common/IPlaceholderProvider.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface IPlaceholderProvider
|
||||
{
|
||||
public IEnumerable<(string Name, Func<string> Func)> GetPlaceholders();
|
||||
}
|
36
src/EllieBot/_common/ImageUrls.cs
Normal file
36
src/EllieBot/_common/ImageUrls.cs
Normal file
|
@ -0,0 +1,36 @@
|
|||
#nullable disable
|
||||
using EllieBot.Common.Yml;
|
||||
using Cloneable;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
[Cloneable]
|
||||
public partial class ImageUrls : ICloneable<ImageUrls>
|
||||
{
|
||||
[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; }
|
||||
}
|
||||
}
|
174
src/EllieBot/_common/Interaction/EllieInteraction.cs
Normal file
174
src/EllieBot/_common/Interaction/EllieInteraction.cs
Normal file
|
@ -0,0 +1,174 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public abstract class EllieInteractionBase
|
||||
{
|
||||
private readonly ulong _authorId;
|
||||
private readonly Func<SocketMessageComponent, Task> _onAction;
|
||||
private readonly bool _onlyAuthor;
|
||||
public DiscordSocketClient Client { get; }
|
||||
|
||||
private readonly TaskCompletionSource<bool> _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<SocketMessageComponent, Task> 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<SocketModal, Task> _onAction;
|
||||
private readonly bool _onlyAuthor;
|
||||
public DiscordSocketClient Client { get; }
|
||||
|
||||
private readonly TaskCompletionSource<bool> _interactionCompletedSource;
|
||||
|
||||
private IUserMessage message = null!;
|
||||
private readonly string _customId;
|
||||
|
||||
public EllieModalSubmitHandler(
|
||||
DiscordSocketClient client,
|
||||
ulong authorId,
|
||||
string customId,
|
||||
Func<SocketModal, Task> 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);
|
||||
}
|
82
src/EllieBot/_common/Interaction/EllieInteractionService.cs
Normal file
82
src/EllieBot/_common/Interaction/EllieInteractionService.cs
Normal file
|
@ -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<SocketMessageComponent, Task> onTrigger,
|
||||
bool singleUse = true,
|
||||
bool clearAfter = true)
|
||||
=> new EllieButtonInteractionHandler(_client,
|
||||
userId,
|
||||
button,
|
||||
onTrigger,
|
||||
onlyAuthor: true,
|
||||
singleUse: singleUse,
|
||||
clearAfter: clearAfter);
|
||||
|
||||
public EllieInteractionBase Create<T>(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
Func<SocketMessageComponent, T, Task> onTrigger,
|
||||
in T state,
|
||||
bool singleUse = true,
|
||||
bool clearAfter = true
|
||||
)
|
||||
=> Create(userId,
|
||||
button,
|
||||
((Func<T, Func<SocketMessageComponent, Task>>)((data)
|
||||
=> smc => onTrigger(smc, data)))(state),
|
||||
singleUse,
|
||||
clearAfter);
|
||||
|
||||
public EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
SelectMenuBuilder menu,
|
||||
Func<SocketMessageComponent, Task> onTrigger,
|
||||
bool singleUse = true)
|
||||
=> new EllieButtonSelectInteractionHandler(_client,
|
||||
userId,
|
||||
menu,
|
||||
onTrigger,
|
||||
onlyAuthor: true,
|
||||
singleUse: singleUse);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Create an interaction which opens a modal
|
||||
/// </summary>
|
||||
/// <param name="userId">Id of the author</param>
|
||||
/// <param name="button">Button builder for the button that will open the modal</param>
|
||||
/// <param name="modal">Modal</param>
|
||||
/// <param name="onTrigger">The function that will be called when the modal is submitted</param>
|
||||
/// <param name="singleUse">Whether the button is single use</param>
|
||||
/// <returns></returns>
|
||||
public EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
ModalBuilder modal,
|
||||
Func<SocketModal, Task> 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);
|
||||
}
|
33
src/EllieBot/_common/Interaction/IEllieInteractionService.cs
Normal file
33
src/EllieBot/_common/Interaction/IEllieInteractionService.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public interface IEllieInteractionService
|
||||
{
|
||||
public EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
Func<SocketMessageComponent, Task> onTrigger,
|
||||
bool singleUse = true,
|
||||
bool clearAfter = true);
|
||||
|
||||
public EllieInteractionBase Create<T>(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
Func<SocketMessageComponent, T, Task> onTrigger,
|
||||
in T state,
|
||||
bool singleUse = true,
|
||||
bool clearAfter = true);
|
||||
|
||||
EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
SelectMenuBuilder menu,
|
||||
Func<SocketMessageComponent, Task> onTrigger,
|
||||
bool singleUse = true);
|
||||
|
||||
EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
ModalBuilder modal,
|
||||
Func<SocketModal, Task> onTrigger,
|
||||
bool singleUse = true);
|
||||
|
||||
}
|
7
src/EllieBot/_common/Interaction/InteractionHelpers.cs
Normal file
7
src/EllieBot/_common/Interaction/InteractionHelpers.cs
Normal file
|
@ -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>");
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public sealed class EllieButtonInteractionHandler : EllieInteractionBase
|
||||
{
|
||||
public EllieButtonInteractionHandler(
|
||||
DiscordSocketClient client,
|
||||
ulong authorId,
|
||||
ButtonBuilder button,
|
||||
Func<SocketMessageComponent, Task> 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);
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public sealed class EllieButtonSelectInteractionHandler : EllieInteractionBase
|
||||
{
|
||||
public EllieButtonSelectInteractionHandler(
|
||||
DiscordSocketClient client,
|
||||
ulong authorId,
|
||||
SelectMenuBuilder menu,
|
||||
Func<SocketMessageComponent, Task> 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);
|
||||
}
|
14
src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs
Normal file
14
src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EllieBot.Common.JsonConverters;
|
||||
|
||||
public class CultureInfoConverter : JsonConverter<CultureInfo>
|
||||
{
|
||||
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);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public class NumberToStringConverter : JsonConverter<object>
|
||||
{
|
||||
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());
|
||||
}
|
14
src/EllieBot/_common/JsonConverters/Rgba32Converter.cs
Normal file
14
src/EllieBot/_common/JsonConverters/Rgba32Converter.cs
Normal file
|
@ -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<Rgba32>
|
||||
{
|
||||
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());
|
||||
}
|
14
src/EllieBot/_common/LbOpts.cs
Normal file
14
src/EllieBot/_common/LbOpts.cs
Normal file
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
17
src/EllieBot/_common/Linq2DbExpressions.cs
Normal file
17
src/EllieBot/_common/Linq2DbExpressions.cs
Normal file
|
@ -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<Func<ulong, int, int, bool>> GuildOnShardExpression()
|
||||
=> (guildId, totalShards, shardId)
|
||||
=> guildId / 4194304 % (ulong)totalShards == (ulong)shardId;
|
||||
}
|
52
src/EllieBot/_common/LoginErrorHandler.cs
Normal file
52
src/EllieBot/_common/LoginErrorHandler.cs
Normal file
|
@ -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");
|
||||
}
|
||||
}
|
8
src/EllieBot/_common/MessageType.cs
Normal file
8
src/EllieBot/_common/MessageType.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace EllieBot.Common;
|
||||
|
||||
public enum MsgType
|
||||
{
|
||||
Ok,
|
||||
Pending,
|
||||
Error
|
||||
}
|
6
src/EllieBot/_common/ModuleBehaviors/IBehavior.cs
Normal file
6
src/EllieBot/_common/ModuleBehaviors/IBehavior.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
public interface IBehavior
|
||||
{
|
||||
public virtual string Name => this.GetType().Name;
|
||||
}
|
19
src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs
Normal file
19
src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Executed if no command was found for this message
|
||||
/// </summary>
|
||||
public interface IExecNoCommand : IBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Executed at the end of the lifecycle if no command was found
|
||||
/// <see cref="IExecOnMessage"/> →
|
||||
/// <see cref="IInputTransformer"/> →
|
||||
/// <see cref="IExecPreCommand"/> →
|
||||
/// [<see cref="IExecPostCommand"/> | *<see cref="IExecNoCommand"/>*]
|
||||
/// </summary>
|
||||
/// <param name="guild"></param>
|
||||
/// <param name="msg"></param>
|
||||
/// <returns>A task representing completion</returns>
|
||||
Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg);
|
||||
}
|
21
src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs
Normal file
21
src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Implemented by modules to handle non-bot messages received
|
||||
/// </summary>
|
||||
public interface IExecOnMessage : IBehavior
|
||||
{
|
||||
int Priority { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ran after a non-bot message was received
|
||||
/// *<see cref="IExecOnMessage"/>* →
|
||||
/// <see cref="IInputTransformer"/> →
|
||||
/// <see cref="IExecPreCommand"/> →
|
||||
/// [<see cref="IExecPostCommand"/> | <see cref="IExecNoCommand"/>]
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild where the message was sent</param>
|
||||
/// <param name="msg">The message that was received</param>
|
||||
/// <returns>Whether further processing of this message should be blocked</returns>
|
||||
Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage msg);
|
||||
}
|
22
src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs
Normal file
22
src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public interface IExecPostCommand : IBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Executed after a command was successfully executed
|
||||
/// <see cref="IExecOnMessage"/> →
|
||||
/// <see cref="IInputTransformer"/> →
|
||||
/// <see cref="IExecPreCommand"/> →
|
||||
/// [*<see cref="IExecPostCommand"/>* | <see cref="IExecNoCommand"/>]
|
||||
/// </summary>
|
||||
/// <param name="ctx">Command context</param>
|
||||
/// <param name="moduleName">Module name</param>
|
||||
/// <param name="commandName">Command name</param>
|
||||
/// <returns>A task representing completion</returns>
|
||||
ValueTask ExecPostCommandAsync(ICommandContext ctx, string moduleName, string commandName);
|
||||
}
|
25
src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs
Normal file
25
src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
/// <summary>
|
||||
/// This interface's method is executed after a command was found but before it was executed.
|
||||
/// Able to block further processing of a command
|
||||
/// </summary>
|
||||
public interface IExecPreCommand : IBehavior
|
||||
{
|
||||
public int Priority { get; }
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Ran after a command was found but before execution.
|
||||
/// </para>
|
||||
/// <see cref="IExecOnMessage"/> →
|
||||
/// <see cref="IInputTransformer"/> →
|
||||
/// *<see cref="IExecPreCommand"/>* →
|
||||
/// [<see cref="IExecPostCommand"/> | <see cref="IExecNoCommand"/>]
|
||||
/// </summary>
|
||||
/// <param name="context">Command context</param>
|
||||
/// <param name="moduleName">Name of the module</param>
|
||||
/// <param name="command">Command info</param>
|
||||
/// <returns>Whether further processing of the command is blocked</returns>
|
||||
Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command);
|
||||
}
|
25
src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs
Normal file
25
src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Implemented by services which may transform input before a command is searched for
|
||||
/// </summary>
|
||||
public interface IInputTransformer : IBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Ran after a non-bot message was received
|
||||
/// <see cref="IExecOnMessage"/> ->
|
||||
/// *<see cref="IInputTransformer"/>* ->
|
||||
/// <see cref="IExecPreCommand"/> ->
|
||||
/// [<see cref="IExecPostCommand"/> OR <see cref="IExecNoCommand"/>]
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild</param>
|
||||
/// <param name="channel">Channel in which the message was sent</param>
|
||||
/// <param name="user">User who sent the message</param>
|
||||
/// <param name="input">Content of the message</param>
|
||||
/// <returns>New input, if any, otherwise null</returns>
|
||||
Task<string?> TransformInput(
|
||||
IGuild guild,
|
||||
IMessageChannel channel,
|
||||
IUser user,
|
||||
string input);
|
||||
}
|
13
src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs
Normal file
13
src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
/// <summary>
|
||||
/// All services which need to execute something after
|
||||
/// the bot is ready should implement this interface
|
||||
/// </summary>
|
||||
public interface IReadyExecutor : IBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Executed when bot is ready
|
||||
/// </summary>
|
||||
public Task OnReadyAsync();
|
||||
}
|
23
src/EllieBot/_common/OptionsParser.cs
Normal file
23
src/EllieBot/_common/OptionsParser.cs
Normal file
|
@ -0,0 +1,23 @@
|
|||
using CommandLine;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public static class OptionsParser
|
||||
{
|
||||
public static T ParseFrom<T>(string[]? args)
|
||||
where T : IEllieCommandOptions, new()
|
||||
=> ParseFrom(new T(), args).Item1;
|
||||
|
||||
public static (T, bool) ParseFrom<T>(T options, string[]? args)
|
||||
where T : IEllieCommandOptions
|
||||
{
|
||||
using var p = new Parser(x =>
|
||||
{
|
||||
x.HelpWriter = null;
|
||||
});
|
||||
var res = p.ParseArguments<T>(args);
|
||||
var output = res.MapResult(x => x, _ => options);
|
||||
output.NormalizeOptions();
|
||||
return (output, res.Tag == ParserResultType.Parsed);
|
||||
}
|
||||
}
|
14
src/EllieBot/_common/Patronage/FeatureLimitKey.cs
Normal file
14
src/EllieBot/_common/Patronage/FeatureLimitKey.cs
Normal file
|
@ -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; }
|
||||
}
|
11
src/EllieBot/_common/Patronage/IPatronData.cs
Normal file
11
src/EllieBot/_common/Patronage/IPatronData.cs
Normal file
|
@ -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; }
|
||||
}
|
42
src/EllieBot/_common/Patronage/IPatronageService.cs
Normal file
42
src/EllieBot/_common/Patronage/IPatronageService.cs
Normal file
|
@ -0,0 +1,42 @@
|
|||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
/// <summary>
|
||||
/// Manages patrons and provides access to their data
|
||||
/// </summary>
|
||||
public interface IPatronageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Called when the payment is made.
|
||||
/// Either as a single payment for that patron,
|
||||
/// or as a recurring monthly donation.
|
||||
/// </summary>
|
||||
public event Func<Patron, Task> OnNewPatronPayment;
|
||||
|
||||
/// <summary>
|
||||
/// Called when the patron changes the pledge amount
|
||||
/// (Patron old, Patron new) => Task
|
||||
/// </summary>
|
||||
public event Func<Patron, Patron, Task> OnPatronUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Called when the patron refunds the purchase or it's marked as fraud
|
||||
/// </summary>
|
||||
public event Func<Patron, Task> OnPatronRefunded;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a Patron with the specified userId
|
||||
/// </summary>
|
||||
/// <param name="userId">UserId for which to get the patron data for.</param>
|
||||
/// <returns>A patron with the specifeid userId</returns>
|
||||
public Task<Patron?> GetPatronAsync(ulong userId);
|
||||
|
||||
Task<bool> LimitHitAsync(LimitedFeatureName key, ulong userId, int amount = 1);
|
||||
Task<bool> LimitForceHit(LimitedFeatureName key, ulong userId, int amount);
|
||||
Task<QuotaLimit> GetUserLimit(LimitedFeatureName name, ulong userId);
|
||||
|
||||
Task<Dictionary<LimitedFeatureName, (int, QuotaLimit)>> LimitStats(ulong userId);
|
||||
|
||||
PatronConfigData GetConfig();
|
||||
int PercentBonus(Patron? user);
|
||||
int PercentBonus(long amount);
|
||||
}
|
16
src/EllieBot/_common/Patronage/ISubscriptionHandler.cs
Normal file
16
src/EllieBot/_common/Patronage/ISubscriptionHandler.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
/// <summary>
|
||||
/// Services implementing this interface are handling pledges/subscriptions/payments coming
|
||||
/// from a payment platform.
|
||||
/// </summary>
|
||||
public interface ISubscriptionHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Get Current patrons in batches.
|
||||
/// This will only return patrons who have their discord account connected
|
||||
/// </summary>
|
||||
/// <returns>Batched patrons</returns>
|
||||
public IAsyncEnumerable<IReadOnlyCollection<ISubscriberData>> GetPatronsAsync();
|
||||
}
|
38
src/EllieBot/_common/Patronage/Patron.cs
Normal file
38
src/EllieBot/_common/Patronage/Patron.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
public readonly struct Patron
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique id assigned to this patron by the payment platform
|
||||
/// </summary>
|
||||
public string UniquePlatformUserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Discord UserId to which this <see cref="UniquePlatformUserId"/> is connected to
|
||||
/// </summary>
|
||||
public ulong UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Amount the Patron is currently pledging or paid in cents
|
||||
/// </summary>
|
||||
public int Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current Tier of the patron
|
||||
/// (do not question it in consumer classes, as the calculation should be always internal and may change)
|
||||
/// </summary>
|
||||
public PatronTier Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When was the last time this <see cref="Amount"/> was paid
|
||||
/// </summary>
|
||||
public DateTime PaidAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// After which date does the user's Patronage benefit end
|
||||
/// </summary>
|
||||
public DateTime ValidThru { get; init; }
|
||||
|
||||
public bool IsActive
|
||||
=> !ValidThru.IsBeforeToday();
|
||||
}
|
17
src/EllieBot/_common/Patronage/PatronConfigData.cs
Normal file
17
src/EllieBot/_common/Patronage/PatronConfigData.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
using EllieBot.Common.Yml;
|
||||
using Cloneable;
|
||||
|
||||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
[Cloneable]
|
||||
public partial class PatronConfigData : ICloneable<PatronConfigData>
|
||||
{
|
||||
[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<int, Dictionary<LimitedFeatureName, QuotaLimit>> Limits { get; set; } = new();
|
||||
}
|
30
src/EllieBot/_common/Patronage/PatronExtensions.cs
Normal file
30
src/EllieBot/_common/Patronage/PatronExtensions.cs
Normal file
|
@ -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}";
|
||||
}
|
||||
}
|
14
src/EllieBot/_common/Patronage/PatronTier.cs
Normal file
14
src/EllieBot/_common/Patronage/PatronTier.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
// ReSharper disable InconsistentNaming
|
||||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
public enum PatronTier
|
||||
{
|
||||
None,
|
||||
I,
|
||||
V,
|
||||
X,
|
||||
XX,
|
||||
L,
|
||||
C,
|
||||
ComingSoon
|
||||
}
|
23
src/EllieBot/_common/Patronage/QuotaLimit.cs
Normal file
23
src/EllieBot/_common/Patronage/QuotaLimit.cs
Normal file
|
@ -0,0 +1,23 @@
|
|||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
/// <summary>
|
||||
/// Represents information about why the user has triggered a quota limit
|
||||
/// </summary>
|
||||
public readonly struct QuotaLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Amount of usages reached, which is the limit
|
||||
/// </summary>
|
||||
public int Quota { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which period is this quota limit for (hourly, daily, monthly, etc...)
|
||||
/// </summary>
|
||||
public QuotaPer QuotaPeriod { get; init; }
|
||||
|
||||
public QuotaLimit(int quota, QuotaPer quotaPeriod)
|
||||
{
|
||||
Quota = quota;
|
||||
QuotaPeriod = quotaPeriod;
|
||||
}
|
||||
}
|
9
src/EllieBot/_common/Patronage/QuotaPer.cs
Normal file
9
src/EllieBot/_common/Patronage/QuotaPer.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
public enum QuotaPer
|
||||
{
|
||||
PerHour,
|
||||
PerDay,
|
||||
PerMonth,
|
||||
Total,
|
||||
}
|
10
src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs
Normal file
10
src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Patronage;
|
||||
|
||||
public enum SubscriptionChargeStatus
|
||||
{
|
||||
Paid,
|
||||
Refunded,
|
||||
Unpaid,
|
||||
Other,
|
||||
}
|
8
src/EllieBot/_common/Pokemon/PokemonNameId.cs
Normal file
8
src/EllieBot/_common/Pokemon/PokemonNameId.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common.Pokemon;
|
||||
|
||||
public class PokemonNameId
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue