Added the removed common files

This commit is contained in:
Toastie 2024-09-20 23:23:21 +12:00
parent 6b1d961642
commit f18808fb1c
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
194 changed files with 9929 additions and 0 deletions

View 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();
}

View file

@ -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;
}
}

View 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);
}

View 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);
}
}
}

View file

@ -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;
}

View file

@ -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();
}

View 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);
}
}

View file

@ -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];
}
}

View file

@ -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);
}

View file

@ -0,0 +1,7 @@
namespace Ellie.Common;
public static class Extensions
{
public static long ToTimestamp(this in DateTime value)
=> (value.Ticks - 621355968000000000) / 10000000;
}

View file

@ -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;
}

View file

@ -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 _);
}

View file

@ -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);
}

View 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;
});
}

View 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
}
}

View file

@ -0,0 +1,7 @@
namespace Ellie.Common;
public static class StandardConversions
{
public static double CelsiusToFahrenheit(double cel)
=> (cel * 1.8f) + 32;
}

View 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();
}

View 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;
}
}
}

View 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;
}

View file

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

View 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);
}

View 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;
}

View 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;
}
}

View 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; }
}

View file

@ -0,0 +1,8 @@
namespace EllieBot;
public interface IBotCredsProvider
{
public void Reload();
public IBotCredentials GetCreds();
public void ModifyCredsFile(Action<IBotCredentials> func);
}

View 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; }
}

View 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);
}

View file

@ -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);
}

View file

@ -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);
}

View 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();
}

View 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;
}
}

View 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
}

View 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))
{
}
}

View 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();
}
}

View 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
{
}

View file

@ -0,0 +1,7 @@
namespace EllieBot.Common.Attributes;
[AttributeUsage(AttributeTargets.Method)]
public sealed class EllieOptionsAttribute<TOption> : Attribute
where TOption: IEllieCommandOptions
{
}

View 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
}
}

View 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
}
}

View 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"));
}
}

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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();
}
}
}

View 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";
}

View 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;
}
}

View 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; }
}

View 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;
}
}

View 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
}

View 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);
}

View 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; }
}

View 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();
}

View 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;
}

View 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();
}
}
}

View 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;
}
}

View 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; }
}

View 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);
}

View 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);
}
}

View 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; }
}

View file

@ -0,0 +1,8 @@
#nullable disable
namespace EllieBot.Common;
public interface ICloneable<T>
where T : new()
{
public T Clone();
}

View 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;
}
}

View file

@ -0,0 +1,7 @@
#nullable disable
namespace Ellie.Common;
public interface IDiscordPermOverrideService
{
bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm);
}

View file

@ -0,0 +1,7 @@
#nullable disable
namespace EllieBot.Common;
public interface IEllieCommandOptions
{
void NormalizeOptions();
}

View 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
}

View 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);

View file

@ -0,0 +1,7 @@
#nullable disable
namespace EllieBot.Common;
public interface IPlaceholderProvider
{
public IEnumerable<(string Name, Func<string> Func)> GetPlaceholders();
}

View 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; }
}
}

View 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);
}

View 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);
}

View 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);
}

View 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>");
}

View file

@ -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);
}

View file

@ -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();
}
}

View file

@ -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);
}

View 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);
}

View file

@ -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());
}

View 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());
}

View 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()
{
}
}

View 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;
}

View 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");
}
}

View file

@ -0,0 +1,8 @@
namespace EllieBot.Common;
public enum MsgType
{
Ok,
Pending,
Error
}

View file

@ -0,0 +1,6 @@
namespace EllieBot.Common.ModuleBehaviors;
public interface IBehavior
{
public virtual string Name => this.GetType().Name;
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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();
}

View 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);
}
}

View 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; }
}

View 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; }
}

View 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);
}

View 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();
}

View 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();
}

View 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();
}

View 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}";
}
}

View file

@ -0,0 +1,14 @@
// ReSharper disable InconsistentNaming
namespace EllieBot.Modules.Patronage;
public enum PatronTier
{
None,
I,
V,
X,
XX,
L,
C,
ComingSoon
}

View 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;
}
}

View file

@ -0,0 +1,9 @@
namespace EllieBot.Modules.Patronage;
public enum QuotaPer
{
PerHour,
PerDay,
PerMonth,
Total,
}

View file

@ -0,0 +1,10 @@
#nullable disable
namespace EllieBot.Modules.Patronage;
public enum SubscriptionChargeStatus
{
Paid,
Refunded,
Unpaid,
Other,
}

View 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