diff --git a/src/EllieBot/_common/AddRemove.cs b/src/EllieBot/_common/AddRemove.cs
new file mode 100644
index 0000000..bb3862e
--- /dev/null
+++ b/src/EllieBot/_common/AddRemove.cs
@@ -0,0 +1,10 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public enum AddRemove
+{
+ Add = int.MinValue,
+ Remove = int.MinValue + 1,
+ Rem = int.MinValue + 1,
+ Rm = int.MinValue + 1
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/AliasesAttribute.cs b/src/EllieBot/_common/Attributes/AliasesAttribute.cs
new file mode 100644
index 0000000..bef833e
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/AliasesAttribute.cs
@@ -0,0 +1,12 @@
+using System.Runtime.CompilerServices;
+
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method)]
+public sealed class AliasesAttribute : AliasAttribute
+{
+ public AliasesAttribute([CallerMemberName] string memberName = "")
+ : base(CommandNameLoadHelper.GetAliasesFor(memberName))
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/CmdAttribute.cs b/src/EllieBot/_common/Attributes/CmdAttribute.cs
new file mode 100644
index 0000000..a02fd1e
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/CmdAttribute.cs
@@ -0,0 +1,18 @@
+using System.Runtime.CompilerServices;
+
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method)]
+public sealed class CmdAttribute : CommandAttribute
+{
+ public string MethodName { get; }
+
+ public CmdAttribute([CallerMemberName] string memberName = "")
+ : base(CommandNameLoadHelper.GetCommandNameFor(memberName))
+ {
+ MethodName = memberName.ToLowerInvariant();
+ Aliases = CommandNameLoadHelper.GetAliasesFor(memberName);
+ Remarks = memberName.ToLowerInvariant();
+ Summary = memberName.ToLowerInvariant();
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs
new file mode 100644
index 0000000..7be799a
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs
@@ -0,0 +1,11 @@
+#nullable disable
+namespace EllieBot.Common;
+
+///
+/// Classed marked with this attribute will not be added to the service provider
+///
+[AttributeUsage(AttributeTargets.Class)]
+public class DIIgnoreAttribute : Attribute
+{
+
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs b/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs
new file mode 100644
index 0000000..c94b109
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method)]
+public sealed class EllieOptionsAttribute : Attribute
+ where TOption: IEllieCommandOptions
+{
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs b/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs
new file mode 100644
index 0000000..2ce8ccc
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs
@@ -0,0 +1,20 @@
+#nullable disable
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieBot.Common;
+
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+public sealed class NoPublicBotAttribute : PreconditionAttribute
+{
+ public override Task CheckPermissionsAsync(
+ ICommandContext context,
+ CommandInfo command,
+ IServiceProvider services)
+ {
+#if GLOBAL_ELLIE
+ return Task.FromResult(PreconditionResult.FromError("Not available on the public bot. To learn how to selfhost a private bot, click [here](https://docs.elliebot.net/ellie/)."));
+#else
+ return Task.FromResult(PreconditionResult.FromSuccess());
+#endif
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs b/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs
new file mode 100644
index 0000000..6ae9408
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs
@@ -0,0 +1,21 @@
+#nullable disable
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieBot.Common;
+
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+[SuppressMessage("Style", "IDE0022:Use expression body for methods")]
+public sealed class OnlyPublicBotAttribute : PreconditionAttribute
+{
+ public override Task CheckPermissionsAsync(
+ ICommandContext context,
+ CommandInfo command,
+ IServiceProvider services)
+ {
+#if GLOBAL_ELLIE || DEBUG
+ return Task.FromResult(PreconditionResult.FromSuccess());
+#else
+ return Task.FromResult(PreconditionResult.FromError("Only available on the public bot."));
+#endif
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs b/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs
new file mode 100644
index 0000000..7aa9317
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+public sealed class OwnerOnlyAttribute : PreconditionAttribute
+{
+ public override Task CheckPermissionsAsync(
+ ICommandContext context,
+ CommandInfo command,
+ IServiceProvider services)
+ {
+ var creds = services.GetRequiredService().GetCreds();
+
+ return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id
+ ? PreconditionResult.FromSuccess()
+ : PreconditionResult.FromError("Not owner"));
+ }
+}
diff --git a/src/EllieBot/_common/Attributes/RatelimitAttribute.cs b/src/EllieBot/_common/Attributes/RatelimitAttribute.cs
new file mode 100644
index 0000000..7fcf9c8
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/RatelimitAttribute.cs
@@ -0,0 +1,37 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method)]
+public sealed class RatelimitAttribute : PreconditionAttribute
+{
+ public int Seconds { get; }
+
+ public RatelimitAttribute(int seconds)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(seconds);
+
+ Seconds = seconds;
+ }
+
+ public override async Task CheckPermissionsAsync(
+ ICommandContext context,
+ CommandInfo command,
+ IServiceProvider services)
+ {
+ if (Seconds == 0)
+ return PreconditionResult.FromSuccess();
+
+ var cache = services.GetRequiredService();
+ var rem = await cache.GetRatelimitAsync(
+ new($"precondition:{context.User.Id}:{command.Name}"),
+ Seconds.Seconds());
+
+ if (rem is null)
+ return PreconditionResult.FromSuccess();
+
+ var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s.";
+
+ return PreconditionResult.FromError(msgContent);
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/UserPermAttribute.cs b/src/EllieBot/_common/Attributes/UserPermAttribute.cs
new file mode 100644
index 0000000..1b4ee75
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/UserPermAttribute.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Discord;
+
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+public class UserPermAttribute : RequireUserPermissionAttribute
+{
+ public UserPermAttribute(GuildPerm permission)
+ : base(permission)
+ {
+ }
+
+ public UserPermAttribute(ChannelPerm permission)
+ : base(permission)
+ {
+ }
+
+ public override Task CheckPermissionsAsync(
+ ICommandContext context,
+ CommandInfo command,
+ IServiceProvider services)
+ {
+ var permService = services.GetRequiredService();
+ if (permService.TryGetOverrides(context.Guild?.Id ?? 0, command.Name.ToUpperInvariant(), out _))
+ return Task.FromResult(PreconditionResult.FromSuccess());
+
+ return base.CheckPermissionsAsync(context, command, services);
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/BotCommandTypeReader.cs b/src/EllieBot/_common/BotCommandTypeReader.cs
new file mode 100644
index 0000000..fc839bd
--- /dev/null
+++ b/src/EllieBot/_common/BotCommandTypeReader.cs
@@ -0,0 +1,30 @@
+#nullable disable
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class CommandTypeReader : EllieTypeReader
+{
+ private readonly CommandService _cmds;
+ private readonly ICommandHandler _handler;
+
+ public CommandTypeReader(ICommandHandler handler, CommandService cmds)
+ {
+ _handler = handler;
+ _cmds = cmds;
+ }
+
+ public override ValueTask> ReadAsync(ICommandContext ctx, string input)
+ {
+ input = input.ToUpperInvariant();
+ var prefix = _handler.GetPrefix(ctx.Guild);
+ if (!input.StartsWith(prefix.ToUpperInvariant(), StringComparison.InvariantCulture))
+ return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such command found."));
+
+ input = input[prefix.Length..];
+
+ var cmd = _cmds.Commands.FirstOrDefault(c => c.Aliases.Select(a => a.ToUpperInvariant()).Contains(input));
+ if (cmd is null)
+ return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such command found."));
+
+ return new(TypeReaderResult.FromSuccess(cmd));
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CleanupModuleBase.cs b/src/EllieBot/_common/CleanupModuleBase.cs
new file mode 100644
index 0000000..1e97a66
--- /dev/null
+++ b/src/EllieBot/_common/CleanupModuleBase.cs
@@ -0,0 +1,25 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public abstract class CleanupModuleBase : EllieModule
+{
+ protected async Task ConfirmActionInternalAsync(string name, Func action)
+ {
+ try
+ {
+ var embed = _sender.CreateEmbed()
+ .WithTitle(GetText(strs.sql_confirm_exec))
+ .WithDescription(name);
+
+ if (!await PromptUserConfirmAsync(embed))
+ return;
+
+ await action();
+ await ctx.OkAsync();
+ }
+ catch (Exception ex)
+ {
+ await Response().Error(ex.ToString()).SendAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CleverBotResponseStr.cs b/src/EllieBot/_common/CleverBotResponseStr.cs
new file mode 100644
index 0000000..6675a41
--- /dev/null
+++ b/src/EllieBot/_common/CleverBotResponseStr.cs
@@ -0,0 +1,10 @@
+#nullable disable
+using System.Runtime.InteropServices;
+
+namespace EllieBot.Modules.Permissions;
+
+[StructLayout(LayoutKind.Sequential, Size = 1)]
+public readonly struct CleverBotResponseStr
+{
+ public const string CLEVERBOT_RESPONSE = "cleverbot:response";
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CmdStrings.cs b/src/EllieBot/_common/CmdStrings.cs
new file mode 100644
index 0000000..c28ed1a
--- /dev/null
+++ b/src/EllieBot/_common/CmdStrings.cs
@@ -0,0 +1,17 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Common;
+
+public class CmdStrings
+{
+ public string[] Usages { get; }
+ public string Description { get; }
+
+ [JsonConstructor]
+ public CmdStrings([JsonProperty("args")] string[] usages, [JsonProperty("desc")] string description)
+ {
+ Usages = usages;
+ Description = description;
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CommandData.cs b/src/EllieBot/_common/CommandData.cs
new file mode 100644
index 0000000..f0514da
--- /dev/null
+++ b/src/EllieBot/_common/CommandData.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public class CommandData
+{
+ public string Cmd { get; set; }
+ public string Desc { get; set; }
+ public string[] Usage { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CommandNameLoadHelper.cs b/src/EllieBot/_common/CommandNameLoadHelper.cs
new file mode 100644
index 0000000..3d69f2e
--- /dev/null
+++ b/src/EllieBot/_common/CommandNameLoadHelper.cs
@@ -0,0 +1,40 @@
+using EllieBot.Common.Yml;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Common.Attributes;
+
+public static class CommandNameLoadHelper
+{
+ private static readonly IDeserializer _deserializer = new Deserializer();
+
+ private static readonly Lazy> _lazyCommandAliases
+ = new(() => LoadAliases());
+
+ public static Dictionary LoadAliases(string aliasesFilePath = "data/aliases.yml")
+ {
+ var text = File.ReadAllText(aliasesFilePath);
+ return _deserializer.Deserialize>(text);
+ }
+
+ public static Dictionary LoadCommandStrings(
+ string commandsFilePath = "data/strings/commands.yml")
+ {
+ var text = File.ReadAllText(commandsFilePath);
+
+ return Yaml.Deserializer.Deserialize>(text);
+ }
+
+ public static string[] GetAliasesFor(string methodName)
+ => _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1
+ ? aliases.ToArray()
+ : Array.Empty();
+
+ public static string GetCommandNameFor(string methodName)
+ {
+ methodName = methodName.ToLowerInvariant();
+ var toReturn = _lazyCommandAliases.Value.TryGetValue(methodName, out var aliases) && aliases.Length > 0
+ ? aliases[0]
+ : methodName;
+ return toReturn;
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Configs/BotConfig.cs b/src/EllieBot/_common/Configs/BotConfig.cs
new file mode 100644
index 0000000..0715701
--- /dev/null
+++ b/src/EllieBot/_common/Configs/BotConfig.cs
@@ -0,0 +1,210 @@
+#nullable disable
+using Cloneable;
+using EllieBot.Common.Yml;
+using SixLabors.ImageSharp.PixelFormats;
+using System.Globalization;
+using YamlDotNet.Core;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Common.Configs;
+
+[Cloneable]
+public sealed partial class BotConfig : ICloneable
+{
+ [Comment("""DO NOT CHANGE""")]
+ public int Version { get; set; } = 7;
+
+ [Comment("""
+ Most commands, when executed, have a small colored line
+ next to the response. The color depends whether the command
+ is completed, errored or in progress (pending)
+ Color settings below are for the color of those lines.
+ To get color's hex, you can go here https://htmlcolorcodes.com/
+ and copy the hex code fo your selected color (marked as #)
+ """)]
+ public ColorConfig Color { get; set; }
+
+ [Comment("Default bot language. It has to be in the list of supported languages (.langli)")]
+ public CultureInfo DefaultLocale { get; set; }
+
+ [Comment("""
+ Style in which executed commands will show up in the console.
+ Allowed values: Simple, Normal, None
+ """)]
+ public ConsoleOutputType ConsoleOutputType { get; set; }
+
+ [Comment("""Whether the bot will check for new releases every hour""")]
+ public bool CheckForUpdates { get; set; } = true;
+
+ [Comment("""Do you want any messages sent by users in Bot's DM to be forwarded to the owner(s)?""")]
+ public bool ForwardMessages { get; set; }
+
+ [Comment("""
+ Do you want the message to be forwarded only to the first owner specified in the list of owners (in creds.yml),
+ or all owners? (this might cause the bot to lag if there's a lot of owners specified)
+ """)]
+ public bool ForwardToAllOwners { get; set; }
+
+ [Comment("""
+ Any messages sent by users in Bot's DM to be forwarded to the specified channel.
+ This option will only work when ForwardToAllOwners is set to false
+ """)]
+ public ulong? ForwardToChannel { get; set; }
+
+ [Comment("""
+ Should the bot ignore messages from other bots?
+ Settings this to false might get your bot banned if it gets into a spam loop with another bot.
+ This will only affect command executions, other features will still block bots from access.
+ Default true
+ """)]
+ public bool IgnoreOtherBots { get; set; }
+
+ [Comment("""
+ When a user DMs the bot with a message which is not a command
+ they will receive this message. Leave empty for no response. The string which will be sent whenever someone DMs the bot.
+ Supports embeds. How it looks: https://puu.sh/B0BLV.png
+ """)]
+ [YamlMember(ScalarStyle = ScalarStyle.Literal)]
+ public string DmHelpText { get; set; }
+
+ [Comment("""
+ Only users who send a DM to the bot containing one of the specified words will get a DmHelpText response.
+ Case insensitive.
+ Leave empty to reply with DmHelpText to every DM.
+ """)]
+ public List DmHelpTextKeywords { get; set; }
+
+ [Comment("""This is the response for the .h command""")]
+ [YamlMember(ScalarStyle = ScalarStyle.Literal)]
+ public string HelpText { get; set; }
+
+ [Comment("""List of modules and commands completely blocked on the bot""")]
+ public BlockedConfig Blocked { get; set; }
+
+ [Comment("""Which string will be used to recognize the commands""")]
+ public string Prefix { get; set; }
+
+ [Comment("""
+ 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://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;
+ GroupGreets = false;
+ DmHelpTextKeywords =
+ [
+ "help",
+ "commands",
+ "cmds",
+ "module",
+ "can you do"
+ ];
+ }
+
+ // [Comment(@"Whether the prefix will be a suffix, or prefix.
+ // For example, if your prefix is ! you will run a command called 'cash' by typing either
+ // '!cash @Someone' if your prefixIsSuffix: false or
+ // 'cash @Someone!' if your prefixIsSuffix: true")]
+ // public bool PrefixIsSuffix { get; set; }
+
+ // public string Prefixed(string text) => PrefixIsSuffix
+ // ? text + Prefix
+ // : Prefix + text;
+
+ public string Prefixed(string text)
+ => Prefix + text;
+}
+
+[Cloneable]
+public sealed partial class BlockedConfig
+{
+ public HashSet Commands { get; set; }
+ public HashSet Modules { get; set; }
+
+ public BlockedConfig()
+ {
+ Modules = [];
+ Commands = [];
+ }
+}
+
+[Cloneable]
+public partial class ColorConfig
+{
+ [Comment("""Color used for embed responses when command successfully executes""")]
+ public Rgba32 Ok { get; set; }
+
+ [Comment("""Color used for embed responses when command has an error""")]
+ public Rgba32 Error { get; set; }
+
+ [Comment("""Color used for embed responses while command is doing work or is in progress""")]
+ public Rgba32 Pending { get; set; }
+
+ public ColorConfig()
+ {
+ Ok = Rgba32.ParseHex("00e584");
+ Error = Rgba32.ParseHex("ee281f");
+ Pending = Rgba32.ParseHex("faa61a");
+ }
+}
+
+public enum ConsoleOutputType
+{
+ Normal = 0,
+ Simple = 1,
+ None = 2
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Configs/IConfigSeria.cs b/src/EllieBot/_common/Configs/IConfigSeria.cs
new file mode 100644
index 0000000..1f96850
--- /dev/null
+++ b/src/EllieBot/_common/Configs/IConfigSeria.cs
@@ -0,0 +1,18 @@
+namespace EllieBot.Common.Configs;
+
+///
+/// Base interface for available config serializers
+///
+public interface IConfigSeria
+{
+ ///
+ /// Serialize the object to string
+ ///
+ public string Serialize(T obj)
+ where T : notnull;
+
+ ///
+ /// Deserialize string data into an object of the specified type
+ ///
+ public T Deserialize(string data);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Creds.cs b/src/EllieBot/_common/Creds.cs
new file mode 100644
index 0000000..f6aef5d
--- /dev/null
+++ b/src/EllieBot/_common/Creds.cs
@@ -0,0 +1,272 @@
+#nullable disable
+using EllieBot.Common.Yml;
+
+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 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(
+ """
+ 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: "EllieBot.dll -- {0}"
+ Windows default
+ cmd: EllieBot.exe
+ args: "{0}"
+ """)]
+ public RestartConfig RestartCommand { get; set; }
+
+ public Creds()
+ {
+ Version = 7;
+ Token = string.Empty;
+ UsePrivilegedIntents = true;
+ OwnerIds = new List();
+ TotalShards = 1;
+ GoogleApiKey = string.Empty;
+ Votes = new VotesSettings(string.Empty, string.Empty, string.Empty, string.Empty);
+ Patreon = new PatreonSettings(string.Empty, string.Empty, string.Empty, string.Empty);
+ BotListToken = string.Empty;
+ CleverbotApiKey = string.Empty;
+ Gpt3ApiKey = string.Empty;
+ BotCache = BotCacheImplemenation.Memory;
+ RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=";
+ Db = new DbOptions()
+ {
+ Type = "sqlite",
+ ConnectionString = "Data Source=data/EllieBot.db"
+ };
+
+ CoordinatorUrl = "http://localhost:3442";
+
+ RestartCommand = new RestartConfig();
+ Google = new GoogleApiConfig();
+ }
+
+ public class DbOptions
+ : IDbOptions
+ {
+ [Comment("""
+ Database type. "sqlite", "mysql" and "postgresql" are supported.
+ Default is "sqlite"
+ """)]
+ public string Type { get; set; }
+
+ [Comment("""
+ Database connection string.
+ You MUST change this if you're not using "sqlite" type.
+ Default is "Data Source=data/EllieBot.db"
+ Example for mysql: "Server=localhost;Port=3306;Uid=root;Pwd=my_super_secret_mysql_password;Database=ellie"
+ Example for postgresql: "Server=localhost;Port=5432;User Id=postgres;Password=my_super_secret_postgres_password;Database=ellie;"
+ """)]
+ public string ConnectionString { get; set; }
+ }
+
+ public sealed record PatreonSettings : IPatreonSettings
+ {
+ public string ClientId { get; set; }
+ public string AccessToken { get; set; }
+ public string RefreshToken { get; set; }
+ public string ClientSecret { get; set; }
+
+ [Comment(
+ """Campaign ID of your patreon page. Go to your patreon page (make sure you're logged in) and type "prompt('Campaign ID', window.patreon.bootstrap.creator.data.id);" in the console. (ctrl + shift + i)""")]
+ public string CampaignId { get; set; }
+
+ public PatreonSettings(
+ string accessToken,
+ string refreshToken,
+ string clientSecret,
+ string campaignId)
+ {
+ AccessToken = accessToken;
+ RefreshToken = refreshToken;
+ ClientSecret = clientSecret;
+ CampaignId = campaignId;
+ }
+
+ public PatreonSettings()
+ {
+ }
+ }
+
+ public sealed record VotesSettings : IVotesSettings
+ {
+ [Comment("""
+ top.gg votes service url
+ This is the url of your instance of the EllieBot.Votes api
+ Example: https://votes.my.cool.bot.com
+ """)]
+ public string TopggServiceUrl { get; set; }
+
+ [Comment("""
+ Authorization header value sent to the TopGG service url with each request
+ This should be equivalent to the TopggKey in your EllieBot.Votes api appsettings.json file
+ """)]
+ public string TopggKey { get; set; }
+
+ [Comment("""
+ discords.com votes service url
+ This is the url of your instance of the EllieBot.Votes api
+ Example: https://votes.my.cool.bot.com
+ """)]
+ public string DiscordsServiceUrl { get; set; }
+
+ [Comment("""
+ Authorization header value sent to the Discords service url with each request
+ This should be equivalent to the DiscordsKey in your EllieBot.Votes api appsettings.json file
+ """)]
+ public string DiscordsKey { get; set; }
+
+ public VotesSettings()
+ {
+ }
+
+ public VotesSettings(
+ string topggServiceUrl,
+ string topggKey,
+ string discordsServiceUrl,
+ string discordsKey)
+ {
+ TopggServiceUrl = topggServiceUrl;
+ TopggKey = topggKey;
+ DiscordsServiceUrl = discordsServiceUrl;
+ DiscordsKey = discordsKey;
+ }
+ }
+}
+
+public class GoogleApiConfig : IGoogleApiConfig
+{
+ public string SearchId { get; init; }
+ public string ImageSearchId { get; init; }
+}
diff --git a/src/EllieBot/_common/Currency/CurrencyType.cs b/src/EllieBot/_common/Currency/CurrencyType.cs
new file mode 100644
index 0000000..1037fa4
--- /dev/null
+++ b/src/EllieBot/_common/Currency/CurrencyType.cs
@@ -0,0 +1,6 @@
+namespace EllieBot.Services.Currency;
+
+public enum CurrencyType
+{
+ Default
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Currency/IBankService.cs b/src/EllieBot/_common/Currency/IBankService.cs
new file mode 100644
index 0000000..f563fb9
--- /dev/null
+++ b/src/EllieBot/_common/Currency/IBankService.cs
@@ -0,0 +1,10 @@
+namespace EllieBot.Modules.Gambling.Bank;
+
+public interface IBankService
+{
+ Task DepositAsync(ulong userId, long amount);
+ Task WithdrawAsync(ulong userId, long amount);
+ Task GetBalanceAsync(ulong userId);
+ Task AwardAsync(ulong userId, long amount);
+ Task TakeAsync(ulong userId, long amount);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Currency/ICurrencyService.cs b/src/EllieBot/_common/Currency/ICurrencyService.cs
new file mode 100644
index 0000000..35e8273
--- /dev/null
+++ b/src/EllieBot/_common/Currency/ICurrencyService.cs
@@ -0,0 +1,43 @@
+using EllieBot.Db.Models;
+using EllieBot.Services.Currency;
+
+namespace EllieBot.Services;
+
+public interface ICurrencyService
+{
+ Task GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default);
+
+ Task AddBulkAsync(
+ IReadOnlyCollection userIds,
+ long amount,
+ TxData? txData,
+ CurrencyType type = CurrencyType.Default);
+
+ Task RemoveBulkAsync(
+ IReadOnlyCollection 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 RemoveAsync(
+ ulong userId,
+ long amount,
+ TxData? txData);
+
+ Task RemoveAsync(
+ IUser user,
+ long amount,
+ TxData? txData);
+
+ Task> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Currency/ITxTracker.cs b/src/EllieBot/_common/Currency/ITxTracker.cs
new file mode 100644
index 0000000..d7cad66
--- /dev/null
+++ b/src/EllieBot/_common/Currency/ITxTracker.cs
@@ -0,0 +1,9 @@
+using EllieBot.Services.Currency;
+
+namespace EllieBot.Services;
+
+public interface ITxTracker
+{
+ Task TrackAdd(long amount, TxData? txData);
+ Task TrackRemove(long amount, TxData? txData);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Currency/IWallet.cs b/src/EllieBot/_common/Currency/IWallet.cs
new file mode 100644
index 0000000..39018e9
--- /dev/null
+++ b/src/EllieBot/_common/Currency/IWallet.cs
@@ -0,0 +1,40 @@
+namespace EllieBot.Services.Currency;
+
+public interface IWallet
+{
+ public ulong UserId { get; }
+
+ public Task GetBalance();
+ public Task Take(long amount, TxData? txData);
+ public Task Add(long amount, TxData? txData);
+
+ public async Task 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;
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Currency/TxData.cs b/src/EllieBot/_common/Currency/TxData.cs
new file mode 100644
index 0000000..06dbab2
--- /dev/null
+++ b/src/EllieBot/_common/Currency/TxData.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Services.Currency;
+
+public record class TxData(
+ string Type,
+ string Extra,
+ string? Note = "",
+ ulong? OtherId = null);
\ No newline at end of file
diff --git a/src/EllieBot/_common/DbService.cs b/src/EllieBot/_common/DbService.cs
new file mode 100644
index 0000000..cdff91f
--- /dev/null
+++ b/src/EllieBot/_common/DbService.cs
@@ -0,0 +1,15 @@
+#nullable disable
+using Microsoft.EntityFrameworkCore;
+
+namespace EllieBot.Services;
+
+public abstract class DbService
+{
+ ///
+ /// Call this to apply all migrations
+ ///
+ public abstract Task SetupAsync();
+
+ public abstract DbContext CreateRawDbContext(string dbType, string connString);
+ public abstract DbContext GetDbContext();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Deck/Deck.cs b/src/EllieBot/_common/Deck/Deck.cs
new file mode 100644
index 0000000..3398d3c
--- /dev/null
+++ b/src/EllieBot/_common/Deck/Deck.cs
@@ -0,0 +1,309 @@
+#nullable disable
+namespace Ellie.Econ;
+
+public class Deck
+{
+ public enum CardSuit
+ {
+ Spades = 1,
+ Hearts = 2,
+ Diamonds = 3,
+ Clubs = 4
+ }
+
+ private static readonly Dictionary _cardNames = new()
+ {
+ { 1, "Ace" },
+ { 2, "Two" },
+ { 3, "Three" },
+ { 4, "Four" },
+ { 5, "Five" },
+ { 6, "Six" },
+ { 7, "Seven" },
+ { 8, "Eight" },
+ { 9, "Nine" },
+ { 10, "Ten" },
+ { 11, "Jack" },
+ { 12, "Queen" },
+ { 13, "King" }
+ };
+
+ private static Dictionary, bool>> handValues;
+
+ public List CardPool { get; set; }
+ private readonly Random _r = new EllieRandom();
+
+ static Deck()
+ => InitHandValues();
+
+ ///
+ /// Creates a new instance of the BlackJackGame, this allows you to create multiple games running at one time.
+ ///
+ public Deck()
+ => RefillPool();
+
+ ///
+ /// Restart the game of blackjack. It will only refill the pool for now. Probably wont be used, unless you want to have
+ /// only 1 bjg running at one time,
+ /// then you will restart the same game every time.
+ ///
+ public void Restart()
+ => RefillPool();
+
+ ///
+ /// Removes all cards from the pool and refills the pool with all of the possible cards. NOTE: I think this is too
+ /// expensive.
+ /// We should probably make it so it copies another premade list with all the cards, or something.
+ ///
+ protected virtual void RefillPool()
+ {
+ CardPool = new(52);
+ //foreach suit
+ for (var j = 1; j < 14; j++)
+ // and number
+ for (var i = 1; i < 5; i++)
+ //generate a card of that suit and number and add it to the pool
+
+ // the pool will go from ace of spades,hears,diamonds,clubs all the way to the king of spades. hearts, ...
+ CardPool.Add(new((CardSuit)i, j));
+ }
+
+ ///
+ /// Take a card from the pool, you either take it from the top if the deck is shuffled, or from a random place if the
+ /// deck is in the default order.
+ ///
+ /// A card from the pool
+ public Card Draw()
+ {
+ if (CardPool.Count == 0)
+ Restart();
+ //you can either do this if your deck is not shuffled
+
+ var num = _r.Next(0, CardPool.Count);
+ var c = CardPool[num];
+ CardPool.RemoveAt(num);
+ return c;
+
+ // if you want to shuffle when you fill, then take the first one
+ /*
+ Card c = cardPool[0];
+ cardPool.RemoveAt(0);
+ return c;
+ */
+ }
+
+ ///
+ /// Shuffles the deck. Use this if you want to take cards from the top of the deck, instead of randomly. See DrawACard
+ /// method.
+ ///
+ private void Shuffle()
+ {
+ if (CardPool.Count <= 1)
+ return;
+ var orderedPool = CardPool.Shuffle();
+ CardPool ??= orderedPool.ToList();
+ }
+
+ public override string ToString()
+ => string.Concat(CardPool.Select(c => c.ToString())) + Environment.NewLine;
+
+ private static void InitHandValues()
+ {
+ bool HasPair(List cards)
+ {
+ return cards.GroupBy(card => card.Number).Count(group => group.Count() == 2) == 1;
+ }
+
+ bool IsPair(List cards)
+ {
+ return cards.GroupBy(card => card.Number).Count(group => group.Count() == 3) == 0 && HasPair(cards);
+ }
+
+ bool IsTwoPair(List cards)
+ {
+ return cards.GroupBy(card => card.Number).Count(group => group.Count() == 2) == 2;
+ }
+
+ bool IsStraight(List cards)
+ {
+ if (cards.GroupBy(card => card.Number).Count() != cards.Count())
+ return false;
+ var toReturn = cards.Max(card => card.Number) - cards.Min(card => card.Number) == 4;
+ if (toReturn || cards.All(c => c.Number != 1))
+ return toReturn;
+
+ var newCards = cards.Select(c => c.Number == 1 ? new(c.Suit, 14) : c).ToArray();
+ return newCards.Max(card => card.Number) - newCards.Min(card => card.Number) == 4;
+ }
+
+ bool HasThreeOfKind(List cards)
+ {
+ return cards.GroupBy(card => card.Number).Any(group => group.Count() == 3);
+ }
+
+ bool IsThreeOfKind(List cards)
+ {
+ return HasThreeOfKind(cards) && !HasPair(cards);
+ }
+
+ bool IsFlush(List cards)
+ {
+ return cards.GroupBy(card => card.Suit).Count() == 1;
+ }
+
+ bool IsFourOfKind(List cards)
+ {
+ return cards.GroupBy(card => card.Number).Any(group => group.Count() == 4);
+ }
+
+ bool IsFullHouse(List cards)
+ {
+ return HasPair(cards) && HasThreeOfKind(cards);
+ }
+
+ bool HasStraightFlush(List cards)
+ {
+ return IsFlush(cards) && IsStraight(cards);
+ }
+
+ bool IsRoyalFlush(List cards)
+ {
+ return cards.Min(card => card.Number) == 1
+ && cards.Max(card => card.Number) == 13
+ && HasStraightFlush(cards);
+ }
+
+ bool IsStraightFlush(List cards)
+ {
+ return HasStraightFlush(cards) && !IsRoyalFlush(cards);
+ }
+
+ handValues = new()
+ {
+ { "Royal Flush", IsRoyalFlush },
+ { "Straight Flush", IsStraightFlush },
+ { "Four Of A Kind", IsFourOfKind },
+ { "Full House", IsFullHouse },
+ { "Flush", IsFlush },
+ { "Straight", IsStraight },
+ { "Three Of A Kind", IsThreeOfKind },
+ { "Two Pairs", IsTwoPair },
+ { "A Pair", IsPair }
+ };
+ }
+
+ public static string GetHandValue(List cards)
+ {
+ if (handValues is null)
+ InitHandValues();
+
+ foreach (var kvp in handValues.Where(x => x.Value(cards)))
+ return kvp.Key;
+ return "High card " + (cards.FirstOrDefault(c => c.Number == 1)?.GetValueText() ?? cards.Max().GetValueText());
+ }
+
+ public class Card : IComparable
+ {
+ private static readonly IReadOnlyDictionary _suitToSuitChar = new Dictionary
+ {
+ { CardSuit.Diamonds, "♦" },
+ { CardSuit.Clubs, "♣" },
+ { CardSuit.Spades, "♠" },
+ { CardSuit.Hearts, "♥" }
+ };
+
+ private static readonly IReadOnlyDictionary _suitCharToSuit = new Dictionary
+ {
+ { "♦", CardSuit.Diamonds },
+ { "d", CardSuit.Diamonds },
+ { "♣", CardSuit.Clubs },
+ { "c", CardSuit.Clubs },
+ { "♠", CardSuit.Spades },
+ { "s", CardSuit.Spades },
+ { "♥", CardSuit.Hearts },
+ { "h", CardSuit.Hearts }
+ };
+
+ private static readonly IReadOnlyDictionary _numberCharToNumber = new Dictionary
+ {
+ { 'a', 1 },
+ { '2', 2 },
+ { '3', 3 },
+ { '4', 4 },
+ { '5', 5 },
+ { '6', 6 },
+ { '7', 7 },
+ { '8', 8 },
+ { '9', 9 },
+ { 't', 10 },
+ { 'j', 11 },
+ { 'q', 12 },
+ { 'k', 13 }
+ };
+
+ public CardSuit Suit { get; }
+ public int Number { get; }
+
+ public string FullName
+ {
+ get
+ {
+ var str = string.Empty;
+
+ if (Number is <= 10 and > 1)
+ str += "_" + Number;
+ else
+ str += GetValueText().ToLowerInvariant();
+ return str + "_of_" + Suit.ToString().ToLowerInvariant();
+ }
+ }
+
+ private readonly string[] _regIndicators =
+ [
+ "🇦", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:", ":keycap_ten:",
+ "🇯", "🇶", "🇰"
+ ];
+
+ public Card(CardSuit s, int cardNum)
+ {
+ Suit = s;
+ Number = cardNum;
+ }
+
+ public string GetValueText()
+ => _cardNames[Number];
+
+ public override string ToString()
+ => _cardNames[Number] + " Of " + Suit;
+
+ public int CompareTo(object obj)
+ {
+ if (obj is not Card card)
+ return 0;
+ return Number - card.Number;
+ }
+
+ public static Card Parse(string input)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ throw new ArgumentNullException(nameof(input));
+
+ if (input.Length != 2
+ || !_numberCharToNumber.TryGetValue(input[0], out var n)
+ || !_suitCharToSuit.TryGetValue(input[1].ToString(), out var s))
+ throw new ArgumentException("Invalid input", nameof(input));
+
+ return new(s, n);
+ }
+
+ public string GetEmojiString()
+ {
+ var str = string.Empty;
+
+ str += _regIndicators[Number - 1];
+ str += _suitToSuitChar[Suit];
+
+ return str;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Deck/NewCard.cs b/src/EllieBot/_common/Deck/NewCard.cs
new file mode 100644
index 0000000..4a091a4
--- /dev/null
+++ b/src/EllieBot/_common/Deck/NewCard.cs
@@ -0,0 +1,5 @@
+namespace Ellie.Econ;
+
+public abstract record class NewCard(TSuit Suit, TValue Value)
+ where TSuit : struct, Enum
+ where TValue : struct, Enum;
\ No newline at end of file
diff --git a/src/EllieBot/_common/Deck/NewDeck.cs b/src/EllieBot/_common/Deck/NewDeck.cs
new file mode 100644
index 0000000..a71406c
--- /dev/null
+++ b/src/EllieBot/_common/Deck/NewDeck.cs
@@ -0,0 +1,54 @@
+namespace Ellie.Econ;
+
+public abstract class NewDeck
+ where TCard: NewCard
+ where TSuit : struct, Enum
+ where TValue : struct, Enum
+{
+ protected static readonly TSuit[] _suits = Enum.GetValues();
+ protected static readonly TValue[] _values = Enum.GetValues();
+
+ public virtual int CurrentCount
+ => _cards.Count;
+
+ public virtual int TotalCount { get; }
+
+ protected readonly LinkedList _cards = new();
+ public NewDeck()
+ {
+ TotalCount = _suits.Length * _values.Length;
+ }
+
+ public virtual TCard? Draw()
+ {
+ var first = _cards.First;
+ if (first is not null)
+ {
+ _cards.RemoveFirst();
+ return first.Value;
+ }
+
+ return null;
+ }
+
+ public virtual TCard? Peek(int x = 0)
+ {
+ var card = _cards.First;
+ for (var i = 0; i < x; i++)
+ {
+ card = card?.Next;
+ }
+
+ return card?.Value;
+ }
+
+ public virtual void Shuffle()
+ {
+ var cards = _cards.ToList();
+ var newCards = cards.Shuffle();
+
+ _cards.Clear();
+ foreach (var card in newCards)
+ _cards.AddFirst(card);
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs b/src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs
new file mode 100644
index 0000000..2a7e7df
--- /dev/null
+++ b/src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs
@@ -0,0 +1,28 @@
+namespace Ellie.Econ;
+
+public class MultipleRegularDeck : NewDeck
+{
+ private int Decks { get; }
+
+ public override int TotalCount { get; }
+
+ public MultipleRegularDeck(int decks = 1)
+ {
+ if (decks < 1)
+ throw new ArgumentOutOfRangeException(nameof(decks), "Has to be more than 0");
+
+ Decks = decks;
+ TotalCount = base.TotalCount * decks;
+
+ for (var i = 0; i < Decks; i++)
+ {
+ foreach (var suit in _suits)
+ {
+ foreach (var val in _values)
+ {
+ _cards.AddLast((RegularCard)Activator.CreateInstance(typeof(RegularCard), suit, val)!);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Deck/Regular/RegularCard.cs b/src/EllieBot/_common/Deck/Regular/RegularCard.cs
new file mode 100644
index 0000000..337a1ff
--- /dev/null
+++ b/src/EllieBot/_common/Deck/Regular/RegularCard.cs
@@ -0,0 +1,4 @@
+namespace Ellie.Econ;
+
+public sealed record class RegularCard(RegularSuit Suit, RegularValue Value)
+ : NewCard(Suit, Value);
\ No newline at end of file
diff --git a/src/EllieBot/_common/Deck/Regular/RegularDeck.cs b/src/EllieBot/_common/Deck/Regular/RegularDeck.cs
new file mode 100644
index 0000000..6997623
--- /dev/null
+++ b/src/EllieBot/_common/Deck/Regular/RegularDeck.cs
@@ -0,0 +1,15 @@
+namespace Ellie.Econ;
+
+public sealed class RegularDeck : NewDeck
+{
+ public RegularDeck()
+ {
+ foreach (var suit in _suits)
+ {
+ foreach (var val in _values)
+ {
+ _cards.AddLast((RegularCard)Activator.CreateInstance(typeof(RegularCard), suit, val)!);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs b/src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs
new file mode 100644
index 0000000..98c880c
--- /dev/null
+++ b/src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs
@@ -0,0 +1,56 @@
+namespace Ellie.Econ;
+
+public static class RegularDeckExtensions
+{
+ public static string GetEmoji(this RegularSuit suit)
+ => suit switch
+ {
+ RegularSuit.Hearts => "♥️",
+ RegularSuit.Spades => "♠️",
+ RegularSuit.Diamonds => "♦️",
+ _ => "♣️",
+ };
+
+ public static string GetEmoji(this RegularValue value)
+ => value switch
+ {
+ RegularValue.Ace => "🇦",
+ RegularValue.Two => "2️⃣",
+ RegularValue.Three => "3️⃣",
+ RegularValue.Four => "4️⃣",
+ RegularValue.Five => "5️⃣",
+ RegularValue.Six => "6️⃣",
+ RegularValue.Seven => "7️⃣",
+ RegularValue.Eight => "8️⃣",
+ RegularValue.Nine => "9️⃣",
+ RegularValue.Ten => "🔟",
+ RegularValue.Jack => "🇯",
+ RegularValue.Queen => "🇶",
+ _ => "🇰",
+ };
+
+ public static string GetEmoji(this RegularCard card)
+ => $"{card.Value.GetEmoji()} {card.Suit.GetEmoji()}";
+
+ public static string GetName(this RegularValue value)
+ => value.ToString();
+
+ public static string GetName(this RegularSuit suit)
+ => suit.ToString();
+
+ public static string GetName(this RegularCard card)
+ => $"{card.Value.ToString()} of {card.Suit.GetName()}";
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EllieBot/_common/Deck/Regular/RegularSuit.cs b/src/EllieBot/_common/Deck/Regular/RegularSuit.cs
new file mode 100644
index 0000000..dc4167b
--- /dev/null
+++ b/src/EllieBot/_common/Deck/Regular/RegularSuit.cs
@@ -0,0 +1,9 @@
+namespace Ellie.Econ;
+
+public enum RegularSuit
+{
+ Hearts,
+ Diamonds,
+ Clubs,
+ Spades
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Deck/Regular/RegularValue.cs b/src/EllieBot/_common/Deck/Regular/RegularValue.cs
new file mode 100644
index 0000000..8aa9171
--- /dev/null
+++ b/src/EllieBot/_common/Deck/Regular/RegularValue.cs
@@ -0,0 +1,18 @@
+namespace Ellie.Econ;
+
+public enum RegularValue
+{
+ Ace = 1,
+ Two = 2,
+ Three = 3,
+ Four = 4,
+ Five = 5,
+ Six = 6,
+ Seven = 7,
+ Eight = 8,
+ Nine = 9,
+ Ten = 10,
+ Jack = 12,
+ Queen = 13,
+ King = 14,
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/DoAsUserMessage.cs b/src/EllieBot/_common/DoAsUserMessage.cs
new file mode 100644
index 0000000..f8fba27
--- /dev/null
+++ b/src/EllieBot/_common/DoAsUserMessage.cs
@@ -0,0 +1,154 @@
+using MessageType = Discord.MessageType;
+
+namespace EllieBot.Modules.Administration;
+
+public sealed class DoAsUserMessage : IUserMessage
+{
+ private readonly string _message;
+ private IUserMessage _msg;
+ private readonly IUser _user;
+
+ public DoAsUserMessage(SocketUserMessage msg, IUser user, string message)
+ {
+ _msg = msg;
+ _user = user;
+ _message = message;
+ }
+
+ public ulong Id => _msg.Id;
+
+ public DateTimeOffset CreatedAt => _msg.CreatedAt;
+
+ public Task DeleteAsync(RequestOptions? options = null)
+ {
+ return _msg.DeleteAsync(options);
+ }
+
+ public Task AddReactionAsync(IEmote emote, RequestOptions? options = null)
+ {
+ return _msg.AddReactionAsync(emote, options);
+ }
+
+ public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions? options = null)
+ {
+ return _msg.RemoveReactionAsync(emote, user, options);
+ }
+
+ public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions? options = null)
+ {
+ return _msg.RemoveReactionAsync(emote, userId, options);
+ }
+
+ public Task RemoveAllReactionsAsync(RequestOptions? options = null)
+ {
+ return _msg.RemoveAllReactionsAsync(options);
+ }
+
+ public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions? options = null)
+ {
+ return _msg.RemoveAllReactionsForEmoteAsync(emote, options);
+ }
+
+ public IAsyncEnumerable> GetReactionUsersAsync(
+ IEmote emoji,
+ int limit,
+ RequestOptions? options = null,
+ ReactionType type = ReactionType.Normal)
+ => _msg.GetReactionUsersAsync(emoji, limit, options, type);
+
+ public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit,
+ RequestOptions? options = null)
+ {
+ return _msg.GetReactionUsersAsync(emoji, limit, options);
+ }
+
+ public MessageType Type => _msg.Type;
+
+ public MessageSource Source => _msg.Source;
+
+ public bool IsTTS => _msg.IsTTS;
+
+ public bool IsPinned => _msg.IsPinned;
+
+ public bool IsSuppressed => _msg.IsSuppressed;
+
+ public bool MentionedEveryone => _msg.MentionedEveryone;
+
+ public string Content => _message;
+
+ public string CleanContent => _msg.CleanContent;
+
+ public DateTimeOffset Timestamp => _msg.Timestamp;
+
+ public DateTimeOffset? EditedTimestamp => _msg.EditedTimestamp;
+
+ public IMessageChannel Channel => _msg.Channel;
+
+ public IUser Author => _user;
+
+ public IThreadChannel Thread => _msg.Thread;
+
+ public IReadOnlyCollection Attachments => _msg.Attachments;
+
+ public IReadOnlyCollection Embeds => _msg.Embeds;
+
+ public IReadOnlyCollection Tags => _msg.Tags;
+
+ public IReadOnlyCollection MentionedChannelIds => _msg.MentionedChannelIds;
+
+ public IReadOnlyCollection MentionedRoleIds => _msg.MentionedRoleIds;
+
+ public IReadOnlyCollection MentionedUserIds => _msg.MentionedUserIds;
+
+ public MessageActivity Activity => _msg.Activity;
+
+ public MessageApplication Application => _msg.Application;
+
+ public MessageReference Reference => _msg.Reference;
+
+ public IReadOnlyDictionary Reactions => _msg.Reactions;
+
+ public IReadOnlyCollection Components => _msg.Components;
+
+ public IReadOnlyCollection Stickers => _msg.Stickers;
+
+ public MessageFlags? Flags => _msg.Flags;
+
+ [Obsolete("Obsolete in favor of InteractionMetadata")]
+ public IMessageInteraction Interaction => _msg.Interaction;
+ public MessageRoleSubscriptionData RoleSubscriptionData => _msg.RoleSubscriptionData;
+
+ public Task ModifyAsync(Action func, RequestOptions? options = null)
+ {
+ return _msg.ModifyAsync(func, options);
+ }
+
+ public Task PinAsync(RequestOptions? options = null)
+ {
+ return _msg.PinAsync(options);
+ }
+
+ public Task UnpinAsync(RequestOptions? options = null)
+ {
+ return _msg.UnpinAsync(options);
+ }
+
+ public Task CrosspostAsync(RequestOptions? options = null)
+ {
+ return _msg.CrosspostAsync(options);
+ }
+
+ public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name,
+ TagHandling roleHandling = TagHandling.Name,
+ TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name)
+ {
+ return _msg.Resolve(userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling);
+ }
+
+ public MessageResolvedData ResolvedData => _msg.ResolvedData;
+
+ public IUserMessage ReferencedMessage => _msg.ReferencedMessage;
+
+ public IMessageInteractionMetadata InteractionMetadata
+ => _msg.InteractionMetadata;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/DownloadTracker.cs b/src/EllieBot/_common/DownloadTracker.cs
new file mode 100644
index 0000000..51d7cc6
--- /dev/null
+++ b/src/EllieBot/_common/DownloadTracker.cs
@@ -0,0 +1,38 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public class DownloadTracker : IEService
+{
+ private ConcurrentDictionary LastDownloads { get; } = new();
+ private readonly SemaphoreSlim _downloadUsersSemaphore = new(1, 1);
+
+ ///
+ /// Ensures all users on the specified guild were downloaded within the last hour.
+ ///
+ /// Guild to check and potentially download users from
+ /// Task representing download state
+ public async Task EnsureUsersDownloadedAsync(IGuild guild)
+ {
+#if GLOBAL_ELLIE
+ return;
+#endif
+ await _downloadUsersSemaphore.WaitAsync();
+ try
+ {
+ var now = DateTime.UtcNow;
+
+ // download once per hour at most
+ var added = LastDownloads.AddOrUpdate(guild.Id,
+ now,
+ (_, old) => now - old > TimeSpan.FromHours(1) ? now : old);
+
+ // means that this entry was just added - download the users
+ if (added == now)
+ await guild.DownloadUsersAsync();
+ }
+ finally
+ {
+ _downloadUsersSemaphore.Release();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/EllieModule.cs b/src/EllieBot/_common/EllieModule.cs
new file mode 100644
index 0000000..ba52708
--- /dev/null
+++ b/src/EllieBot/_common/EllieModule.cs
@@ -0,0 +1,108 @@
+#nullable disable
+using System.Globalization;
+
+// ReSharper disable InconsistentNaming
+
+namespace EllieBot.Common;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Default
+ | ImplicitUseTargetFlags.WithInheritors
+ | ImplicitUseTargetFlags.WithMembers)]
+public abstract class EllieModule : ModuleBase
+{
+ protected CultureInfo Culture { get; set; }
+
+ // Injected by Discord.net
+ public IBotStrings Strings { get; set; }
+ public ICommandHandler _cmdHandler { get; set; }
+ public ILocalization _localization { get; set; }
+ public IEllieInteractionService _inter { get; set; }
+ public IReplacementService repSvc { get; set; }
+ public IMessageSenderService _sender { get; set; }
+ public BotConfigService _bcs { get; set; }
+
+ protected string prefix
+ => _cmdHandler.GetPrefix(ctx.Guild);
+
+ protected ICommandContext ctx
+ => Context;
+
+ public ResponseBuilder Response()
+ => new ResponseBuilder(Strings, _bcs, (DiscordSocketClient)ctx.Client)
+ .Context(ctx);
+
+ protected override void BeforeExecute(CommandInfo command)
+ => Culture = _localization.GetCultureInfo(ctx.Guild?.Id);
+
+ protected string GetText(in LocStr data)
+ => Strings.GetText(data, Culture);
+
+ // localized normal
+ public async Task PromptUserConfirmAsync(EmbedBuilder embed)
+ {
+ embed.WithPendingColor()
+ .WithFooter("yes/no");
+
+ var msg = await Response().Embed(embed).SendAsync();
+ try
+ {
+ var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
+ input = input?.ToUpperInvariant();
+
+ if (input != "YES" && input != "Y")
+ return false;
+
+ return true;
+ }
+ finally
+ {
+ _ = Task.Run(() => msg.DeleteAsync());
+ }
+ }
+
+ // TypeConverter typeConverter = TypeDescriptor.GetConverter(propType); ?
+ public async Task GetUserInputAsync(ulong userId, ulong channelId, Func validate = null)
+ {
+ var userInputTask = new TaskCompletionSource();
+ var dsc = (DiscordSocketClient)ctx.Client;
+ try
+ {
+ dsc.MessageReceived += MessageReceived;
+
+ if (await Task.WhenAny(userInputTask.Task, Task.Delay(10000)) != userInputTask.Task)
+ return null;
+
+ return await userInputTask.Task;
+ }
+ finally
+ {
+ dsc.MessageReceived -= MessageReceived;
+ }
+
+ Task MessageReceived(SocketMessage arg)
+ {
+ _ = Task.Run(() =>
+ {
+ if (arg is not SocketUserMessage userMsg
+ || userMsg.Channel is not ITextChannel
+ || userMsg.Author.Id != userId
+ || userMsg.Channel.Id != channelId)
+ return Task.CompletedTask;
+
+ if (validate is not null && !validate(arg.Content))
+ return Task.CompletedTask;
+
+ if (userInputTask.TrySetResult(arg.Content))
+ userMsg.DeleteAfter(1);
+
+ return Task.CompletedTask;
+ });
+ return Task.CompletedTask;
+ }
+ }
+}
+
+public abstract class EllieModule : EllieModule
+{
+ public TService _service { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/EllieTypeReader.cs b/src/EllieBot/_common/EllieTypeReader.cs
new file mode 100644
index 0000000..bab013e
--- /dev/null
+++ b/src/EllieBot/_common/EllieTypeReader.cs
@@ -0,0 +1,14 @@
+#nullable disable
+namespace EllieBot.Common.TypeReaders;
+
+[MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)]
+public abstract class EllieTypeReader : TypeReader
+{
+ public abstract ValueTask> ReadAsync(ICommandContext ctx, string input);
+
+ public override async Task ReadAsync(
+ ICommandContext ctx,
+ string input,
+ IServiceProvider services)
+ => await ReadAsync(ctx, input);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs
new file mode 100644
index 0000000..8b95530
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Gambling.Betdraw;
+
+public enum BetdrawColorGuess
+{
+ Red,
+ Black
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs
new file mode 100644
index 0000000..5ebcb6c
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs
@@ -0,0 +1,86 @@
+using Ellie.Econ;
+
+namespace EllieBot.Modules.Gambling.Betdraw;
+
+public sealed class BetdrawGame
+{
+ private static readonly EllieRandom _rng = new();
+ private readonly RegularDeck _deck;
+
+ private const decimal SINGLE_GUESS_MULTI = 2.075M;
+ private const decimal DOUBLE_GUESS_MULTI = 4.15M;
+
+ public BetdrawGame()
+ {
+ _deck = new RegularDeck();
+ }
+
+ public BetdrawResult Draw(BetdrawValueGuess? val, BetdrawColorGuess? col, decimal amount)
+ {
+ if (val is null && col is null)
+ throw new ArgumentNullException(nameof(val));
+
+ var card = _deck.Peek(_rng.Next(0, 52))!;
+
+ var realVal = (int)card.Value < 7
+ ? BetdrawValueGuess.Low
+ : BetdrawValueGuess.High;
+
+ var realCol = card.Suit is RegularSuit.Diamonds or RegularSuit.Hearts
+ ? BetdrawColorGuess.Red
+ : BetdrawColorGuess.Black;
+
+ // if card is 7, autoloss
+ if (card.Value == RegularValue.Seven)
+ {
+ return new()
+ {
+ Won = 0M,
+ Multiplier = 0M,
+ ResultType = BetdrawResultType.Lose,
+ Card = card,
+ };
+ }
+
+ byte win = 0;
+ if (val is BetdrawValueGuess valGuess)
+ {
+ if (realVal != valGuess)
+ return new()
+ {
+ Won = 0M,
+ Multiplier = 0M,
+ ResultType = BetdrawResultType.Lose,
+ Card = card
+ };
+
+ ++win;
+ }
+
+ if (col is BetdrawColorGuess colGuess)
+ {
+ if (realCol != colGuess)
+ return new()
+ {
+ Won = 0M,
+ Multiplier = 0M,
+ ResultType = BetdrawResultType.Lose,
+ Card = card
+ };
+
+ ++win;
+ }
+
+ var multi = win == 1
+ ? SINGLE_GUESS_MULTI
+ : DOUBLE_GUESS_MULTI;
+
+ return new()
+ {
+ Won = amount * multi,
+ Multiplier = multi,
+ ResultType = BetdrawResultType.Win,
+ Card = card
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs
new file mode 100644
index 0000000..a491985
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs
@@ -0,0 +1,11 @@
+using Ellie.Econ;
+
+namespace EllieBot.Modules.Gambling.Betdraw;
+
+public readonly struct BetdrawResult
+{
+ public decimal Won { get; init; }
+ public decimal Multiplier { get; init; }
+ public BetdrawResultType ResultType { get; init; }
+ public RegularCard Card { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs
new file mode 100644
index 0000000..cc7ab51
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Gambling.Betdraw;
+
+public enum BetdrawResultType
+{
+ Win,
+ Lose
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs
new file mode 100644
index 0000000..204cc46
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Gambling.Betdraw;
+
+public enum BetdrawValueGuess
+{
+ High,
+ Low,
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs b/src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs
new file mode 100644
index 0000000..f704025
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs
@@ -0,0 +1,33 @@
+namespace EllieBot.Modules.Gambling;
+
+public sealed class BetflipGame
+{
+ private readonly decimal _winMulti;
+ private static readonly EllieRandom _rng = new EllieRandom();
+
+ public BetflipGame(decimal winMulti)
+ {
+ _winMulti = winMulti;
+ }
+
+ public BetflipResult Flip(byte guess, decimal amount)
+ {
+ var side = (byte)_rng.Next(0, 2);
+ if (side == guess)
+ {
+ return new BetflipResult()
+ {
+ Side = side,
+ Won = amount * _winMulti,
+ Multiplier = _winMulti
+ };
+ }
+
+ return new BetflipResult()
+ {
+ Side = side,
+ Won = 0,
+ Multiplier = 0,
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs b/src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs
new file mode 100644
index 0000000..e87f2f8
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs
@@ -0,0 +1,8 @@
+namespace EllieBot.Modules.Gambling;
+
+public readonly struct BetflipResult
+{
+ public decimal Won { get; init; }
+ public byte Side { get; init; }
+ public decimal Multiplier { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs b/src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs
new file mode 100644
index 0000000..7937538
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs
@@ -0,0 +1,42 @@
+namespace EllieBot.Modules.Gambling;
+
+public sealed class BetrollGame
+{
+ private readonly (int WhenAbove, decimal MultiplyBy)[] _thresholdPairs;
+ private readonly EllieRandom _rng;
+
+ public BetrollGame(IReadOnlyList<(int WhenAbove, decimal MultiplyBy)> pairs)
+ {
+ _thresholdPairs = pairs.OrderByDescending(x => x.WhenAbove).ToArray();
+ _rng = new();
+ }
+
+ public BetrollResult Roll(decimal amount = 0)
+ {
+ var roll = _rng.Next(1, 101);
+
+ for (var i = 0; i < _thresholdPairs.Length; i++)
+ {
+ ref var pair = ref _thresholdPairs[i];
+
+ if (pair.WhenAbove < roll)
+ {
+ return new()
+ {
+ Multiplier = pair.MultiplyBy,
+ Roll = roll,
+ Threshold = pair.WhenAbove,
+ Won = amount * pair.MultiplyBy
+ };
+ }
+ }
+
+ return new()
+ {
+ Multiplier = 0,
+ Roll = roll,
+ Threshold = -1,
+ Won = 0,
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs b/src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs
new file mode 100644
index 0000000..b107f36
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Modules.Gambling;
+
+public readonly struct BetrollResult
+{
+ public int Roll { get; init; }
+ public decimal Multiplier { get; init; }
+ public decimal Threshold { get; init; }
+ public decimal Won { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Rps/RpsGame.cs b/src/EllieBot/_common/Gambling/Rps/RpsGame.cs
new file mode 100644
index 0000000..976a67a
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Rps/RpsGame.cs
@@ -0,0 +1,75 @@
+namespace EllieBot.Modules.Gambling.Rps;
+
+public sealed class RpsGame
+{
+ private static readonly EllieRandom _rng = new EllieRandom();
+
+ private const decimal WIN_MULTI = 1.95m;
+ private const decimal DRAW_MULTI = 1m;
+ private const decimal LOSE_MULTI = 0m;
+
+ public RpsGame()
+ {
+
+ }
+
+ public RpsResult Play(RpsPick pick, decimal amount)
+ {
+ var compPick = (RpsPick)_rng.Next(0, 3);
+ if (compPick == pick)
+ {
+ return new()
+ {
+ Won = amount * DRAW_MULTI,
+ Multiplier = DRAW_MULTI,
+ ComputerPick = compPick,
+ Result = RpsResultType.Draw,
+ };
+ }
+
+ if ((compPick == RpsPick.Paper && pick == RpsPick.Rock)
+ || (compPick == RpsPick.Rock && pick == RpsPick.Scissors)
+ || (compPick == RpsPick.Scissors && pick == RpsPick.Paper))
+ {
+ return new()
+ {
+ Won = amount * LOSE_MULTI,
+ Multiplier = LOSE_MULTI,
+ Result = RpsResultType.Lose,
+ ComputerPick = compPick,
+ };
+ }
+
+ return new()
+ {
+ Won = amount * WIN_MULTI,
+ Multiplier = WIN_MULTI,
+ Result = RpsResultType.Win,
+ ComputerPick = compPick,
+ };
+ }
+}
+
+public enum RpsPick : byte
+{
+ Rock = 0,
+ Paper = 1,
+ Scissors = 2,
+}
+
+public enum RpsResultType : byte
+{
+ Win,
+ Draw,
+ Lose
+}
+
+
+
+public readonly struct RpsResult
+{
+ public decimal Won { get; init; }
+ public decimal Multiplier { get; init; }
+ public RpsResultType Result { get; init; }
+ public RpsPick ComputerPick { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Slot/SlotGame.cs b/src/EllieBot/_common/Gambling/Slot/SlotGame.cs
new file mode 100644
index 0000000..83e92eb
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Slot/SlotGame.cs
@@ -0,0 +1,116 @@
+namespace EllieBot.Modules.Gambling;
+
+//here is a payout chart
+//https://lh6.googleusercontent.com/-i1hjAJy_kN4/UswKxmhrbPI/AAAAAAAAB1U/82wq_4ZZc-Y/DE6B0895-6FC1-48BE-AC4F-14D1B91AB75B.jpg
+//thanks to judge for helping me with this
+public class SlotGame
+{
+ private static readonly EllieRandom _rng = new EllieRandom();
+
+ public SlotResult Spin(decimal bet)
+ {
+ var rolls = new[]
+ {
+ (byte)_rng.Next(0, 6),
+ (byte)_rng.Next(0, 6),
+ (byte)_rng.Next(0, 6)
+ };
+
+ ref var a = ref rolls[0];
+ ref var b = ref rolls[1];
+ ref var c = ref rolls[2];
+
+ var multi = 0;
+ var winType = SlotWinType.None;
+ if (a == b && b == c)
+ {
+ if (a == 5)
+ {
+ winType = SlotWinType.TrippleJoker;
+ multi = 30;
+ }
+ else
+ {
+ winType = SlotWinType.TrippleNormal;
+ multi = 10;
+ }
+ }
+ else if (a == 5 && (b == 5 || c == 5)
+ || (b == 5 && c == 5))
+ {
+ winType = SlotWinType.DoubleJoker;
+ multi = 4;
+ }
+ else if (a == 5 || b == 5 || c == 5)
+ {
+ winType = SlotWinType.SingleJoker;
+ multi = 1;
+ }
+
+ return new()
+ {
+ Won = bet * multi,
+ WinType = winType,
+ Multiplier = multi,
+ Rolls = rolls,
+ };
+ }
+}
+
+public enum SlotWinType : byte
+{
+ None,
+ SingleJoker,
+ DoubleJoker,
+ TrippleNormal,
+ TrippleJoker,
+}
+
+/*
+var rolls = new[]
+ {
+ _rng.Next(default(byte), 6),
+ _rng.Next(default(byte), 6),
+ _rng.Next(default(byte), 6)
+ };
+
+ var multi = 0;
+ var winType = SlotWinType.None;
+
+ ref var a = ref rolls[0];
+ ref var b = ref rolls[1];
+ ref var c = ref rolls[2];
+ if (a == b && b == c)
+ {
+ if (a == 5)
+ {
+ winType = SlotWinType.TrippleJoker;
+ multi = 30;
+ }
+ else
+ {
+ winType = SlotWinType.TrippleNormal;
+ multi = 10;
+ }
+ }
+ else if (a == 5 && (b == 5 || c == 5)
+ || (b == 5 && c == 5))
+ {
+ winType = SlotWinType.DoubleJoker;
+ multi = 4;
+ }
+ else if (rolls.Any(x => x == 5))
+ {
+ winType = SlotWinType.SingleJoker;
+ multi = 1;
+ }
+
+ return new()
+ {
+ Won = bet * multi,
+ WinType = winType,
+ Multiplier = multi,
+ Rolls = rolls,
+ };
+ }
+*/
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Slot/SlotResult.cs b/src/EllieBot/_common/Gambling/Slot/SlotResult.cs
new file mode 100644
index 0000000..d88a706
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Slot/SlotResult.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Modules.Gambling;
+
+public readonly struct SlotResult
+{
+ public decimal Multiplier { get; init; }
+ public byte[] Rolls { get; init; }
+ public decimal Won { get; init; }
+ public SlotWinType WinType { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Wof/LuLaResult.cs b/src/EllieBot/_common/Gambling/Wof/LuLaResult.cs
new file mode 100644
index 0000000..cec8cca
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Wof/LuLaResult.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Modules.Gambling;
+
+public readonly struct LuLaResult
+{
+ public int Index { get; init; }
+ public decimal Multiplier { get; init; }
+ public decimal Won { get; init; }
+ public IReadOnlyList Multipliers { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Gambling/Wof/WofGame.cs b/src/EllieBot/_common/Gambling/Wof/WofGame.cs
new file mode 100644
index 0000000..0922271
--- /dev/null
+++ b/src/EllieBot/_common/Gambling/Wof/WofGame.cs
@@ -0,0 +1,34 @@
+namespace EllieBot.Modules.Gambling;
+
+public sealed class LulaGame
+{
+ private static readonly IReadOnlyList DEFAULT_MULTIPLIERS = new[] { 1.7M, 1.5M, 0.2M, 0.1M, 0.3M, 0.5M, 1.2M, 2.4M };
+
+ private readonly IReadOnlyList _multipliers;
+ private static readonly EllieRandom _rng = new();
+
+ public LulaGame(IReadOnlyList multipliers)
+ {
+ _multipliers = multipliers;
+ }
+
+ public LulaGame() : this(DEFAULT_MULTIPLIERS)
+ {
+ }
+
+ public LuLaResult Spin(long bet)
+ {
+ var result = _rng.Next(0, _multipliers.Count);
+
+ var multi = _multipliers[result];
+ var amount = bet * multi;
+
+ return new()
+ {
+ Index = result,
+ Multiplier = multi,
+ Won = amount,
+ Multipliers = _multipliers.ToArray(),
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Helpers.cs b/src/EllieBot/_common/Helpers.cs
new file mode 100644
index 0000000..a7d458f
--- /dev/null
+++ b/src/EllieBot/_common/Helpers.cs
@@ -0,0 +1,13 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public static class Helpers
+{
+ public static void ReadErrorAndExit(int exitCode)
+ {
+ if (!Console.IsInputRedirected)
+ Console.ReadKey();
+
+ Environment.Exit(exitCode);
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/IBot.cs b/src/EllieBot/_common/IBot.cs
new file mode 100644
index 0000000..c6c5a06
--- /dev/null
+++ b/src/EllieBot/_common/IBot.cs
@@ -0,0 +1,12 @@
+#nullable disable
+using EllieBot.Db.Models;
+
+namespace EllieBot;
+
+public interface IBot
+{
+ IReadOnlyList GetCurrentGuildIds();
+ event Func JoinedGuild;
+ IReadOnlyCollection AllGuildConfigs { get; }
+ bool IsReady { get; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ICloneable.cs b/src/EllieBot/_common/ICloneable.cs
new file mode 100644
index 0000000..c8d3fa8
--- /dev/null
+++ b/src/EllieBot/_common/ICloneable.cs
@@ -0,0 +1,8 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public interface ICloneable
+ where T : new()
+{
+ public T Clone();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ICurrencyProvider.cs b/src/EllieBot/_common/ICurrencyProvider.cs
new file mode 100644
index 0000000..0cca0ae
--- /dev/null
+++ b/src/EllieBot/_common/ICurrencyProvider.cs
@@ -0,0 +1,29 @@
+using System.Globalization;
+using System.Numerics;
+
+namespace EllieBot.Common;
+
+public interface ICurrencyProvider
+{
+ string GetCurrencySign();
+}
+
+public static class CurrencyHelper
+{
+ public static string N(T cur, IFormatProvider format)
+ where T : INumber
+ => cur.ToString("C0", format);
+
+ public static string N(T cur, CultureInfo culture, string currencySign)
+ where T : INumber
+ => N(cur, GetCurrencyFormat(culture, currencySign));
+
+ private static IFormatProvider GetCurrencyFormat(CultureInfo culture, string currencySign)
+ {
+ var flowersCurrencyCulture = (CultureInfo)culture.Clone();
+ flowersCurrencyCulture.NumberFormat.CurrencySymbol = currencySign;
+ flowersCurrencyCulture.NumberFormat.CurrencyNegativePattern = 5;
+
+ return flowersCurrencyCulture;
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/IDiscordPermOverrideService.cs b/src/EllieBot/_common/IDiscordPermOverrideService.cs
new file mode 100644
index 0000000..b8471c3
--- /dev/null
+++ b/src/EllieBot/_common/IDiscordPermOverrideService.cs
@@ -0,0 +1,7 @@
+#nullable disable
+namespace Ellie.Common;
+
+public interface IDiscordPermOverrideService
+{
+ bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/IEllieCommandOptions.cs b/src/EllieBot/_common/IEllieCommandOptions.cs
new file mode 100644
index 0000000..bb758b5
--- /dev/null
+++ b/src/EllieBot/_common/IEllieCommandOptions.cs
@@ -0,0 +1,7 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public interface IEllieCommandOptions
+{
+ void NormalizeOptions();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ILogCommandService.cs b/src/EllieBot/_common/ILogCommandService.cs
new file mode 100644
index 0000000..344be96
--- /dev/null
+++ b/src/EllieBot/_common/ILogCommandService.cs
@@ -0,0 +1,34 @@
+using EllieBot.Db.Models;
+
+namespace EllieBot.Common;
+
+public interface ILogCommandService
+{
+ void AddDeleteIgnore(ulong xId);
+ Task LogServer(ulong guildId, ulong channelId, bool actionValue);
+ bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType);
+ LogSetting? GetGuildLogSettings(ulong guildId);
+ bool Log(ulong guildId, ulong? channelId, LogType type);
+}
+
+public enum LogType
+{
+ Other,
+ MessageUpdated,
+ MessageDeleted,
+ UserJoined,
+ UserLeft,
+ UserBanned,
+ UserUnbanned,
+ UserUpdated,
+ ChannelCreated,
+ ChannelDestroyed,
+ ChannelUpdated,
+ UserPresence,
+ VoicePresence,
+ UserMuted,
+ UserWarned,
+
+ ThreadDeleted,
+ ThreadCreated
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/IPermissionChecker.cs b/src/EllieBot/_common/IPermissionChecker.cs
new file mode 100644
index 0000000..81aaa64
--- /dev/null
+++ b/src/EllieBot/_common/IPermissionChecker.cs
@@ -0,0 +1,37 @@
+using OneOf;
+
+namespace EllieBot.Common;
+
+public interface IPermissionChecker
+{
+ Task CheckPermsAsync(IGuild guild,
+ IMessageChannel channel,
+ IUser author,
+ string module,
+ string? cmd);
+}
+
+[GenerateOneOf]
+public partial class PermCheckResult
+ : OneOfBase
+{
+ public bool IsAllowed
+ => IsT0;
+
+ public bool IsCooldown
+ => IsT1;
+
+ public bool IsGlobalBlock
+ => IsT2;
+
+ public bool IsDisallowed
+ => IsT3;
+}
+
+public readonly record struct PermAllowed;
+
+public readonly record struct PermCooldown;
+
+public readonly record struct PermGlobalBlock;
+
+public readonly record struct PermDisallowed(int PermIndex, string PermText, bool IsVerbose);
\ No newline at end of file
diff --git a/src/EllieBot/_common/IPlaceholderProvider.cs b/src/EllieBot/_common/IPlaceholderProvider.cs
new file mode 100644
index 0000000..1766577
--- /dev/null
+++ b/src/EllieBot/_common/IPlaceholderProvider.cs
@@ -0,0 +1,7 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public interface IPlaceholderProvider
+{
+ public IEnumerable<(string Name, Func Func)> GetPlaceholders();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ImageUrls.cs b/src/EllieBot/_common/ImageUrls.cs
new file mode 100644
index 0000000..7274e60
--- /dev/null
+++ b/src/EllieBot/_common/ImageUrls.cs
@@ -0,0 +1,51 @@
+#nullable disable
+using EllieBot.Common.Yml;
+using Cloneable;
+
+namespace EllieBot.Common;
+
+[Cloneable]
+public partial class ImageUrls : ICloneable
+{
+ [Comment("DO NOT CHANGE")]
+ public int Version { get; set; } = 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; }
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/EllieInteraction.cs b/src/EllieBot/_common/Interaction/EllieInteraction.cs
new file mode 100644
index 0000000..89e2103
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/EllieInteraction.cs
@@ -0,0 +1,164 @@
+namespace EllieBot;
+
+public abstract class EllieInteractionBase
+{
+ private readonly ulong _authorId;
+ private readonly Func _onAction;
+ private readonly bool _onlyAuthor;
+ public DiscordSocketClient Client { get; }
+
+ private readonly TaskCompletionSource _interactionCompletedSource;
+
+ private IUserMessage message = null!;
+ private readonly string _customId;
+ private readonly bool _singleUse;
+
+ public EllieInteractionBase(
+ DiscordSocketClient client,
+ ulong authorId,
+ string customId,
+ Func onAction,
+ bool onlyAuthor,
+ bool singleUse = true)
+ {
+ _authorId = authorId;
+ _customId = customId;
+ _onAction = onAction;
+ _onlyAuthor = onlyAuthor;
+ _singleUse = singleUse;
+ _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Client = client;
+ }
+
+ public async Task RunAsync(IUserMessage msg)
+ {
+ message = msg;
+
+ Client.InteractionCreated += OnInteraction;
+ if (_singleUse)
+ await Task.WhenAny(Task.Delay(30_000), _interactionCompletedSource.Task);
+ else
+ await Task.Delay(30_000);
+ 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 != _customId)
+ return Task.CompletedTask;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ _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 sealed class EllieModalSubmitHandler
+{
+ private readonly ulong _authorId;
+ private readonly Func _onAction;
+ private readonly bool _onlyAuthor;
+ public DiscordSocketClient Client { get; }
+
+ private readonly TaskCompletionSource _interactionCompletedSource;
+
+ private IUserMessage message = null!;
+ private readonly string _customId;
+
+ public EllieModalSubmitHandler(
+ DiscordSocketClient client,
+ ulong authorId,
+ string customId,
+ Func onAction,
+ bool onlyAuthor)
+ {
+ _authorId = authorId;
+ _customId = customId;
+ _onAction = onAction;
+ _onlyAuthor = onlyAuthor;
+ _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Client = client;
+ }
+
+ public async Task RunAsync(IUserMessage msg)
+ {
+ message = msg;
+
+ Client.ModalSubmitted += OnInteraction;
+ await Task.WhenAny(Task.Delay(300_000), _interactionCompletedSource.Task);
+ Client.ModalSubmitted -= OnInteraction;
+
+ await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build());
+ }
+
+ private Task OnInteraction(SocketModal sm)
+ {
+ if (sm.Message.Id != message.Id)
+ return Task.CompletedTask;
+
+ if (_onlyAuthor && sm.User.Id != _authorId)
+ return Task.CompletedTask;
+
+ if (sm.Data.CustomId != _customId)
+ return Task.CompletedTask;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ _interactionCompletedSource.TrySetResult(true);
+ await ExecuteOnActionAsync(sm);
+
+ if (!sm.HasResponded)
+ {
+ await sm.DeferAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "An exception occured while handling a: {Message}", ex.Message);
+ }
+ });
+
+ return Task.CompletedTask;
+ }
+
+
+ public Task ExecuteOnActionAsync(SocketModal smd)
+ => _onAction(smd);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/EllieInteractionService.cs b/src/EllieBot/_common/Interaction/EllieInteractionService.cs
new file mode 100644
index 0000000..115c417
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/EllieInteractionService.cs
@@ -0,0 +1,77 @@
+namespace EllieBot;
+
+public class EllieInteractionService : IEllieInteractionService, IEService
+{
+ private readonly DiscordSocketClient _client;
+
+ public EllieInteractionService(DiscordSocketClient client)
+ {
+ _client = client;
+ }
+
+ public EllieInteractionBase Create(
+ ulong userId,
+ ButtonBuilder button,
+ Func onTrigger,
+ bool singleUse = true)
+ => new EllieButtonInteractionHandler(_client,
+ userId,
+ button,
+ onTrigger,
+ onlyAuthor: true,
+ singleUse: singleUse);
+
+ public EllieInteractionBase Create(
+ ulong userId,
+ ButtonBuilder button,
+ Func onTrigger,
+ in T state,
+ bool singleUse = true)
+ => Create(userId,
+ button,
+ ((Func>)((data)
+ => smc => onTrigger(smc, data)))(state),
+ singleUse);
+
+ public EllieInteractionBase Create(
+ ulong userId,
+ SelectMenuBuilder menu,
+ Func onTrigger,
+ bool singleUse = true)
+ => new EllieButtonSelectInteractionHandler(_client,
+ userId,
+ menu,
+ onTrigger,
+ onlyAuthor: true,
+ singleUse: singleUse);
+
+
+ ///
+ /// Create an interaction which opens a modal
+ ///
+ /// Id of the author
+ /// Button builder for the button that will open the modal
+ /// Modal
+ /// The function that will be called when the modal is submitted
+ /// Whether the button is single use
+ ///
+ public EllieInteractionBase Create(
+ ulong userId,
+ ButtonBuilder button,
+ ModalBuilder modal,
+ Func onTrigger,
+ bool singleUse = true)
+ => Create(userId,
+ button,
+ async (smc) =>
+ {
+ await smc.RespondWithModalAsync(modal.Build());
+ var modalHandler = new EllieModalSubmitHandler(_client,
+ userId,
+ modal.CustomId,
+ onTrigger,
+ true);
+ await modalHandler.RunAsync(smc.Message);
+ },
+ singleUse: singleUse);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/IEllieInteractionService.cs b/src/EllieBot/_common/Interaction/IEllieInteractionService.cs
new file mode 100644
index 0000000..967e91d
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/IEllieInteractionService.cs
@@ -0,0 +1,30 @@
+namespace EllieBot;
+
+public interface IEllieInteractionService
+{
+ public EllieInteractionBase Create(
+ ulong userId,
+ ButtonBuilder button,
+ Func onTrigger,
+ bool singleUse = true);
+
+ public EllieInteractionBase Create(
+ ulong userId,
+ ButtonBuilder button,
+ Func onTrigger,
+ in T state,
+ bool singleUse = true);
+
+ EllieInteractionBase Create(
+ ulong userId,
+ SelectMenuBuilder menu,
+ Func onTrigger,
+ bool singleUse = true);
+
+ EllieInteractionBase Create(
+ ulong userId,
+ ButtonBuilder button,
+ ModalBuilder modal,
+ Func onTrigger,
+ bool singleUse = true);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/InteractionHelpers.cs b/src/EllieBot/_common/Interaction/InteractionHelpers.cs
new file mode 100644
index 0000000..0bac67f
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/InteractionHelpers.cs
@@ -0,0 +1,7 @@
+namespace EllieBot;
+
+public static class InteractionHelpers
+{
+ public static readonly IEmote ArrowLeft = Emote.Parse("<:x:1232256519844790302>");
+ public static readonly IEmote ArrowRight = Emote.Parse("<:x:1232256515298295838>");
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs b/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs
new file mode 100644
index 0000000..3e65c1c
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs
@@ -0,0 +1,21 @@
+namespace EllieBot;
+
+public sealed class EllieButtonInteractionHandler : EllieInteractionBase
+{
+ public EllieButtonInteractionHandler(
+ DiscordSocketClient client,
+ ulong authorId,
+ ButtonBuilder button,
+ Func onAction,
+ bool onlyAuthor,
+ bool singleUse = true)
+ : base(client, authorId, button.CustomId, onAction, onlyAuthor, singleUse)
+ {
+ Button = button;
+ }
+
+ public ButtonBuilder Button { get; }
+
+ public override void AddTo(ComponentBuilder cb)
+ => cb.WithButton(Button);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs b/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs
new file mode 100644
index 0000000..cf54f9e
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs
@@ -0,0 +1,15 @@
+namespace EllieBot;
+
+public static class EllieInteractionExtensions
+{
+ public static MessageComponent CreateComponent(
+ this EllieInteractionBase ellieInteractionBase
+ )
+ {
+ var cb = new ComponentBuilder();
+
+ ellieInteractionBase.AddTo(cb);
+
+ return cb.Build();
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs b/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs
new file mode 100644
index 0000000..7100f01
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs
@@ -0,0 +1,21 @@
+namespace EllieBot;
+
+public sealed class EllieButtonSelectInteractionHandler : EllieInteractionBase
+{
+ public EllieButtonSelectInteractionHandler(
+ DiscordSocketClient client,
+ ulong authorId,
+ SelectMenuBuilder menu,
+ Func onAction,
+ bool onlyAuthor,
+ bool singleUse = true)
+ : base(client, authorId, menu.CustomId, onAction, onlyAuthor, singleUse)
+ {
+ Menu = menu;
+ }
+
+ public SelectMenuBuilder Menu { get; }
+
+ public override void AddTo(ComponentBuilder cb)
+ => cb.WithSelectMenu(Menu);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs b/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs
new file mode 100644
index 0000000..28167d6
--- /dev/null
+++ b/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs
@@ -0,0 +1,14 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Common.JsonConverters;
+
+public class CultureInfoConverter : JsonConverter
+{
+ public override CultureInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => new(reader.GetString() ?? "en_US");
+
+ public override void Write(Utf8JsonWriter writer, CultureInfo value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.Name);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs b/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs
new file mode 100644
index 0000000..ef619a6
--- /dev/null
+++ b/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs
@@ -0,0 +1,14 @@
+using SixLabors.ImageSharp.PixelFormats;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Common.JsonConverters;
+
+public class Rgba32Converter : JsonConverter
+{
+ public override Rgba32 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => Rgba32.ParseHex(reader.GetString());
+
+ public override void Write(Utf8JsonWriter writer, Rgba32 value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToHex());
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/LbOpts.cs b/src/EllieBot/_common/LbOpts.cs
new file mode 100644
index 0000000..5df4986
--- /dev/null
+++ b/src/EllieBot/_common/LbOpts.cs
@@ -0,0 +1,14 @@
+#nullable disable
+using CommandLine;
+
+namespace EllieBot.Common;
+
+public class LbOpts : IEllieCommandOptions
+{
+ [Option('c', "clean", Default = false, HelpText = "Only show users who are on the server.")]
+ public bool Clean { get; set; }
+
+ public void NormalizeOptions()
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Linq2DbExpressions.cs b/src/EllieBot/_common/Linq2DbExpressions.cs
new file mode 100644
index 0000000..a724652
--- /dev/null
+++ b/src/EllieBot/_common/Linq2DbExpressions.cs
@@ -0,0 +1,16 @@
+#nullable disable
+using LinqToDB;
+using System.Linq.Expressions;
+
+namespace EllieBot.Common;
+
+public static class Linq2DbExpressions
+{
+ [ExpressionMethod(nameof(GuildOnShardExpression))]
+ public static bool GuildOnShard(ulong guildId, int totalShards, int shardId)
+ => throw new NotSupportedException();
+
+ private static Expression> GuildOnShardExpression()
+ => (guildId, totalShards, shardId)
+ => guildId / 4194304 % (ulong)totalShards == (ulong)shardId;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/LoginErrorHandler.cs b/src/EllieBot/_common/LoginErrorHandler.cs
new file mode 100644
index 0000000..bbdc9ce
--- /dev/null
+++ b/src/EllieBot/_common/LoginErrorHandler.cs
@@ -0,0 +1,52 @@
+#nullable disable
+using System.Net;
+using System.Runtime.CompilerServices;
+
+namespace EllieBot.Common;
+
+public class LoginErrorHandler
+{
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Handle(Exception ex)
+ => Log.Fatal(ex, "A fatal error has occurred while attempting to connect to Discord");
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Handle(HttpException ex)
+ {
+ switch (ex.HttpCode)
+ {
+ case HttpStatusCode.Unauthorized:
+ Log.Error("Your bot token is wrong.\n"
+ + "You can find the bot token under the Bot tab in the developer page.\n"
+ + "Fix your token in the credentials file and restart the bot");
+ break;
+
+ case HttpStatusCode.BadRequest:
+ Log.Error("Something has been incorrectly formatted in your credentials file.\n"
+ + "Use the JSON Guide as reference to fix it and restart the bot");
+ Log.Error("If you are on Linux, make sure Redis is installed and running");
+ break;
+
+ case HttpStatusCode.RequestTimeout:
+ Log.Error("The request timed out. Make sure you have no external program blocking the bot "
+ + "from connecting to the internet");
+ break;
+
+ case HttpStatusCode.ServiceUnavailable:
+ case HttpStatusCode.InternalServerError:
+ Log.Error("Discord is having internal issues. Please, try again later");
+ break;
+
+ case HttpStatusCode.TooManyRequests:
+ Log.Error("Your bot has been ratelimited by Discord. Please, try again later.\n"
+ + "Global ratelimits usually last for an hour");
+ break;
+
+ default:
+ Log.Warning("An error occurred while attempting to connect to Discord");
+ break;
+ }
+
+ Log.Fatal(ex, "Fatal error occurred while loading credentials");
+ }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/BehaviorAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/BehaviorAdapter.cs
new file mode 100644
index 0000000..d76137f
--- /dev/null
+++ b/src/EllieBot/_common/Marmalade/Common/Adapters/BehaviorAdapter.cs
@@ -0,0 +1,78 @@
+using EllieBot.Marmalade;
+
+[DIIgnore]
+public sealed class BehaviorAdapter : ICustomBehavior
+{
+ private readonly WeakReference _canaryWr;
+ private readonly IMarmaladeStrings _strings;
+ private readonly IServiceProvider _services;
+ private readonly string _name;
+
+ public string Name => _name;
+
+ // unused
+ public int Priority
+ => 0;
+
+ public BehaviorAdapter(WeakReference canaryWr, IMarmaladeStrings strings, IServiceProvider services)
+ {
+ _canaryWr = canaryWr;
+ _strings = strings;
+ _services = services;
+
+ _name = canaryWr.TryGetTarget(out var canary)
+ ? $"canary/{canary.GetType().Name}"
+ : "unknown";
+ }
+
+ public async Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command)
+ {
+ if (!_canaryWr.TryGetTarget(out var canary))
+ return false;
+
+ return await canary.ExecPreCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services),
+ moduleName,
+ command.Name);
+ }
+
+ public async Task ExecOnMessageAsync(IGuild? guild, IUserMessage msg)
+ {
+ if (!_canaryWr.TryGetTarget(out var canary))
+ return false;
+
+ return await canary.ExecOnMessageAsync(guild, msg);
+ }
+
+ public async Task TransformInput(
+ IGuild guild,
+ IMessageChannel channel,
+ IUser user,
+ string input)
+ {
+ if (!_canaryWr.TryGetTarget(out var canary))
+ return null;
+
+ return await canary.ExecInputTransformAsync(guild, channel, user, input);
+ }
+
+ public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg)
+ {
+ if (!_canaryWr.TryGetTarget(out var canary))
+ return;
+
+ await canary.ExecOnNoCommandAsync(guild, msg);
+ }
+
+ public async ValueTask ExecPostCommandAsync(ICommandContext context, string moduleName, string commandName)
+ {
+ if (!_canaryWr.TryGetTarget(out var canary))
+ return;
+
+ await canary.ExecPostCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services),
+ moduleName,
+ commandName);
+ }
+
+ public override string ToString()
+ => _name;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs
new file mode 100644
index 0000000..38c1ad9
--- /dev/null
+++ b/src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs
@@ -0,0 +1,9 @@
+using EllieBot.Marmalade;
+
+internal class ContextAdapterFactory
+{
+ public static AnyContext CreateNew(ICommandContext context, IMarmaladeStrings strings, IServiceProvider services)
+ => context.Guild is null
+ ? new DmContextAdapter(context, strings, services)
+ : new GuildContextAdapter(context, strings, services);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs
new file mode 100644
index 0000000..1f2d1cf
--- /dev/null
+++ b/src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs
@@ -0,0 +1,47 @@
+using Microsoft.Extensions.DependencyInjection;
+using EllieBot.Marmalade;
+
+public sealed class DmContextAdapter : DmContext
+{
+ public override IMarmaladeStrings Strings { get; }
+ public override IDMChannel Channel { get; }
+ public override IUserMessage Message { get; }
+ public override ISelfUser Bot { get; }
+ public override IUser User
+ => Message.Author;
+
+
+ private readonly IServiceProvider _services;
+ private readonly Lazy _botStrings;
+ private readonly Lazy _localization;
+
+ public DmContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services)
+ {
+ if (ctx is not { Channel: IDMChannel ch })
+ {
+ throw new ArgumentException("Can't use non-dm context to create DmContextAdapter", nameof(ctx));
+ }
+
+ Strings = strings;
+
+ _services = services;
+
+ Channel = ch;
+ Message = ctx.Message;
+ Bot = ctx.Client.CurrentUser;
+
+
+ _botStrings = new(_services.GetRequiredService);
+ _localization = new(_services.GetRequiredService());
+ }
+
+ public override string GetText(string key, object[]? args = null)
+ {
+ var cultureInfo = _localization.Value.GetCultureInfo(default(ulong?));
+ var output = Strings.GetText(key, cultureInfo, args ?? Array.Empty