Added Ellie.Bot.Common

This commit is contained in:
Emotion 2023-07-15 22:25:21 +12:00
parent eb91d0f8de
commit feb074a392
No known key found for this signature in database
GPG key ID: D7D3E4C27A98C37B
171 changed files with 7704 additions and 0 deletions

View file

@ -0,0 +1,78 @@
#nullable disable
namespace Ellie;
public interface IBotCredentials
{
string Token { get; }
string GoogleApiKey { get; }
ICollection<ulong> OwnerIds { get; set; }
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 Ellie;
public interface IBotCredsProvider
{
public void Reload();
public IBotCredentials GetCreds();
public void ModifyCredsFile(Action<IBotCredentials> func);
}

View file

@ -0,0 +1,13 @@
#nullable disable
using YamlDotNet.Serialization;
namespace Ellie.Services;
public sealed class CommandStrings
{
[YamlMember(Alias = "desc")]
public string Desc { get; set; }
[YamlMember(Alias = "args")]
public string[] Args { get; set; }
}

View file

@ -0,0 +1,16 @@
#nullable disable
using System.Globalization;
namespace Ellie.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 Ellie.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 Ellie.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,17 @@
#nullable disable
namespace Ellie.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 Ellie;
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,12 @@
using System.Runtime.CompilerServices;
namespace Ellie.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 Ellie.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 Ellie.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 Ellie.Common.Attributes;
[AttributeUsage(AttributeTargets.Method)]
public sealed class NadekoOptionsAttribute<TOption> : Attribute
where TOption: IEllieCommandOptions
{
}

View file

@ -0,0 +1,21 @@
#nullable disable
using System.Diagnostics.CodeAnalysis;
namespace Ellie.Common;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
[SuppressMessage("Style", "IDE0022:Use expression body for methods")]
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 Ellie.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_NADEKO || 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 Ellie.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,38 @@
using Microsoft.Extensions.DependencyInjection;
namespace Ellie.Common.Attributes;
[AttributeUsage(AttributeTargets.Method)]
public sealed class RatelimitAttribute : PreconditionAttribute
{
public int Seconds { get; }
public RatelimitAttribute(int seconds)
{
if (seconds <= 0)
throw new ArgumentOutOfRangeException(nameof(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 Ellie.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,10 @@
#nullable disable
using System.Runtime.InteropServices;
namespace Ellie.Modules.Permissions;
[StructLayout(LayoutKind.Sequential, Size = 1)]
public readonly struct CleverBotResponseStr
{
public const string CLEVERBOT_RESPONSE = "cleverbot:response";
}

View file

@ -0,0 +1,31 @@
using YamlDotNet.Serialization;
namespace Ellie.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 string[] GetAliasesFor(string methodName)
=> _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1
? aliases.Skip(1).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,10 @@
#nullable disable
namespace Ellie.Common;
public enum AddRemove
{
Add = int.MinValue,
Remove = int.MinValue + 1,
Rem = int.MinValue + 1,
Rm = int.MinValue + 1
}

View file

@ -0,0 +1,17 @@
#nullable disable
using Newtonsoft.Json;
namespace Ellie.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 Ellie.Common;
public class CommandData
{
public string Cmd { get; set; }
public string Desc { get; set; }
public string[] Usage { get; set; }
}

View file

@ -0,0 +1,38 @@
#nullable disable
namespace Ellie.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,13 @@
#nullable disable
namespace Ellie.Common;
public static class Helpers
{
public static void ReadErrorAndExit(int exitCode)
{
if (!Console.IsInputRedirected)
Console.ReadKey();
Environment.Exit(exitCode);
}
}

View file

@ -0,0 +1,51 @@
#nullable disable
using Ellie.Common.Yml;
using Cloneable;
namespace Ellie.Common;
[Cloneable]
public partial class ImageUrls : ICloneable<ImageUrls>
{
[Comment("DO NOT CHANGE")]
public int Version { get; set; } = 3;
public CoinData Coins { get; set; }
public Uri[] Currency { get; set; }
public Uri[] Dice { get; set; }
public RategirlData Rategirl { get; set; }
public XpData Xp { get; set; }
//new
public RipData Rip { get; set; }
public SlotData Slots { get; set; }
public class RipData
{
public Uri Bg { get; set; }
public Uri Overlay { 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 RategirlData
{
public Uri Matrix { get; set; }
public Uri Dot { get; set; }
}
public class XpData
{
public Uri Bg { get; set; }
}
}

View file

@ -0,0 +1,14 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Ellie.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,14 @@
using SixLabors.ImageSharp.PixelFormats;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Ellie.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 Ellie.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,16 @@
#nullable disable
using LinqToDB;
using System.Linq.Expressions;
namespace Ellie.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 Ellie.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,46 @@
#nullable disable
namespace Ellie.Common;
public class OldCreds
{
public string Token { get; set; } = string.Empty;
public ulong[] OwnerIds { get; set; } = new ulong[1];
public string LoLApiKey { get; set; } = string.Empty;
public string GoogleApiKey { get; set; } = string.Empty;
public string MashapeKey { get; set; } = string.Empty;
public string OsuApiKey { get; set; } = string.Empty;
public string SoundCloudClientId { get; set; } = string.Empty;
public string CleverbotApiKey { get; set; } = string.Empty;
public string CarbonKey { get; set; } = string.Empty;
public int TotalShards { get; set; } = 1;
public string PatreonAccessToken { get; set; } = string.Empty;
public string PatreonCampaignId { get; set; } = "334038";
public RestartConfig RestartCommand { get; set; }
public string ShardRunCommand { get; set; } = string.Empty;
public string ShardRunArguments { get; set; } = string.Empty;
public int? ShardRunPort { get; set; }
public string MiningProxyUrl { get; set; } = string.Empty;
public string MiningProxyCreds { get; set; } = string.Empty;
public string BotListToken { get; set; } = string.Empty;
public string TwitchClientId { get; set; } = string.Empty;
public string VotesToken { get; set; } = string.Empty;
public string VotesUrl { get; set; } = string.Empty;
public string RedisOptions { get; set; } = string.Empty;
public string LocationIqApiKey { get; set; } = string.Empty;
public string TimezoneDbApiKey { get; set; } = string.Empty;
public string CoinmarketcapApiKey { get; set; } = string.Empty;
public class RestartConfig
{
public string Cmd { get; set; }
public string Args { get; set; }
public RestartConfig(string cmd, string args)
{
Cmd = cmd;
Args = args;
}
}
}

View file

@ -0,0 +1,23 @@
using CommandLine;
namespace Ellie.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,9 @@
#nullable disable
namespace Ellie.Common;
public class OsuMapData
{
public string Title { get; set; }
public string Artist { get; set; }
public string Version { get; set; }
}

View file

@ -0,0 +1,58 @@
#nullable disable
using Newtonsoft.Json;
namespace Ellie.Common;
public class OsuUserBests
{
[JsonProperty("beatmap_id")]
public string BeatmapId { get; set; }
[JsonProperty("score_id")]
public string ScoreId { get; set; }
[JsonProperty("score")]
public string Score { get; set; }
[JsonProperty("maxcombo")]
public string Maxcombo { get; set; }
[JsonProperty("count50")]
public double Count50 { get; set; }
[JsonProperty("count100")]
public double Count100 { get; set; }
[JsonProperty("count300")]
public double Count300 { get; set; }
[JsonProperty("countmiss")]
public int Countmiss { get; set; }
[JsonProperty("countkatu")]
public double Countkatu { get; set; }
[JsonProperty("countgeki")]
public double Countgeki { get; set; }
[JsonProperty("perfect")]
public string Perfect { get; set; }
[JsonProperty("enabled_mods")]
public int EnabledMods { get; set; }
[JsonProperty("user_id")]
public string UserId { get; set; }
[JsonProperty("date")]
public string Date { get; set; }
[JsonProperty("rank")]
public string Rank { get; set; }
[JsonProperty("pp")]
public double Pp { get; set; }
[JsonProperty("replay_available")]
public string ReplayAvailable { get; set; }
}

View file

@ -0,0 +1,8 @@
#nullable disable
namespace Ellie.Common.Pokemon;
public class PokemonNameId
{
public int Id { get; set; }
public string Name { get; set; }
}

View file

@ -0,0 +1,42 @@
#nullable disable
using System.Text.Json.Serialization;
namespace Ellie.Common.Pokemon;
public class SearchPokemon
{
[JsonPropertyName("num")]
public int Id { get; set; }
public string Species { get; set; }
public string[] Types { get; set; }
public GenderRatioClass GenderRatio { get; set; }
public BaseStatsClass BaseStats { get; set; }
public Dictionary<string, string> Abilities { get; set; }
public float HeightM { get; set; }
public float WeightKg { get; set; }
public string Color { get; set; }
public string[] Evos { get; set; }
public string[] EggGroups { get; set; }
public class GenderRatioClass
{
public float M { get; set; }
public float F { get; set; }
}
public class BaseStatsClass
{
public int Hp { get; set; }
public int Atk { get; set; }
public int Def { get; set; }
public int Spa { get; set; }
public int Spd { get; set; }
public int Spe { get; set; }
public override string ToString()
=> $@"💚**HP:** {Hp,-4} ⚔**ATK:** {Atk,-4} 🛡**DEF:** {Def,-4}
**SPA:** {Spa,-4} 🎇**SPD:** {Spd,-4} 💨**SPE:** {Spe,-4}";
}
}

View file

@ -0,0 +1,10 @@
#nullable disable
namespace Ellie.Common.Pokemon;
public class SearchPokemonAbility
{
public string Desc { get; set; }
public string ShortDesc { get; set; }
public string Name { get; set; }
public float Rating { get; set; }
}

View file

@ -0,0 +1,15 @@
#nullable disable
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Ellie.Common;
public class RequireObjectPropertiesContractResolver : DefaultContractResolver
{
protected override JsonObjectContract CreateObjectContract(Type objectType)
{
var contract = base.CreateObjectContract(objectType);
contract.ItemRequired = Required.DisallowNull;
return contract;
}
}

View file

@ -0,0 +1,11 @@
#nullable disable
namespace Ellie.Modules.Games.Common.Trivia;
public sealed class TriviaQuestionModel
{
public string Category { get; init; }
public string Question { get; init; }
public string ImageUrl { get; init; }
public string AnswerImageUrl { get; init; }
public string Answer { get; init; }
}

View file

@ -0,0 +1,13 @@
#nullable disable
namespace Ellie.Common.TypeReaders;
public sealed class EmoteTypeReader : EllieTypeReader<Emote>
{
public override ValueTask<TypeReaderResult<Emote>> ReadAsync(ICommandContext ctx, string input)
{
if (!Emote.TryParse(input, out var emote))
return new(TypeReaderResult.FromError<Emote>(CommandError.ParseFailed, "Input is not a valid emote"));
return new(TypeReaderResult.FromSuccess(emote));
}
}

View file

@ -0,0 +1,24 @@
#nullable disable
namespace Ellie.Common.TypeReaders;
public sealed class GuildTypeReader : EllieTypeReader<IGuild>
{
private readonly DiscordSocketClient _client;
public GuildTypeReader(DiscordSocketClient client)
=> _client = client;
public override ValueTask<TypeReaderResult<IGuild>> ReadAsync(ICommandContext context, string input)
{
input = input.Trim().ToUpperInvariant();
var guilds = _client.Guilds;
IGuild guild = guilds.FirstOrDefault(g => g.Id.ToString().Trim().ToUpperInvariant() == input) //by id
?? guilds.FirstOrDefault(g => g.Name.Trim().ToUpperInvariant() == input); //by name
if (guild is not null)
return new(TypeReaderResult.FromSuccess(guild));
return new(
TypeReaderResult.FromError<IGuild>(CommandError.ParseFailed, "No guild by that name or Id found"));
}
}

View file

@ -0,0 +1,33 @@
namespace Ellie.Common.TypeReaders;
public sealed class GuildUserTypeReader : EllieTypeReader<IGuildUser>
{
public override async ValueTask<TypeReaderResult<IGuildUser>> ReadAsync(ICommandContext ctx, string input)
{
if (ctx.Guild is null)
return TypeReaderResult.FromError<IGuildUser>(CommandError.Unsuccessful, "Must be in a guild.");
input = input.Trim();
IGuildUser? user = null;
if (MentionUtils.TryParseUser(input, out var id))
user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload);
if (ulong.TryParse(input, out id))
user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload);
if (user is null)
{
var users = await ctx.Guild.GetUsersAsync(CacheMode.CacheOnly);
user = users.FirstOrDefault(x => x.Username == input)
?? users.FirstOrDefault(x =>
string.Equals(x.ToString(), input, StringComparison.InvariantCultureIgnoreCase))
?? users.FirstOrDefault(x =>
string.Equals(x.Username, input, StringComparison.InvariantCultureIgnoreCase));
}
if (user is null)
return TypeReaderResult.FromError<IGuildUser>(CommandError.ObjectNotFound, "User not found.");
return TypeReaderResult.FromSuccess(user);
}
}

View file

@ -0,0 +1,19 @@
#nullable disable
namespace Ellie.Common.TypeReaders;
public sealed class KwumTypeReader : EllieTypeReader<kwum>
{
public override ValueTask<TypeReaderResult<kwum>> ReadAsync(ICommandContext context, string input)
{
if (kwum.TryParse(input, out var val))
return new(TypeReaderResult.FromSuccess(val));
return new(TypeReaderResult.FromError<kwum>(CommandError.ParseFailed, "Input is not a valid kwum"));
}
}
public sealed class SmartTextTypeReader : EllieTypeReader<SmartText>
{
public override ValueTask<TypeReaderResult<SmartText>> ReadAsync(ICommandContext ctx, string input)
=> new(TypeReaderResult.FromSuccess(SmartText.CreateFrom(input)));
}

View file

@ -0,0 +1,27 @@
#nullable disable
namespace Ellie.Common.TypeReaders.Models;
public class PermissionAction
{
public static PermissionAction Enable
=> new(true);
public static PermissionAction Disable
=> new(false);
public bool Value { get; }
public PermissionAction(bool value)
=> Value = value;
public override bool Equals(object obj)
{
if (obj is null || GetType() != obj.GetType())
return false;
return Value == ((PermissionAction)obj).Value;
}
public override int GetHashCode()
=> Value.GetHashCode();
}

View file

@ -0,0 +1,55 @@
#nullable disable
using System.Text.RegularExpressions;
namespace Ellie.Common.TypeReaders.Models;
public class StoopidTime
{
private static readonly Regex _regex = new(
@"^(?:(?<months>\d)mo)?(?:(?<weeks>\d{1,2})w)?(?:(?<days>\d{1,2})d)?(?:(?<hours>\d{1,4})h)?(?:(?<minutes>\d{1,5})m)?(?:(?<seconds>\d{1,6})s)?$",
RegexOptions.Compiled | RegexOptions.Multiline);
public string Input { get; set; }
public TimeSpan Time { get; set; }
private StoopidTime() { }
public static StoopidTime FromInput(string input)
{
var m = _regex.Match(input);
if (m.Length == 0)
throw new ArgumentException("Invalid string input format.");
var namesAndValues = new Dictionary<string, int>();
foreach (var groupName in _regex.GetGroupNames())
{
if (groupName == "0")
continue;
if (!int.TryParse(m.Groups[groupName].Value, out var value))
{
namesAndValues[groupName] = 0;
continue;
}
if (value < 1)
throw new ArgumentException($"Invalid {groupName} value.");
namesAndValues[groupName] = value;
}
var ts = new TimeSpan((30 * namesAndValues["months"]) + (7 * namesAndValues["weeks"]) + namesAndValues["days"],
namesAndValues["hours"],
namesAndValues["minutes"],
namesAndValues["seconds"]);
if (ts > TimeSpan.FromDays(90))
throw new ArgumentException("Time is too long.");
return new()
{
Input = input,
Time = ts
};
}
}

View file

@ -0,0 +1,50 @@
#nullable disable
namespace Ellie.Common.TypeReaders;
public sealed class ModuleTypeReader : EllieTypeReader<ModuleInfo>
{
private readonly CommandService _cmds;
public ModuleTypeReader(CommandService cmds)
=> _cmds = cmds;
public override ValueTask<TypeReaderResult<ModuleInfo>> ReadAsync(ICommandContext context, string input)
{
input = input.ToUpperInvariant();
var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule())
.FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input)
?.Key;
if (module is null)
return new(TypeReaderResult.FromError<ModuleInfo>(CommandError.ParseFailed, "No such module found."));
return new(TypeReaderResult.FromSuccess(module));
}
}
public sealed class ModuleOrCrTypeReader : EllieTypeReader<ModuleOrCrInfo>
{
private readonly CommandService _cmds;
public ModuleOrCrTypeReader(CommandService cmds)
=> _cmds = cmds;
public override ValueTask<TypeReaderResult<ModuleOrCrInfo>> ReadAsync(ICommandContext context, string input)
{
input = input.ToUpperInvariant();
var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule())
.FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input)
?.Key;
if (module is null && input != "ACTUALEXPRESSIONS")
return new(TypeReaderResult.FromError<ModuleOrCrInfo>(CommandError.ParseFailed, "No such module found."));
return new(TypeReaderResult.FromSuccess(new ModuleOrCrInfo
{
Name = input
}));
}
}
public sealed class ModuleOrCrInfo
{
public string Name { get; set; }
}

View file

@ -0,0 +1,39 @@
#nullable disable
using Ellie.Common.TypeReaders.Models;
namespace Ellie.Common.TypeReaders;
/// <summary>
/// Used instead of bool for more flexible keywords for true/false only in the permission module
/// </summary>
public sealed class PermissionActionTypeReader : EllieTypeReader<PermissionAction>
{
public override ValueTask<TypeReaderResult<PermissionAction>> ReadAsync(ICommandContext context, string input)
{
input = input.ToUpperInvariant();
switch (input)
{
case "1":
case "T":
case "TRUE":
case "ENABLE":
case "ENABLED":
case "ALLOW":
case "PERMIT":
case "UNBAN":
return new(TypeReaderResult.FromSuccess(PermissionAction.Enable));
case "0":
case "F":
case "FALSE":
case "DENY":
case "DISABLE":
case "DISABLED":
case "DISALLOW":
case "BAN":
return new(TypeReaderResult.FromSuccess(PermissionAction.Disable));
default:
return new(TypeReaderResult.FromError<PermissionAction>(CommandError.ParseFailed,
"Did not receive a valid boolean value"));
}
}
}

View file

@ -0,0 +1,20 @@
using Color = SixLabors.ImageSharp.Color;
#nullable disable
namespace Ellie.Common.TypeReaders;
public sealed class Rgba32TypeReader : EllieTypeReader<Color>
{
public override ValueTask<TypeReaderResult<Color>> ReadAsync(ICommandContext context, string input)
{
input = input.Replace("#", "", StringComparison.InvariantCulture);
try
{
return ValueTask.FromResult(TypeReaderResult.FromSuccess(Color.ParseHex(input)));
}
catch
{
return ValueTask.FromResult(TypeReaderResult.FromError<Color>(CommandError.ParseFailed, "Parameter is not a valid color hex."));
}
}
}

View file

@ -0,0 +1,22 @@
#nullable disable
using Ellie.Common.TypeReaders.Models;
namespace Ellie.Common.TypeReaders;
public sealed class StoopidTimeTypeReader : EllieTypeReader<StoopidTime>
{
public override ValueTask<TypeReaderResult<StoopidTime>> ReadAsync(ICommandContext context, string input)
{
if (string.IsNullOrWhiteSpace(input))
return new(TypeReaderResult.FromError<StoopidTime>(CommandError.Unsuccessful, "Input is empty."));
try
{
var time = StoopidTime.FromInput(input);
return new(TypeReaderResult.FromSuccess(time));
}
catch (Exception ex)
{
return new(TypeReaderResult.FromError<StoopidTime>(CommandError.Exception, ex.Message));
}
}
}

View file

@ -0,0 +1,203 @@
#nullable disable
using Cloneable;
using Ellie.Common.Yml;
using SixLabors.ImageSharp.PixelFormats;
using System.Globalization;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace Ellie.Common.Configs;
[Cloneable]
public sealed partial class BotConfig : ICloneable<BotConfig>
{
[Comment("""DO NOT CHANGE""")]
public int Version { get; set; } = 5;
[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("""
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("""
Toggles whether your bot will group greet/bye messages into a single message every 5 seconds.
1st user who joins will get greeted immediately
If more users join within the next 5 seconds, they will be greeted in groups of 5.
This will cause %user.mention% and other placeholders to be replaced with multiple users.
Keep in mind this might break some of your embeds - for example if you have %user.avatar% in the thumbnail,
it will become invalid, as it will resolve to a list of avatars of grouped users.
note: This setting is primarily used if you're afraid of raids, or you're running medium/large bots where some
servers might get hundreds of people join at once. This is used to prevent the bot from getting ratelimited,
and (slightly) reduce the greet spam in those servers.
""")]
public bool GroupGreets { 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://i.imgur.com/nKYyqMK.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.elliebot.net/ ",
"inline": true
}
]
}
""";
var blocked = new BlockedConfig();
Blocked = blocked;
Prefix = ".";
RotateStatuses = false;
GroupGreets = false;
DmHelpTextKeywords = new()
{
"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 = new();
Commands = new();
}
}
[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 Ellie.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,273 @@
#nullable disable
using Ellie.Common.Yml;
using Microsoft.EntityFrameworkCore;
namespace Ellie.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 Ellie.Coordinator to start the bot, and it will correctly override this value.
""")]
public int TotalShards { 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(@"Official GPT-3 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: "Ellie.dll -- {0}"
Windows default
cmd: Ellie.exe
args: "{0}"
""")]
public RestartConfig RestartCommand { get; set; }
public Creds()
{
Version = 7;
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/Ellie.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/Ellie.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 Ellie.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 Ellie.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 Ellie.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 Ellie.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,5 @@
namespace Ellie.Services.Currency;
public enum CurrencyType{
Default
}

View file

@ -0,0 +1,10 @@
namespace Ellie.Modules.Gambling.Bank;
public interface IBankService
{
Task<bool> DepositAsync(ulong userId, long amount);
Task<bool> WithdrawAsync(ulong userId, long amount);
Task<long> GetBalanceAsync(ulong userId);
Task<bool> AwardAsync(ulong userId, long amount);
Task<bool> TakeAsync(ulong userId, long amount);
}

View file

@ -0,0 +1,40 @@
using Ellie.Services.Currency;
namespace Ellie.Services;
public interface ICurrencyService
{
Task<IWallet> GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default);
Task AddBulkAsync(
IReadOnlyCollection<ulong> userIds,
long amount,
TxData? txData,
CurrencyType type = CurrencyType.Default);
Task RemoveBulkAsync(
IReadOnlyCollection<ulong> userIds,
long amount,
TxData? txData,
CurrencyType type = CurrencyType.Default);
Task AddAsync(
ulong userId,
long amount,
TxData? txData);
Task AddAsync(
IUser user,
long amount,
TxData? txData);
Task<bool> RemoveAsync(
ulong userId,
long amount,
TxData? txData);
Task<bool> RemoveAsync(
IUser user,
long amount,
TxData? txData);
}

View file

@ -0,0 +1,9 @@
using Ellie.Services.Currency;
namespace Ellie.Services;
public interface ITxTracker
{
Task TrackAdd(long amount, TxData? txData);
Task TrackRemove(long amount, TxData? txData);
}

View file

@ -0,0 +1,40 @@
namespace Ellie.Services.Currency;
public interface IWallet
{
public ulong UserId { get; }
public Task<long> GetBalance();
public Task<bool> Take(long amount, TxData? txData);
public Task Add(long amount, TxData? txData);
public async Task<bool> Transfer(
long amount,
IWallet to,
TxData? txData)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0.");
if (txData is not null)
txData = txData with
{
OtherId = to.UserId
};
var succ = await Take(amount, txData);
if (!succ)
return false;
if (txData is not null)
txData = txData with
{
OtherId = UserId
};
await to.Add(amount, txData);
return true;
}
}

View file

@ -0,0 +1,7 @@
namespace Ellie.Services.Currency;
public record class TxData(
string Type,
string Extra,
string? Note = "",
ulong? OtherId = null);

View file

@ -0,0 +1,18 @@
#nullable disable
using LinqToDB.Common;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Ellie.Services.Database;
namespace Ellie.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 DbContext GetDbContext();
}

View file

@ -0,0 +1,138 @@
using MessageType = Discord.MessageType;
namespace Ellie.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)
{
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 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;
public IMessageInteraction Interaction => _msg.Interaction;
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 IUserMessage ReferencedMessage => _msg.ReferencedMessage;
}

View file

@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Discord.Net" Version="3.104.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Ellie.Bot.Db\Ellie.Bot.Db.csproj" />
<ProjectReference Include="..\Ellise.Common\Ellise.Common.csproj" />
<PackageReference Include="JetBrains.Annotations" Version="2022.3.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta17" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta14" />
<PackageReference Include="SixLabors.Shapes" Version="1.0.0-beta0009" />
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Humanizer" Version="2.14.1">
<PrivateAssets>all</PrivateAssets>
<Publish>True</Publish>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<ProjectReference Include="..\Ellie.Marmalade\Ellie.Marmalade.csproj" />
<ProjectReference Include="..\Ellie.Bot.Generators.Strings\Ellie.Bot.Generators.Strings.csproj" OutputItemType="Analyzer" />
<ProjectReference Include="..\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj" OutputItemType="Analyzer" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\Ellie\data\strings\responses\responses.en-US.json" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,141 @@
#nullable disable
using System.Globalization;
// ReSharper disable InconsistentNaming
namespace Ellie.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 IEmbedBuilderService _eb { get; set; }
public IEllieInteractionService _inter { get; set; }
protected string prefix
=> _cmdHandler.GetPrefix(ctx.Guild);
protected ICommandContext ctx
=> Context;
protected override void BeforeExecute(CommandInfo command)
=> Culture = _localization.GetCultureInfo(ctx.Guild?.Id);
protected string GetText(in LocStr data)
=> Strings.GetText(data, Culture);
public Task<IUserMessage> SendErrorAsync(
string title,
string error,
string url = null,
string footer = null,
EllieInteraction inter = null)
=> ctx.Channel.SendErrorAsync(_eb, title, error, url, footer);
public Task<IUserMessage> SendConfirmAsync(
string title,
string text,
string url = null,
string footer = null)
=> ctx.Channel.SendConfirmAsync(_eb, title, text, url, footer);
//
public Task<IUserMessage> SendErrorAsync(string text, EllieInteraction inter = null)
=> ctx.Channel.SendAsync(_eb, text, MsgType.Error, inter);
public Task<IUserMessage> SendConfirmAsync(string text, EllieInteraction inter = null)
=> ctx.Channel.SendAsync(_eb, text, MsgType.Ok, inter);
public Task<IUserMessage> SendPendingAsync(string text, EllieInteraction inter = null)
=> ctx.Channel.SendAsync(_eb, text, MsgType.Pending, inter);
// localized normal
public Task<IUserMessage> ErrorLocalizedAsync(LocStr str, EllieInteraction inter = null)
=> SendErrorAsync(GetText(str), inter);
public Task<IUserMessage> PendingLocalizedAsync(LocStr str, EllieInteraction inter = null)
=> SendPendingAsync(GetText(str), inter);
public Task<IUserMessage> ConfirmLocalizedAsync(LocStr str, EllieInteraction inter = null)
=> SendConfirmAsync(GetText(str), inter);
// localized replies
public Task<IUserMessage> ReplyErrorLocalizedAsync(LocStr str, EllieInteraction inter = null)
=> SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
public Task<IUserMessage> ReplyPendingLocalizedAsync(LocStr str, EllieInteraction inter = null)
=> SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
public Task<IUserMessage> ReplyConfirmLocalizedAsync(LocStr str, EllieInteraction inter = null)
=> SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
public async Task<bool> PromptUserConfirmAsync(IEmbedBuilder embed)
{
embed.WithPendingColor().WithFooter("yes/no");
var msg = await ctx.Channel.EmbedAsync(embed);
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)
{
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 (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 Ellie.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,12 @@
using Ellie.Db;
using Ellie.Db.Models;
// todo fix these namespaces. It should only be Nadeko.Bot.Db
using Ellie.Services.Database;
namespace Ellie.Extensions;
public static class DbExtensions
{
public static DiscordUser GetOrCreateUser(this EllieBaseContext ctx, IUser original, Func<IQueryable<DiscordUser>, IQueryable<DiscordUser>>? includes = null)
=> ctx.GetOrCreateUser(original.Id, original.Username, original.Discriminator, original.AvatarId, includes);
}

View file

@ -0,0 +1,97 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Color = Discord.Color;
namespace Ellie.Extensions;
public static class ImagesharpExtensions
{
// https://github.com/SixLabors/Samples/blob/master/ImageSharp/AvatarWithRoundedCorner/Program.cs
public static IImageProcessingContext ApplyRoundedCorners(this IImageProcessingContext ctx, float cornerRadius)
{
var size = ctx.GetCurrentSize();
var corners = BuildCorners(size.Width, size.Height, cornerRadius);
ctx.SetGraphicsOptions(new GraphicsOptions
{
Antialias = true,
// enforces that any part of this shape that has color is punched out of the background
AlphaCompositionMode = PixelAlphaCompositionMode.DestOut
});
foreach (var c in corners)
ctx = ctx.Fill(SixLabors.ImageSharp.Color.Red, c);
return ctx;
}
private static IPathCollection BuildCorners(int imageWidth, int imageHeight, float cornerRadius)
{
// first create a square
var rect = new RectangularPolygon(-0.5f, -0.5f, cornerRadius, cornerRadius);
// then cut out of the square a circle so we are left with a corner
var cornerTopLeft = rect.Clip(new EllipsePolygon(cornerRadius - 0.5f, cornerRadius - 0.5f, cornerRadius));
// corner is now a corner shape positions top left
//lets make 3 more positioned correctly, we can do that by translating the original around the center of the image
var rightPos = imageWidth - cornerTopLeft.Bounds.Width + 1;
var bottomPos = imageHeight - cornerTopLeft.Bounds.Height + 1;
// move it across the width of the image - the width of the shape
var cornerTopRight = cornerTopLeft.RotateDegree(90).Translate(rightPos, 0);
var cornerBottomLeft = cornerTopLeft.RotateDegree(-90).Translate(0, bottomPos);
var cornerBottomRight = cornerTopLeft.RotateDegree(180).Translate(rightPos, bottomPos);
return new PathCollection(cornerTopLeft, cornerBottomLeft, cornerTopRight, cornerBottomRight);
}
public static Color ToDiscordColor(this Rgba32 color)
=> new(color.R, color.G, color.B);
public static MemoryStream ToStream(this Image<Rgba32> img, IImageFormat? format = null)
{
var imageStream = new MemoryStream();
if (format?.Name == "GIF")
img.SaveAsGif(imageStream);
else
{
img.SaveAsPng(imageStream,
new()
{
ColorType = PngColorType.RgbWithAlpha,
CompressionLevel = PngCompressionLevel.DefaultCompression
});
}
imageStream.Position = 0;
return imageStream;
}
public static async Task<MemoryStream> ToStreamAsync(this Image<Rgba32> img, IImageFormat? format = null)
{
var imageStream = new MemoryStream();
if (format?.Name == "GIF")
{
await img.SaveAsGifAsync(imageStream);
}
else
{
await img.SaveAsPngAsync(imageStream,
new PngEncoder()
{
ColorType = PngColorType.RgbWithAlpha,
CompressionLevel = PngCompressionLevel.DefaultCompression
});
}
imageStream.Position = 0;
return imageStream;
}
}

View file

@ -0,0 +1,31 @@
// // global using System.Collections.Concurrent;
global using NonBlocking;
//
// // packages
global using Humanizer;
//
// // ellie
global using Ellie;
global using Ellie.Services;
global using Ellise.Common; // new project
global using Ellie.Common; // old + ellie specific things
global using Ellie.Common.Attributes;
global using Ellie.Extensions;
// discord
global using Discord;
global using Discord.Commands;
global using Discord.Net;
global using Discord.WebSocket;
// aliases
global using GuildPerm = Discord.GuildPermission;
global using ChannelPerm = Discord.ChannelPermission;
global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute;
global using LeftoverAttribute = Discord.Commands.RemainderAttribute;
// non-essential
global using JetBrains.Annotations;
global using Serilog;

View file

@ -0,0 +1,12 @@
#nullable disable
using Ellie.Services.Database.Models;
namespace Ellie;
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 Ellie.Common;
public interface ICloneable<T>
where T : new()
{
public T Clone();
}

View file

@ -0,0 +1,29 @@
using System.Globalization;
using System.Numerics;
namespace Ellie.Bot.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 Ellise.Common;
public interface IDiscordPermOverrideService
{
bool TryGetOverrides(ulong guildId, string commandName, out Ellie.Bot.Db.GuildPerm? perm);
}

View file

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

View file

@ -0,0 +1,35 @@
using Ellie.Services.Database.Models;
namespace Ellie.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,
VoicePresenceTts,
UserMuted,
UserWarned,
ThreadDeleted,
ThreadCreated
}

View file

@ -0,0 +1,13 @@
using OneOf;
using OneOf.Types;
namespace Ellie.Bot.Common;
public interface IPermissionChecker
{
Task<OneOf<Success, Error<LocStr>>> CheckAsync(IGuild guild,
IMessageChannel channel,
IUser author,
string module,
string? cmd);
}

View file

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

View file

@ -0,0 +1,82 @@
namespace Ellie;
public sealed class EllieInteraction
{
private readonly ulong _authorId;
private readonly ButtonBuilder _button;
private readonly Func<SocketMessageComponent, Task> _onClick;
private readonly bool _onlyAuthor;
public DiscordSocketClient Client { get; }
private readonly TaskCompletionSource<bool> _interactionCompletedSource;
private IUserMessage message = null!;
public EllieInteraction(DiscordSocketClient client,
ulong authorId,
ButtonBuilder button,
Func<SocketMessageComponent, Task> onClick,
bool onlyAuthor)
{
_authorId = authorId;
_button = button;
_onClick = onClick;
_onlyAuthor = onlyAuthor;
_interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
Client = client;
}
public async Task RunAsync(IUserMessage msg)
{
message = msg;
Client.InteractionCreated += OnInteraction;
await Task.WhenAny(Task.Delay(15_000), _interactionCompletedSource.Task);
Client.InteractionCreated -= OnInteraction;
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 != _button.CustomId)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
await ExecuteOnActionAsync(smc);
// this should only be a thing on single-response buttons
_interactionCompletedSource.TrySetResult(true);
if (!smc.HasResponded)
{
await smc.DeferAsync();
}
});
return Task.CompletedTask;
}
public MessageComponent CreateComponent()
{
var comp = new ComponentBuilder()
.WithButton(_button);
return comp.Build();
}
public Task ExecuteOnActionAsync(SocketMessageComponent smc)
=> _onClick(smc);
}

View file

@ -0,0 +1,8 @@
namespace Ellie;
/// <summary>
/// Represents essential interacation data
/// </summary>
/// <param name="Emote">Emote which will show on a button</param>
/// <param name="CustomId">Custom interaction id</param>
public record EllieInteractionData(IEmote Emote, string CustomId, string? Text = null);

View file

@ -0,0 +1,20 @@
namespace Ellie;
public class EllieInteractionService : IEllieInteractionService, IEService
{
private readonly DiscordSocketClient _client;
public EllieInteractionService(DiscordSocketClient client)
{
_client = client;
}
public EllieInteraction Create<T>(
ulong userId,
SimpleInteraction<T> inter)
=> new EllieInteraction(_client,
userId,
inter.Button,
inter.TriggerAsync,
onlyAuthor: true);
}

View file

@ -0,0 +1,8 @@
namespace Ellie;
public interface IEllieInteractionService
{
public EllieInteraction Create<T>(
ulong userId,
SimpleInteraction<T> inter);
}

View file

@ -0,0 +1,20 @@
namespace Ellie;
public class SimpleInteraction<T>
{
public ButtonBuilder Button { get; }
private readonly Func<SocketMessageComponent, T, Task> _onClick;
private readonly T? _state;
public SimpleInteraction(ButtonBuilder button, Func<SocketMessageComponent, T?, Task> onClick, T? state = default)
{
Button = button;
_onClick = onClick;
_state = state;
}
public async Task TriggerAsync(SocketMessageComponent smc)
{
await _onClick(smc, _state!);
}
}

View file

@ -0,0 +1,24 @@
using System.Globalization;
namespace Ellie.Marmalade;
public interface IMarmaladeLoaderSevice
{
Task<MarmaladeLoadResult> LoadMarmaladeAsync(string marmaladeName);
Task<MarmaladeUnloadResult> UnloadMarmaladeAsync(string marmaladeName);
string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture);
string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture);
Task ReloadStrings();
IReadOnlyCollection<string> GetAllMarmalades();
IReadOnlyCollection<MarmaladeStats> GetLoadedMarmalades(CultureInfo? cultureInfo = null);
}
public sealed record MarmaladeStats(string Name,
string? Description,
IReadOnlyCollection<CanaryStats> Canaries);
public sealed record CanaryStats(string Name,
string? Prefix,
IReadOnlyCollection<CanaryCommandStats> Commands);
public sealed record CanaryCommandStats(string Name);

View file

@ -0,0 +1,10 @@
namespace Ellie.Marmalade;
public enum MarmaladeLoadResult
{
Success,
NotFound,
AlreadyLoaded,
Empty,
UnknownError,
}

View file

@ -0,0 +1,9 @@
namespace Ellie.Marmalade;
public enum MarmaladeUnloadResult
{
Success,
NotLoaded,
PossiblyUnable,
NotFound,
}

View file

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

View file

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

View file

@ -0,0 +1,19 @@
namespace Ellie.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 Ellie.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 Ellie.Common.ModuleBehaviors;
/// <summary>
/// This interface's method is executed after the command successfully finished execution.
/// ***There is no support for this method in NadekoBot 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 Ellie.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 Ellie.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 Ellie.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,7 @@
namespace Ellie.Modules.Patronage;
public readonly struct FeatureLimitKey
{
public string PrettyName { get; init; }
public string Key { get; init; }
}

View file

@ -0,0 +1,8 @@
namespace Ellie.Modules.Patronage;
public readonly struct FeatureQuotaStats
{
public (uint Cur, uint Max) Hourly { get; init; }
public (uint Cur, uint Max) Daily { get; init; }
public (uint Cur, uint Max) Monthly { get; init; }
}

View file

@ -0,0 +1,11 @@
namespace Ellie.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,56 @@
using Ellie.Db.Models;
using OneOf;
namespace Ellie.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);
/// <summary>
/// Gets the quota statistic for the user/patron specified by the userId
/// </summary>
/// <param name="userId">UserId of the user for which to get the quota statistic for</param>
/// <returns>Quota stats for the specified user</returns>
Task<UserQuotaStats> GetUserQuotaStatistic(ulong userId);
Task<FeatureLimit> TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue);
ValueTask<OneOf<(uint Hourly, uint Daily, uint Monthly), QuotaLimit>> TryIncrementQuotaCounterAsync(
ulong userId,
bool isSelf,
FeatureType featureType,
string featureName,
uint? maybeHourly,
uint? maybeDaily,
uint? maybeMonthly);
PatronConfigData GetConfig();
}

View file

@ -0,0 +1,16 @@
#nullable disable
namespace Ellie.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 Ellie.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
/// </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,37 @@
using Ellie.Common.Yml;
using Cloneable;
namespace Ellie.Modules.Patronage;
[Cloneable]
public partial class PatronConfigData : ICloneable<PatronConfigData>
{
[Comment("DO NOT CHANGE")]
public int Version { get; set; } = 2;
[Comment("Whether the patronage feature is enabled")]
public bool IsEnabled { get; set; }
[Comment("List of patron only features and relevant quota data")]
public FeatureQuotas Quotas { get; set; }
public PatronConfigData()
{
Quotas = new();
}
public class FeatureQuotas
{
[Comment("Dictionary of feature names with their respective limits. Set to null for unlimited")]
public Dictionary<string, Dictionary<PatronTier, int?>> Features { get; set; } = new();
[Comment("Dictionary of commands with their respective quota data")]
public Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> Commands { get; set; } = new();
[Comment("Dictionary of groups with their respective quota data")]
public Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> Groups { get; set; } = new();
[Comment("Dictionary of modules with their respective quota data")]
public Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> Modules { get; set; } = new();
}
}

View file

@ -0,0 +1,33 @@
namespace Ellie.Modules.Patronage;
public static class PatronExtensions
{
public static string ToFullName(this PatronTier tier)
=> tier switch
{
_ => $"Patron Tier {tier}",
};
public static string ToFullName(this QuotaPer per)
=> per.Humanize(LetterCasing.LowerCase);
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 Ellie.Modules.Patronage;
public enum PatronTier
{
None,
I,
V,
X,
XX,
L,
C,
ComingSoon
}

Some files were not shown because too many files have changed in this diff Show more