Added common files
This took way too long
This commit is contained in:
parent
06f399ff63
commit
547aa8b34d
214 changed files with 11046 additions and 0 deletions
10
src/EllieBot/_common/AddRemove.cs
Normal file
10
src/EllieBot/_common/AddRemove.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public enum AddRemove
|
||||
{
|
||||
Add = int.MinValue,
|
||||
Remove = int.MinValue + 1,
|
||||
Rem = int.MinValue + 1,
|
||||
Rm = int.MinValue + 1
|
||||
}
|
12
src/EllieBot/_common/Attributes/AliasesAttribute.cs
Normal file
12
src/EllieBot/_common/Attributes/AliasesAttribute.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace EllieBot.Common.Attributes;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class AliasesAttribute : AliasAttribute
|
||||
{
|
||||
public AliasesAttribute([CallerMemberName] string memberName = "")
|
||||
: base(CommandNameLoadHelper.GetAliasesFor(memberName))
|
||||
{
|
||||
}
|
||||
}
|
18
src/EllieBot/_common/Attributes/CmdAttribute.cs
Normal file
18
src/EllieBot/_common/Attributes/CmdAttribute.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace EllieBot.Common.Attributes;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class CmdAttribute : CommandAttribute
|
||||
{
|
||||
public string MethodName { get; }
|
||||
|
||||
public CmdAttribute([CallerMemberName] string memberName = "")
|
||||
: base(CommandNameLoadHelper.GetCommandNameFor(memberName))
|
||||
{
|
||||
MethodName = memberName.ToLowerInvariant();
|
||||
Aliases = CommandNameLoadHelper.GetAliasesFor(memberName);
|
||||
Remarks = memberName.ToLowerInvariant();
|
||||
Summary = memberName.ToLowerInvariant();
|
||||
}
|
||||
}
|
11
src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs
Normal file
11
src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Classed marked with this attribute will not be added to the service provider
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public class DIIgnoreAttribute : Attribute
|
||||
{
|
||||
|
||||
}
|
7
src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs
Normal file
7
src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace EllieBot.Common.Attributes;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class EllieOptionsAttribute<TOption> : Attribute
|
||||
where TOption: IEllieCommandOptions
|
||||
{
|
||||
}
|
20
src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs
Normal file
20
src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs
Normal file
|
@ -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<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
#if GLOBAL_ELLIE
|
||||
return Task.FromResult(PreconditionResult.FromError("Not available on the public bot. To learn how to selfhost a private bot, click [here](https://docs.elliebot.net/ellie/)."));
|
||||
#else
|
||||
return Task.FromResult(PreconditionResult.FromSuccess());
|
||||
#endif
|
||||
}
|
||||
}
|
21
src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs
Normal file
21
src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
#nullable disable
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
[SuppressMessage("Style", "IDE0022:Use expression body for methods")]
|
||||
public sealed class OnlyPublicBotAttribute : PreconditionAttribute
|
||||
{
|
||||
public override Task<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
#if GLOBAL_ELLIE || DEBUG
|
||||
return Task.FromResult(PreconditionResult.FromSuccess());
|
||||
#else
|
||||
return Task.FromResult(PreconditionResult.FromError("Only available on the public bot."));
|
||||
#endif
|
||||
}
|
||||
}
|
19
src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs
Normal file
19
src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace EllieBot.Common.Attributes;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public sealed class OwnerOnlyAttribute : PreconditionAttribute
|
||||
{
|
||||
public override Task<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
var creds = services.GetRequiredService<IBotCredsProvider>().GetCreds();
|
||||
|
||||
return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id
|
||||
? PreconditionResult.FromSuccess()
|
||||
: PreconditionResult.FromError("Not owner"));
|
||||
}
|
||||
}
|
37
src/EllieBot/_common/Attributes/RatelimitAttribute.cs
Normal file
37
src/EllieBot/_common/Attributes/RatelimitAttribute.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace EllieBot.Common.Attributes;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class RatelimitAttribute : PreconditionAttribute
|
||||
{
|
||||
public int Seconds { get; }
|
||||
|
||||
public RatelimitAttribute(int seconds)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(seconds);
|
||||
|
||||
Seconds = seconds;
|
||||
}
|
||||
|
||||
public override async Task<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
if (Seconds == 0)
|
||||
return PreconditionResult.FromSuccess();
|
||||
|
||||
var cache = services.GetRequiredService<IBotCache>();
|
||||
var rem = await cache.GetRatelimitAsync(
|
||||
new($"precondition:{context.User.Id}:{command.Name}"),
|
||||
Seconds.Seconds());
|
||||
|
||||
if (rem is null)
|
||||
return PreconditionResult.FromSuccess();
|
||||
|
||||
var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s.";
|
||||
|
||||
return PreconditionResult.FromError(msgContent);
|
||||
}
|
||||
}
|
29
src/EllieBot/_common/Attributes/UserPermAttribute.cs
Normal file
29
src/EllieBot/_common/Attributes/UserPermAttribute.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Discord;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
|
||||
public class UserPermAttribute : RequireUserPermissionAttribute
|
||||
{
|
||||
public UserPermAttribute(GuildPerm permission)
|
||||
: base(permission)
|
||||
{
|
||||
}
|
||||
|
||||
public UserPermAttribute(ChannelPerm permission)
|
||||
: base(permission)
|
||||
{
|
||||
}
|
||||
|
||||
public override Task<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
var permService = services.GetRequiredService<IDiscordPermOverrideService>();
|
||||
if (permService.TryGetOverrides(context.Guild?.Id ?? 0, command.Name.ToUpperInvariant(), out _))
|
||||
return Task.FromResult(PreconditionResult.FromSuccess());
|
||||
|
||||
return base.CheckPermissionsAsync(context, command, services);
|
||||
}
|
||||
}
|
30
src/EllieBot/_common/BotCommandTypeReader.cs
Normal file
30
src/EllieBot/_common/BotCommandTypeReader.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common.TypeReaders;
|
||||
|
||||
public sealed class CommandTypeReader : EllieTypeReader<CommandInfo>
|
||||
{
|
||||
private readonly CommandService _cmds;
|
||||
private readonly ICommandHandler _handler;
|
||||
|
||||
public CommandTypeReader(ICommandHandler handler, CommandService cmds)
|
||||
{
|
||||
_handler = handler;
|
||||
_cmds = cmds;
|
||||
}
|
||||
|
||||
public override ValueTask<TypeReaderResult<CommandInfo>> ReadAsync(ICommandContext ctx, string input)
|
||||
{
|
||||
input = input.ToUpperInvariant();
|
||||
var prefix = _handler.GetPrefix(ctx.Guild);
|
||||
if (!input.StartsWith(prefix.ToUpperInvariant(), StringComparison.InvariantCulture))
|
||||
return new(TypeReaderResult.FromError<CommandInfo>(CommandError.ParseFailed, "No such command found."));
|
||||
|
||||
input = input[prefix.Length..];
|
||||
|
||||
var cmd = _cmds.Commands.FirstOrDefault(c => c.Aliases.Select(a => a.ToUpperInvariant()).Contains(input));
|
||||
if (cmd is null)
|
||||
return new(TypeReaderResult.FromError<CommandInfo>(CommandError.ParseFailed, "No such command found."));
|
||||
|
||||
return new(TypeReaderResult.FromSuccess(cmd));
|
||||
}
|
||||
}
|
25
src/EllieBot/_common/CleanupModuleBase.cs
Normal file
25
src/EllieBot/_common/CleanupModuleBase.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public abstract class CleanupModuleBase : EllieModule
|
||||
{
|
||||
protected async Task ConfirmActionInternalAsync(string name, Func<Task> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithTitle(GetText(strs.sql_confirm_exec))
|
||||
.WithDescription(name);
|
||||
|
||||
if (!await PromptUserConfirmAsync(embed))
|
||||
return;
|
||||
|
||||
await action();
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Response().Error(ex.ToString()).SendAsync();
|
||||
}
|
||||
}
|
||||
}
|
10
src/EllieBot/_common/CleverBotResponseStr.cs
Normal file
10
src/EllieBot/_common/CleverBotResponseStr.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
#nullable disable
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace EllieBot.Modules.Permissions;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Size = 1)]
|
||||
public readonly struct CleverBotResponseStr
|
||||
{
|
||||
public const string CLEVERBOT_RESPONSE = "cleverbot:response";
|
||||
}
|
17
src/EllieBot/_common/CmdStrings.cs
Normal file
17
src/EllieBot/_common/CmdStrings.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public class CmdStrings
|
||||
{
|
||||
public string[] Usages { get; }
|
||||
public string Description { get; }
|
||||
|
||||
[JsonConstructor]
|
||||
public CmdStrings([JsonProperty("args")] string[] usages, [JsonProperty("desc")] string description)
|
||||
{
|
||||
Usages = usages;
|
||||
Description = description;
|
||||
}
|
||||
}
|
9
src/EllieBot/_common/CommandData.cs
Normal file
9
src/EllieBot/_common/CommandData.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public class CommandData
|
||||
{
|
||||
public string Cmd { get; set; }
|
||||
public string Desc { get; set; }
|
||||
public string[] Usage { get; set; }
|
||||
}
|
40
src/EllieBot/_common/CommandNameLoadHelper.cs
Normal file
40
src/EllieBot/_common/CommandNameLoadHelper.cs
Normal file
|
@ -0,0 +1,40 @@
|
|||
using EllieBot.Common.Yml;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace EllieBot.Common.Attributes;
|
||||
|
||||
public static class CommandNameLoadHelper
|
||||
{
|
||||
private static readonly IDeserializer _deserializer = new Deserializer();
|
||||
|
||||
private static readonly Lazy<Dictionary<string, string[]>> _lazyCommandAliases
|
||||
= new(() => LoadAliases());
|
||||
|
||||
public static Dictionary<string, string[]> LoadAliases(string aliasesFilePath = "data/aliases.yml")
|
||||
{
|
||||
var text = File.ReadAllText(aliasesFilePath);
|
||||
return _deserializer.Deserialize<Dictionary<string, string[]>>(text);
|
||||
}
|
||||
|
||||
public static Dictionary<string, CommandStrings> LoadCommandStrings(
|
||||
string commandsFilePath = "data/strings/commands.yml")
|
||||
{
|
||||
var text = File.ReadAllText(commandsFilePath);
|
||||
|
||||
return Yaml.Deserializer.Deserialize<Dictionary<string, CommandStrings>>(text);
|
||||
}
|
||||
|
||||
public static string[] GetAliasesFor(string methodName)
|
||||
=> _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1
|
||||
? aliases.ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
public static string GetCommandNameFor(string methodName)
|
||||
{
|
||||
methodName = methodName.ToLowerInvariant();
|
||||
var toReturn = _lazyCommandAliases.Value.TryGetValue(methodName, out var aliases) && aliases.Length > 0
|
||||
? aliases[0]
|
||||
: methodName;
|
||||
return toReturn;
|
||||
}
|
||||
}
|
210
src/EllieBot/_common/Configs/BotConfig.cs
Normal file
210
src/EllieBot/_common/Configs/BotConfig.cs
Normal file
|
@ -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<BotConfig>
|
||||
{
|
||||
[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<string> DmHelpTextKeywords { get; set; }
|
||||
|
||||
[Comment("""This is the response for the .h command""")]
|
||||
[YamlMember(ScalarStyle = ScalarStyle.Literal)]
|
||||
public string HelpText { get; set; }
|
||||
|
||||
[Comment("""List of modules and commands completely blocked on the bot""")]
|
||||
public BlockedConfig Blocked { get; set; }
|
||||
|
||||
[Comment("""Which string will be used to recognize the commands""")]
|
||||
public string Prefix { get; set; }
|
||||
|
||||
[Comment("""
|
||||
Toggles whether your bot will group greet/bye messages into a single message every 5 seconds.
|
||||
1st user who joins will get greeted immediately
|
||||
If more users join within the next 5 seconds, they will be greeted in groups of 5.
|
||||
This will cause %user.mention% and other placeholders to be replaced with multiple users.
|
||||
Keep in mind this might break some of your embeds - for example if you have %user.avatar% in the thumbnail,
|
||||
it will become invalid, as it will resolve to a list of avatars of grouped users.
|
||||
note: This setting is primarily used if you're afraid of raids, or you're running medium/large bots where some
|
||||
servers might get hundreds of people join at once. This is used to prevent the bot from getting ratelimited,
|
||||
and (slightly) reduce the greet spam in those servers.
|
||||
""")]
|
||||
public bool GroupGreets { get; set; }
|
||||
|
||||
[Comment("""
|
||||
Whether the bot will rotate through all specified statuses.
|
||||
This setting can be changed via .ropl command.
|
||||
See RotatingStatuses submodule in Administration.
|
||||
""")]
|
||||
public bool RotateStatuses { get; set; }
|
||||
|
||||
public BotConfig()
|
||||
{
|
||||
var color = new ColorConfig();
|
||||
Color = color;
|
||||
DefaultLocale = new("en-US");
|
||||
ConsoleOutputType = ConsoleOutputType.Normal;
|
||||
ForwardMessages = false;
|
||||
ForwardToAllOwners = false;
|
||||
DmHelpText = """{"description": "Type `%prefix%h` for help."}""";
|
||||
HelpText = """
|
||||
{
|
||||
"title": "To invite me to your server, use this link",
|
||||
"description": "https://discordapp.com/oauth2/authorize?client_id={0}&scope=bot&permissions=66186303",
|
||||
"color": 53380,
|
||||
"thumbnail": "https://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<string> Commands { get; set; }
|
||||
public HashSet<string> Modules { get; set; }
|
||||
|
||||
public BlockedConfig()
|
||||
{
|
||||
Modules = [];
|
||||
Commands = [];
|
||||
}
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public partial class ColorConfig
|
||||
{
|
||||
[Comment("""Color used for embed responses when command successfully executes""")]
|
||||
public Rgba32 Ok { get; set; }
|
||||
|
||||
[Comment("""Color used for embed responses when command has an error""")]
|
||||
public Rgba32 Error { get; set; }
|
||||
|
||||
[Comment("""Color used for embed responses while command is doing work or is in progress""")]
|
||||
public Rgba32 Pending { get; set; }
|
||||
|
||||
public ColorConfig()
|
||||
{
|
||||
Ok = Rgba32.ParseHex("00e584");
|
||||
Error = Rgba32.ParseHex("ee281f");
|
||||
Pending = Rgba32.ParseHex("faa61a");
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConsoleOutputType
|
||||
{
|
||||
Normal = 0,
|
||||
Simple = 1,
|
||||
None = 2
|
||||
}
|
18
src/EllieBot/_common/Configs/IConfigSeria.cs
Normal file
18
src/EllieBot/_common/Configs/IConfigSeria.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
namespace EllieBot.Common.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// Base interface for available config serializers
|
||||
/// </summary>
|
||||
public interface IConfigSeria
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialize the object to string
|
||||
/// </summary>
|
||||
public string Serialize<T>(T obj)
|
||||
where T : notnull;
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize string data into an object of the specified type
|
||||
/// </summary>
|
||||
public T Deserialize<T>(string data);
|
||||
}
|
272
src/EllieBot/_common/Creds.cs
Normal file
272
src/EllieBot/_common/Creds.cs
Normal file
|
@ -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<ulong> OwnerIds { get; set; }
|
||||
|
||||
[Comment("Keep this on 'true' unless you're sure your bot shouldn't use privileged intents or you're waiting to be accepted")]
|
||||
public bool UsePrivilegedIntents { get; set; }
|
||||
|
||||
[Comment("""
|
||||
The number of shards that the bot will be running on.
|
||||
Leave at 1 if you don't know what you're doing.
|
||||
|
||||
note: If you are planning to have more than one shard, then you must change botCache to 'redis'.
|
||||
Also, in that case you should be using EllieBot.Coordinator to start the bot, and it will correctly override this value.
|
||||
""")]
|
||||
public int TotalShards { get; set; }
|
||||
|
||||
[Comment(
|
||||
"""
|
||||
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<ulong>();
|
||||
TotalShards = 1;
|
||||
GoogleApiKey = string.Empty;
|
||||
Votes = new VotesSettings(string.Empty, string.Empty, string.Empty, string.Empty);
|
||||
Patreon = new PatreonSettings(string.Empty, string.Empty, string.Empty, string.Empty);
|
||||
BotListToken = string.Empty;
|
||||
CleverbotApiKey = string.Empty;
|
||||
Gpt3ApiKey = string.Empty;
|
||||
BotCache = BotCacheImplemenation.Memory;
|
||||
RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=";
|
||||
Db = new DbOptions()
|
||||
{
|
||||
Type = "sqlite",
|
||||
ConnectionString = "Data Source=data/EllieBot.db"
|
||||
};
|
||||
|
||||
CoordinatorUrl = "http://localhost:3442";
|
||||
|
||||
RestartCommand = new RestartConfig();
|
||||
Google = new GoogleApiConfig();
|
||||
}
|
||||
|
||||
public class DbOptions
|
||||
: IDbOptions
|
||||
{
|
||||
[Comment("""
|
||||
Database type. "sqlite", "mysql" and "postgresql" are supported.
|
||||
Default is "sqlite"
|
||||
""")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[Comment("""
|
||||
Database connection string.
|
||||
You MUST change this if you're not using "sqlite" type.
|
||||
Default is "Data Source=data/EllieBot.db"
|
||||
Example for mysql: "Server=localhost;Port=3306;Uid=root;Pwd=my_super_secret_mysql_password;Database=ellie"
|
||||
Example for postgresql: "Server=localhost;Port=5432;User Id=postgres;Password=my_super_secret_postgres_password;Database=ellie;"
|
||||
""")]
|
||||
public string ConnectionString { get; set; }
|
||||
}
|
||||
|
||||
public sealed record PatreonSettings : IPatreonSettings
|
||||
{
|
||||
public string ClientId { get; set; }
|
||||
public string AccessToken { get; set; }
|
||||
public string RefreshToken { get; set; }
|
||||
public string ClientSecret { get; set; }
|
||||
|
||||
[Comment(
|
||||
"""Campaign ID of your patreon page. Go to your patreon page (make sure you're logged in) and type "prompt('Campaign ID', window.patreon.bootstrap.creator.data.id);" in the console. (ctrl + shift + i)""")]
|
||||
public string CampaignId { get; set; }
|
||||
|
||||
public PatreonSettings(
|
||||
string accessToken,
|
||||
string refreshToken,
|
||||
string clientSecret,
|
||||
string campaignId)
|
||||
{
|
||||
AccessToken = accessToken;
|
||||
RefreshToken = refreshToken;
|
||||
ClientSecret = clientSecret;
|
||||
CampaignId = campaignId;
|
||||
}
|
||||
|
||||
public PatreonSettings()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VotesSettings : IVotesSettings
|
||||
{
|
||||
[Comment("""
|
||||
top.gg votes service url
|
||||
This is the url of your instance of the EllieBot.Votes api
|
||||
Example: https://votes.my.cool.bot.com
|
||||
""")]
|
||||
public string TopggServiceUrl { get; set; }
|
||||
|
||||
[Comment("""
|
||||
Authorization header value sent to the TopGG service url with each request
|
||||
This should be equivalent to the TopggKey in your EllieBot.Votes api appsettings.json file
|
||||
""")]
|
||||
public string TopggKey { get; set; }
|
||||
|
||||
[Comment("""
|
||||
discords.com votes service url
|
||||
This is the url of your instance of the EllieBot.Votes api
|
||||
Example: https://votes.my.cool.bot.com
|
||||
""")]
|
||||
public string DiscordsServiceUrl { get; set; }
|
||||
|
||||
[Comment("""
|
||||
Authorization header value sent to the Discords service url with each request
|
||||
This should be equivalent to the DiscordsKey in your EllieBot.Votes api appsettings.json file
|
||||
""")]
|
||||
public string DiscordsKey { get; set; }
|
||||
|
||||
public VotesSettings()
|
||||
{
|
||||
}
|
||||
|
||||
public VotesSettings(
|
||||
string topggServiceUrl,
|
||||
string topggKey,
|
||||
string discordsServiceUrl,
|
||||
string discordsKey)
|
||||
{
|
||||
TopggServiceUrl = topggServiceUrl;
|
||||
TopggKey = topggKey;
|
||||
DiscordsServiceUrl = discordsServiceUrl;
|
||||
DiscordsKey = discordsKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class GoogleApiConfig : IGoogleApiConfig
|
||||
{
|
||||
public string SearchId { get; init; }
|
||||
public string ImageSearchId { get; init; }
|
||||
}
|
6
src/EllieBot/_common/Currency/CurrencyType.cs
Normal file
6
src/EllieBot/_common/Currency/CurrencyType.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace EllieBot.Services.Currency;
|
||||
|
||||
public enum CurrencyType
|
||||
{
|
||||
Default
|
||||
}
|
10
src/EllieBot/_common/Currency/IBankService.cs
Normal file
10
src/EllieBot/_common/Currency/IBankService.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace EllieBot.Modules.Gambling.Bank;
|
||||
|
||||
public interface IBankService
|
||||
{
|
||||
Task<bool> DepositAsync(ulong userId, long amount);
|
||||
Task<bool> WithdrawAsync(ulong userId, long amount);
|
||||
Task<long> GetBalanceAsync(ulong userId);
|
||||
Task<bool> AwardAsync(ulong userId, long amount);
|
||||
Task<bool> TakeAsync(ulong userId, long amount);
|
||||
}
|
43
src/EllieBot/_common/Currency/ICurrencyService.cs
Normal file
43
src/EllieBot/_common/Currency/ICurrencyService.cs
Normal file
|
@ -0,0 +1,43 @@
|
|||
using EllieBot.Db.Models;
|
||||
using EllieBot.Services.Currency;
|
||||
|
||||
namespace EllieBot.Services;
|
||||
|
||||
public interface ICurrencyService
|
||||
{
|
||||
Task<IWallet> GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default);
|
||||
|
||||
Task AddBulkAsync(
|
||||
IReadOnlyCollection<ulong> userIds,
|
||||
long amount,
|
||||
TxData? txData,
|
||||
CurrencyType type = CurrencyType.Default);
|
||||
|
||||
Task RemoveBulkAsync(
|
||||
IReadOnlyCollection<ulong> userIds,
|
||||
long amount,
|
||||
TxData? txData,
|
||||
CurrencyType type = CurrencyType.Default);
|
||||
|
||||
Task AddAsync(
|
||||
ulong userId,
|
||||
long amount,
|
||||
TxData? txData);
|
||||
|
||||
Task AddAsync(
|
||||
IUser user,
|
||||
long amount,
|
||||
TxData? txData);
|
||||
|
||||
Task<bool> RemoveAsync(
|
||||
ulong userId,
|
||||
long amount,
|
||||
TxData? txData);
|
||||
|
||||
Task<bool> RemoveAsync(
|
||||
IUser user,
|
||||
long amount,
|
||||
TxData? txData);
|
||||
|
||||
Task<IReadOnlyList<DiscordUser>> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9);
|
||||
}
|
9
src/EllieBot/_common/Currency/ITxTracker.cs
Normal file
9
src/EllieBot/_common/Currency/ITxTracker.cs
Normal file
|
@ -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);
|
||||
}
|
40
src/EllieBot/_common/Currency/IWallet.cs
Normal file
40
src/EllieBot/_common/Currency/IWallet.cs
Normal file
|
@ -0,0 +1,40 @@
|
|||
namespace EllieBot.Services.Currency;
|
||||
|
||||
public interface IWallet
|
||||
{
|
||||
public ulong UserId { get; }
|
||||
|
||||
public Task<long> GetBalance();
|
||||
public Task<bool> Take(long amount, TxData? txData);
|
||||
public Task Add(long amount, TxData? txData);
|
||||
|
||||
public async Task<bool> Transfer(
|
||||
long amount,
|
||||
IWallet to,
|
||||
TxData? txData)
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0.");
|
||||
|
||||
if (txData is not null)
|
||||
txData = txData with
|
||||
{
|
||||
OtherId = to.UserId
|
||||
};
|
||||
|
||||
var succ = await Take(amount, txData);
|
||||
|
||||
if (!succ)
|
||||
return false;
|
||||
|
||||
if (txData is not null)
|
||||
txData = txData with
|
||||
{
|
||||
OtherId = UserId
|
||||
};
|
||||
|
||||
await to.Add(amount, txData);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
7
src/EllieBot/_common/Currency/TxData.cs
Normal file
7
src/EllieBot/_common/Currency/TxData.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace EllieBot.Services.Currency;
|
||||
|
||||
public record class TxData(
|
||||
string Type,
|
||||
string Extra,
|
||||
string? Note = "",
|
||||
ulong? OtherId = null);
|
15
src/EllieBot/_common/DbService.cs
Normal file
15
src/EllieBot/_common/DbService.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EllieBot.Services;
|
||||
|
||||
public abstract class DbService
|
||||
{
|
||||
/// <summary>
|
||||
/// Call this to apply all migrations
|
||||
/// </summary>
|
||||
public abstract Task SetupAsync();
|
||||
|
||||
public abstract DbContext CreateRawDbContext(string dbType, string connString);
|
||||
public abstract DbContext GetDbContext();
|
||||
}
|
309
src/EllieBot/_common/Deck/Deck.cs
Normal file
309
src/EllieBot/_common/Deck/Deck.cs
Normal file
|
@ -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<int, string> _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<string, Func<List<Card>, bool>> handValues;
|
||||
|
||||
public List<Card> CardPool { get; set; }
|
||||
private readonly Random _r = new EllieRandom();
|
||||
|
||||
static Deck()
|
||||
=> InitHandValues();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the BlackJackGame, this allows you to create multiple games running at one time.
|
||||
/// </summary>
|
||||
public Deck()
|
||||
=> RefillPool();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Restart()
|
||||
=> RefillPool();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <returns>A card from the pool</returns>
|
||||
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;
|
||||
*/
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shuffles the deck. Use this if you want to take cards from the top of the deck, instead of randomly. See DrawACard
|
||||
/// method.
|
||||
/// </summary>
|
||||
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<Card> cards)
|
||||
{
|
||||
return cards.GroupBy(card => card.Number).Count(group => group.Count() == 2) == 1;
|
||||
}
|
||||
|
||||
bool IsPair(List<Card> cards)
|
||||
{
|
||||
return cards.GroupBy(card => card.Number).Count(group => group.Count() == 3) == 0 && HasPair(cards);
|
||||
}
|
||||
|
||||
bool IsTwoPair(List<Card> cards)
|
||||
{
|
||||
return cards.GroupBy(card => card.Number).Count(group => group.Count() == 2) == 2;
|
||||
}
|
||||
|
||||
bool IsStraight(List<Card> 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<Card> cards)
|
||||
{
|
||||
return cards.GroupBy(card => card.Number).Any(group => group.Count() == 3);
|
||||
}
|
||||
|
||||
bool IsThreeOfKind(List<Card> cards)
|
||||
{
|
||||
return HasThreeOfKind(cards) && !HasPair(cards);
|
||||
}
|
||||
|
||||
bool IsFlush(List<Card> cards)
|
||||
{
|
||||
return cards.GroupBy(card => card.Suit).Count() == 1;
|
||||
}
|
||||
|
||||
bool IsFourOfKind(List<Card> cards)
|
||||
{
|
||||
return cards.GroupBy(card => card.Number).Any(group => group.Count() == 4);
|
||||
}
|
||||
|
||||
bool IsFullHouse(List<Card> cards)
|
||||
{
|
||||
return HasPair(cards) && HasThreeOfKind(cards);
|
||||
}
|
||||
|
||||
bool HasStraightFlush(List<Card> cards)
|
||||
{
|
||||
return IsFlush(cards) && IsStraight(cards);
|
||||
}
|
||||
|
||||
bool IsRoyalFlush(List<Card> cards)
|
||||
{
|
||||
return cards.Min(card => card.Number) == 1
|
||||
&& cards.Max(card => card.Number) == 13
|
||||
&& HasStraightFlush(cards);
|
||||
}
|
||||
|
||||
bool IsStraightFlush(List<Card> 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<Card> 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<CardSuit, string> _suitToSuitChar = new Dictionary<CardSuit, string>
|
||||
{
|
||||
{ CardSuit.Diamonds, "♦" },
|
||||
{ CardSuit.Clubs, "♣" },
|
||||
{ CardSuit.Spades, "♠" },
|
||||
{ CardSuit.Hearts, "♥" }
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, CardSuit> _suitCharToSuit = new Dictionary<string, CardSuit>
|
||||
{
|
||||
{ "♦", CardSuit.Diamonds },
|
||||
{ "d", CardSuit.Diamonds },
|
||||
{ "♣", CardSuit.Clubs },
|
||||
{ "c", CardSuit.Clubs },
|
||||
{ "♠", CardSuit.Spades },
|
||||
{ "s", CardSuit.Spades },
|
||||
{ "♥", CardSuit.Hearts },
|
||||
{ "h", CardSuit.Hearts }
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<char, int> _numberCharToNumber = new Dictionary<char, int>
|
||||
{
|
||||
{ '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;
|
||||
}
|
||||
}
|
||||
}
|
5
src/EllieBot/_common/Deck/NewCard.cs
Normal file
5
src/EllieBot/_common/Deck/NewCard.cs
Normal file
|
@ -0,0 +1,5 @@
|
|||
namespace Ellie.Econ;
|
||||
|
||||
public abstract record class NewCard<TSuit, TValue>(TSuit Suit, TValue Value)
|
||||
where TSuit : struct, Enum
|
||||
where TValue : struct, Enum;
|
54
src/EllieBot/_common/Deck/NewDeck.cs
Normal file
54
src/EllieBot/_common/Deck/NewDeck.cs
Normal file
|
@ -0,0 +1,54 @@
|
|||
namespace Ellie.Econ;
|
||||
|
||||
public abstract class NewDeck<TCard, TSuit, TValue>
|
||||
where TCard: NewCard<TSuit, TValue>
|
||||
where TSuit : struct, Enum
|
||||
where TValue : struct, Enum
|
||||
{
|
||||
protected static readonly TSuit[] _suits = Enum.GetValues<TSuit>();
|
||||
protected static readonly TValue[] _values = Enum.GetValues<TValue>();
|
||||
|
||||
public virtual int CurrentCount
|
||||
=> _cards.Count;
|
||||
|
||||
public virtual int TotalCount { get; }
|
||||
|
||||
protected readonly LinkedList<TCard> _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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
namespace Ellie.Econ;
|
||||
|
||||
public class MultipleRegularDeck : NewDeck<RegularCard, RegularSuit, RegularValue>
|
||||
{
|
||||
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)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
4
src/EllieBot/_common/Deck/Regular/RegularCard.cs
Normal file
4
src/EllieBot/_common/Deck/Regular/RegularCard.cs
Normal file
|
@ -0,0 +1,4 @@
|
|||
namespace Ellie.Econ;
|
||||
|
||||
public sealed record class RegularCard(RegularSuit Suit, RegularValue Value)
|
||||
: NewCard<RegularSuit, RegularValue>(Suit, Value);
|
15
src/EllieBot/_common/Deck/Regular/RegularDeck.cs
Normal file
15
src/EllieBot/_common/Deck/Regular/RegularDeck.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
namespace Ellie.Econ;
|
||||
|
||||
public sealed class RegularDeck : NewDeck<RegularCard, RegularSuit, RegularValue>
|
||||
{
|
||||
public RegularDeck()
|
||||
{
|
||||
foreach (var suit in _suits)
|
||||
{
|
||||
foreach (var val in _values)
|
||||
{
|
||||
_cards.AddLast((RegularCard)Activator.CreateInstance(typeof(RegularCard), suit, val)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
56
src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs
Normal file
56
src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs
Normal file
|
@ -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()}";
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
9
src/EllieBot/_common/Deck/Regular/RegularSuit.cs
Normal file
9
src/EllieBot/_common/Deck/Regular/RegularSuit.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
namespace Ellie.Econ;
|
||||
|
||||
public enum RegularSuit
|
||||
{
|
||||
Hearts,
|
||||
Diamonds,
|
||||
Clubs,
|
||||
Spades
|
||||
}
|
18
src/EllieBot/_common/Deck/Regular/RegularValue.cs
Normal file
18
src/EllieBot/_common/Deck/Regular/RegularValue.cs
Normal file
|
@ -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,
|
||||
}
|
154
src/EllieBot/_common/DoAsUserMessage.cs
Normal file
154
src/EllieBot/_common/DoAsUserMessage.cs
Normal file
|
@ -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<IReadOnlyCollection<IUser>> GetReactionUsersAsync(
|
||||
IEmote emoji,
|
||||
int limit,
|
||||
RequestOptions? options = null,
|
||||
ReactionType type = ReactionType.Normal)
|
||||
=> _msg.GetReactionUsersAsync(emoji, limit, options, type);
|
||||
|
||||
public IAsyncEnumerable<IReadOnlyCollection<IUser>> GetReactionUsersAsync(IEmote emoji, int limit,
|
||||
RequestOptions? options = null)
|
||||
{
|
||||
return _msg.GetReactionUsersAsync(emoji, limit, options);
|
||||
}
|
||||
|
||||
public MessageType Type => _msg.Type;
|
||||
|
||||
public MessageSource Source => _msg.Source;
|
||||
|
||||
public bool IsTTS => _msg.IsTTS;
|
||||
|
||||
public bool IsPinned => _msg.IsPinned;
|
||||
|
||||
public bool IsSuppressed => _msg.IsSuppressed;
|
||||
|
||||
public bool MentionedEveryone => _msg.MentionedEveryone;
|
||||
|
||||
public string Content => _message;
|
||||
|
||||
public string CleanContent => _msg.CleanContent;
|
||||
|
||||
public DateTimeOffset Timestamp => _msg.Timestamp;
|
||||
|
||||
public DateTimeOffset? EditedTimestamp => _msg.EditedTimestamp;
|
||||
|
||||
public IMessageChannel Channel => _msg.Channel;
|
||||
|
||||
public IUser Author => _user;
|
||||
|
||||
public IThreadChannel Thread => _msg.Thread;
|
||||
|
||||
public IReadOnlyCollection<IAttachment> Attachments => _msg.Attachments;
|
||||
|
||||
public IReadOnlyCollection<IEmbed> Embeds => _msg.Embeds;
|
||||
|
||||
public IReadOnlyCollection<ITag> Tags => _msg.Tags;
|
||||
|
||||
public IReadOnlyCollection<ulong> MentionedChannelIds => _msg.MentionedChannelIds;
|
||||
|
||||
public IReadOnlyCollection<ulong> MentionedRoleIds => _msg.MentionedRoleIds;
|
||||
|
||||
public IReadOnlyCollection<ulong> MentionedUserIds => _msg.MentionedUserIds;
|
||||
|
||||
public MessageActivity Activity => _msg.Activity;
|
||||
|
||||
public MessageApplication Application => _msg.Application;
|
||||
|
||||
public MessageReference Reference => _msg.Reference;
|
||||
|
||||
public IReadOnlyDictionary<IEmote, ReactionMetadata> Reactions => _msg.Reactions;
|
||||
|
||||
public IReadOnlyCollection<IMessageComponent> Components => _msg.Components;
|
||||
|
||||
public IReadOnlyCollection<IStickerItem> Stickers => _msg.Stickers;
|
||||
|
||||
public MessageFlags? Flags => _msg.Flags;
|
||||
|
||||
[Obsolete("Obsolete in favor of InteractionMetadata")]
|
||||
public IMessageInteraction Interaction => _msg.Interaction;
|
||||
public MessageRoleSubscriptionData RoleSubscriptionData => _msg.RoleSubscriptionData;
|
||||
|
||||
public Task ModifyAsync(Action<MessageProperties> func, RequestOptions? options = null)
|
||||
{
|
||||
return _msg.ModifyAsync(func, options);
|
||||
}
|
||||
|
||||
public Task PinAsync(RequestOptions? options = null)
|
||||
{
|
||||
return _msg.PinAsync(options);
|
||||
}
|
||||
|
||||
public Task UnpinAsync(RequestOptions? options = null)
|
||||
{
|
||||
return _msg.UnpinAsync(options);
|
||||
}
|
||||
|
||||
public Task CrosspostAsync(RequestOptions? options = null)
|
||||
{
|
||||
return _msg.CrosspostAsync(options);
|
||||
}
|
||||
|
||||
public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name,
|
||||
TagHandling roleHandling = TagHandling.Name,
|
||||
TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name)
|
||||
{
|
||||
return _msg.Resolve(userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling);
|
||||
}
|
||||
|
||||
public MessageResolvedData ResolvedData => _msg.ResolvedData;
|
||||
|
||||
public IUserMessage ReferencedMessage => _msg.ReferencedMessage;
|
||||
|
||||
public IMessageInteractionMetadata InteractionMetadata
|
||||
=> _msg.InteractionMetadata;
|
||||
}
|
38
src/EllieBot/_common/DownloadTracker.cs
Normal file
38
src/EllieBot/_common/DownloadTracker.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public class DownloadTracker : IEService
|
||||
{
|
||||
private ConcurrentDictionary<ulong, DateTime> LastDownloads { get; } = new();
|
||||
private readonly SemaphoreSlim _downloadUsersSemaphore = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures all users on the specified guild were downloaded within the last hour.
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild to check and potentially download users from</param>
|
||||
/// <returns>Task representing download state</returns>
|
||||
public async Task EnsureUsersDownloadedAsync(IGuild guild)
|
||||
{
|
||||
#if GLOBAL_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();
|
||||
}
|
||||
}
|
||||
}
|
108
src/EllieBot/_common/EllieModule.cs
Normal file
108
src/EllieBot/_common/EllieModule.cs
Normal file
|
@ -0,0 +1,108 @@
|
|||
#nullable disable
|
||||
using System.Globalization;
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
[UsedImplicitly(ImplicitUseTargetFlags.Default
|
||||
| ImplicitUseTargetFlags.WithInheritors
|
||||
| ImplicitUseTargetFlags.WithMembers)]
|
||||
public abstract class EllieModule : ModuleBase
|
||||
{
|
||||
protected CultureInfo Culture { get; set; }
|
||||
|
||||
// Injected by Discord.net
|
||||
public IBotStrings Strings { get; set; }
|
||||
public ICommandHandler _cmdHandler { get; set; }
|
||||
public ILocalization _localization { get; set; }
|
||||
public IEllieInteractionService _inter { get; set; }
|
||||
public IReplacementService repSvc { get; set; }
|
||||
public IMessageSenderService _sender { get; set; }
|
||||
public BotConfigService _bcs { get; set; }
|
||||
|
||||
protected string prefix
|
||||
=> _cmdHandler.GetPrefix(ctx.Guild);
|
||||
|
||||
protected ICommandContext ctx
|
||||
=> Context;
|
||||
|
||||
public ResponseBuilder Response()
|
||||
=> new ResponseBuilder(Strings, _bcs, (DiscordSocketClient)ctx.Client)
|
||||
.Context(ctx);
|
||||
|
||||
protected override void BeforeExecute(CommandInfo command)
|
||||
=> Culture = _localization.GetCultureInfo(ctx.Guild?.Id);
|
||||
|
||||
protected string GetText(in LocStr data)
|
||||
=> Strings.GetText(data, Culture);
|
||||
|
||||
// localized normal
|
||||
public async Task<bool> PromptUserConfirmAsync(EmbedBuilder embed)
|
||||
{
|
||||
embed.WithPendingColor()
|
||||
.WithFooter("yes/no");
|
||||
|
||||
var msg = await Response().Embed(embed).SendAsync();
|
||||
try
|
||||
{
|
||||
var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
|
||||
input = input?.ToUpperInvariant();
|
||||
|
||||
if (input != "YES" && input != "Y")
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = Task.Run(() => msg.DeleteAsync());
|
||||
}
|
||||
}
|
||||
|
||||
// TypeConverter typeConverter = TypeDescriptor.GetConverter(propType); ?
|
||||
public async Task<string> GetUserInputAsync(ulong userId, ulong channelId, Func<string, bool> validate = null)
|
||||
{
|
||||
var userInputTask = new TaskCompletionSource<string>();
|
||||
var dsc = (DiscordSocketClient)ctx.Client;
|
||||
try
|
||||
{
|
||||
dsc.MessageReceived += MessageReceived;
|
||||
|
||||
if (await Task.WhenAny(userInputTask.Task, Task.Delay(10000)) != userInputTask.Task)
|
||||
return null;
|
||||
|
||||
return await userInputTask.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dsc.MessageReceived -= MessageReceived;
|
||||
}
|
||||
|
||||
Task MessageReceived(SocketMessage arg)
|
||||
{
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
if (arg is not SocketUserMessage userMsg
|
||||
|| userMsg.Channel is not ITextChannel
|
||||
|| userMsg.Author.Id != userId
|
||||
|| userMsg.Channel.Id != channelId)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (validate is not null && !validate(arg.Content))
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (userInputTask.TrySetResult(arg.Content))
|
||||
userMsg.DeleteAfter(1);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class EllieModule<TService> : EllieModule
|
||||
{
|
||||
public TService _service { get; set; }
|
||||
}
|
14
src/EllieBot/_common/EllieTypeReader.cs
Normal file
14
src/EllieBot/_common/EllieTypeReader.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common.TypeReaders;
|
||||
|
||||
[MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)]
|
||||
public abstract class EllieTypeReader<T> : TypeReader
|
||||
{
|
||||
public abstract ValueTask<TypeReaderResult<T>> ReadAsync(ICommandContext ctx, string input);
|
||||
|
||||
public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
|
||||
ICommandContext ctx,
|
||||
string input,
|
||||
IServiceProvider services)
|
||||
=> await ReadAsync(ctx, input);
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace EllieBot.Modules.Gambling.Betdraw;
|
||||
|
||||
public enum BetdrawColorGuess
|
||||
{
|
||||
Red,
|
||||
Black
|
||||
}
|
86
src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs
Normal file
86
src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs
Normal file
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
11
src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs
Normal file
11
src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs
Normal file
|
@ -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; }
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace EllieBot.Modules.Gambling.Betdraw;
|
||||
|
||||
public enum BetdrawResultType
|
||||
{
|
||||
Win,
|
||||
Lose
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace EllieBot.Modules.Gambling.Betdraw;
|
||||
|
||||
public enum BetdrawValueGuess
|
||||
{
|
||||
High,
|
||||
Low,
|
||||
}
|
33
src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs
Normal file
33
src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs
Normal file
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
8
src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs
Normal file
8
src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs
Normal file
|
@ -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; }
|
||||
}
|
42
src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs
Normal file
42
src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs
Normal file
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
9
src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs
Normal file
9
src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs
Normal file
|
@ -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; }
|
||||
}
|
75
src/EllieBot/_common/Gambling/Rps/RpsGame.cs
Normal file
75
src/EllieBot/_common/Gambling/Rps/RpsGame.cs
Normal file
|
@ -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; }
|
||||
}
|
116
src/EllieBot/_common/Gambling/Slot/SlotGame.cs
Normal file
116
src/EllieBot/_common/Gambling/Slot/SlotGame.cs
Normal file
|
@ -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,
|
||||
};
|
||||
}
|
||||
*/
|
9
src/EllieBot/_common/Gambling/Slot/SlotResult.cs
Normal file
9
src/EllieBot/_common/Gambling/Slot/SlotResult.cs
Normal file
|
@ -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; }
|
||||
}
|
9
src/EllieBot/_common/Gambling/Wof/LuLaResult.cs
Normal file
9
src/EllieBot/_common/Gambling/Wof/LuLaResult.cs
Normal file
|
@ -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<decimal> Multipliers { get; init; }
|
||||
}
|
34
src/EllieBot/_common/Gambling/Wof/WofGame.cs
Normal file
34
src/EllieBot/_common/Gambling/Wof/WofGame.cs
Normal file
|
@ -0,0 +1,34 @@
|
|||
namespace EllieBot.Modules.Gambling;
|
||||
|
||||
public sealed class LulaGame
|
||||
{
|
||||
private static readonly IReadOnlyList<decimal> DEFAULT_MULTIPLIERS = new[] { 1.7M, 1.5M, 0.2M, 0.1M, 0.3M, 0.5M, 1.2M, 2.4M };
|
||||
|
||||
private readonly IReadOnlyList<decimal> _multipliers;
|
||||
private static readonly EllieRandom _rng = new();
|
||||
|
||||
public LulaGame(IReadOnlyList<decimal> 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(),
|
||||
};
|
||||
}
|
||||
}
|
13
src/EllieBot/_common/Helpers.cs
Normal file
13
src/EllieBot/_common/Helpers.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public static class Helpers
|
||||
{
|
||||
public static void ReadErrorAndExit(int exitCode)
|
||||
{
|
||||
if (!Console.IsInputRedirected)
|
||||
Console.ReadKey();
|
||||
|
||||
Environment.Exit(exitCode);
|
||||
}
|
||||
}
|
12
src/EllieBot/_common/IBot.cs
Normal file
12
src/EllieBot/_common/IBot.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
#nullable disable
|
||||
using EllieBot.Db.Models;
|
||||
|
||||
namespace EllieBot;
|
||||
|
||||
public interface IBot
|
||||
{
|
||||
IReadOnlyList<ulong> GetCurrentGuildIds();
|
||||
event Func<GuildConfig, Task> JoinedGuild;
|
||||
IReadOnlyCollection<GuildConfig> AllGuildConfigs { get; }
|
||||
bool IsReady { get; }
|
||||
}
|
8
src/EllieBot/_common/ICloneable.cs
Normal file
8
src/EllieBot/_common/ICloneable.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface ICloneable<T>
|
||||
where T : new()
|
||||
{
|
||||
public T Clone();
|
||||
}
|
29
src/EllieBot/_common/ICurrencyProvider.cs
Normal file
29
src/EllieBot/_common/ICurrencyProvider.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface ICurrencyProvider
|
||||
{
|
||||
string GetCurrencySign();
|
||||
}
|
||||
|
||||
public static class CurrencyHelper
|
||||
{
|
||||
public static string N<T>(T cur, IFormatProvider format)
|
||||
where T : INumber<T>
|
||||
=> cur.ToString("C0", format);
|
||||
|
||||
public static string N<T>(T cur, CultureInfo culture, string currencySign)
|
||||
where T : INumber<T>
|
||||
=> N(cur, GetCurrencyFormat(culture, currencySign));
|
||||
|
||||
private static IFormatProvider GetCurrencyFormat(CultureInfo culture, string currencySign)
|
||||
{
|
||||
var flowersCurrencyCulture = (CultureInfo)culture.Clone();
|
||||
flowersCurrencyCulture.NumberFormat.CurrencySymbol = currencySign;
|
||||
flowersCurrencyCulture.NumberFormat.CurrencyNegativePattern = 5;
|
||||
|
||||
return flowersCurrencyCulture;
|
||||
}
|
||||
}
|
7
src/EllieBot/_common/IDiscordPermOverrideService.cs
Normal file
7
src/EllieBot/_common/IDiscordPermOverrideService.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Common;
|
||||
|
||||
public interface IDiscordPermOverrideService
|
||||
{
|
||||
bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm);
|
||||
}
|
7
src/EllieBot/_common/IEllieCommandOptions.cs
Normal file
7
src/EllieBot/_common/IEllieCommandOptions.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface IEllieCommandOptions
|
||||
{
|
||||
void NormalizeOptions();
|
||||
}
|
34
src/EllieBot/_common/ILogCommandService.cs
Normal file
34
src/EllieBot/_common/ILogCommandService.cs
Normal file
|
@ -0,0 +1,34 @@
|
|||
using EllieBot.Db.Models;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface ILogCommandService
|
||||
{
|
||||
void AddDeleteIgnore(ulong xId);
|
||||
Task LogServer(ulong guildId, ulong channelId, bool actionValue);
|
||||
bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType);
|
||||
LogSetting? GetGuildLogSettings(ulong guildId);
|
||||
bool Log(ulong guildId, ulong? channelId, LogType type);
|
||||
}
|
||||
|
||||
public enum LogType
|
||||
{
|
||||
Other,
|
||||
MessageUpdated,
|
||||
MessageDeleted,
|
||||
UserJoined,
|
||||
UserLeft,
|
||||
UserBanned,
|
||||
UserUnbanned,
|
||||
UserUpdated,
|
||||
ChannelCreated,
|
||||
ChannelDestroyed,
|
||||
ChannelUpdated,
|
||||
UserPresence,
|
||||
VoicePresence,
|
||||
UserMuted,
|
||||
UserWarned,
|
||||
|
||||
ThreadDeleted,
|
||||
ThreadCreated
|
||||
}
|
37
src/EllieBot/_common/IPermissionChecker.cs
Normal file
37
src/EllieBot/_common/IPermissionChecker.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
using OneOf;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface IPermissionChecker
|
||||
{
|
||||
Task<PermCheckResult> CheckPermsAsync(IGuild guild,
|
||||
IMessageChannel channel,
|
||||
IUser author,
|
||||
string module,
|
||||
string? cmd);
|
||||
}
|
||||
|
||||
[GenerateOneOf]
|
||||
public partial class PermCheckResult
|
||||
: OneOfBase<PermAllowed, PermCooldown, PermGlobalBlock, PermDisallowed>
|
||||
{
|
||||
public bool IsAllowed
|
||||
=> IsT0;
|
||||
|
||||
public bool IsCooldown
|
||||
=> IsT1;
|
||||
|
||||
public bool IsGlobalBlock
|
||||
=> IsT2;
|
||||
|
||||
public bool IsDisallowed
|
||||
=> IsT3;
|
||||
}
|
||||
|
||||
public readonly record struct PermAllowed;
|
||||
|
||||
public readonly record struct PermCooldown;
|
||||
|
||||
public readonly record struct PermGlobalBlock;
|
||||
|
||||
public readonly record struct PermDisallowed(int PermIndex, string PermText, bool IsVerbose);
|
7
src/EllieBot/_common/IPlaceholderProvider.cs
Normal file
7
src/EllieBot/_common/IPlaceholderProvider.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public interface IPlaceholderProvider
|
||||
{
|
||||
public IEnumerable<(string Name, Func<string> Func)> GetPlaceholders();
|
||||
}
|
51
src/EllieBot/_common/ImageUrls.cs
Normal file
51
src/EllieBot/_common/ImageUrls.cs
Normal file
|
@ -0,0 +1,51 @@
|
|||
#nullable disable
|
||||
using EllieBot.Common.Yml;
|
||||
using Cloneable;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
[Cloneable]
|
||||
public partial class ImageUrls : ICloneable<ImageUrls>
|
||||
{
|
||||
[Comment("DO NOT CHANGE")]
|
||||
public int Version { get; set; } = 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; }
|
||||
}
|
||||
}
|
164
src/EllieBot/_common/Interaction/EllieInteraction.cs
Normal file
164
src/EllieBot/_common/Interaction/EllieInteraction.cs
Normal file
|
@ -0,0 +1,164 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public abstract class EllieInteractionBase
|
||||
{
|
||||
private readonly ulong _authorId;
|
||||
private readonly Func<SocketMessageComponent, Task> _onAction;
|
||||
private readonly bool _onlyAuthor;
|
||||
public DiscordSocketClient Client { get; }
|
||||
|
||||
private readonly TaskCompletionSource<bool> _interactionCompletedSource;
|
||||
|
||||
private IUserMessage message = null!;
|
||||
private readonly string _customId;
|
||||
private readonly bool _singleUse;
|
||||
|
||||
public EllieInteractionBase(
|
||||
DiscordSocketClient client,
|
||||
ulong authorId,
|
||||
string customId,
|
||||
Func<SocketMessageComponent, Task> 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<SocketModal, Task> _onAction;
|
||||
private readonly bool _onlyAuthor;
|
||||
public DiscordSocketClient Client { get; }
|
||||
|
||||
private readonly TaskCompletionSource<bool> _interactionCompletedSource;
|
||||
|
||||
private IUserMessage message = null!;
|
||||
private readonly string _customId;
|
||||
|
||||
public EllieModalSubmitHandler(
|
||||
DiscordSocketClient client,
|
||||
ulong authorId,
|
||||
string customId,
|
||||
Func<SocketModal, Task> onAction,
|
||||
bool onlyAuthor)
|
||||
{
|
||||
_authorId = authorId;
|
||||
_customId = customId;
|
||||
_onAction = onAction;
|
||||
_onlyAuthor = onlyAuthor;
|
||||
_interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Client = client;
|
||||
}
|
||||
|
||||
public async Task RunAsync(IUserMessage msg)
|
||||
{
|
||||
message = msg;
|
||||
|
||||
Client.ModalSubmitted += OnInteraction;
|
||||
await Task.WhenAny(Task.Delay(300_000), _interactionCompletedSource.Task);
|
||||
Client.ModalSubmitted -= OnInteraction;
|
||||
|
||||
await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build());
|
||||
}
|
||||
|
||||
private Task OnInteraction(SocketModal sm)
|
||||
{
|
||||
if (sm.Message.Id != message.Id)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (_onlyAuthor && sm.User.Id != _authorId)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (sm.Data.CustomId != _customId)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_interactionCompletedSource.TrySetResult(true);
|
||||
await ExecuteOnActionAsync(sm);
|
||||
|
||||
if (!sm.HasResponded)
|
||||
{
|
||||
await sm.DeferAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "An exception occured while handling a: {Message}", ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
public Task ExecuteOnActionAsync(SocketModal smd)
|
||||
=> _onAction(smd);
|
||||
}
|
77
src/EllieBot/_common/Interaction/EllieInteractionService.cs
Normal file
77
src/EllieBot/_common/Interaction/EllieInteractionService.cs
Normal file
|
@ -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<SocketMessageComponent, Task> onTrigger,
|
||||
bool singleUse = true)
|
||||
=> new EllieButtonInteractionHandler(_client,
|
||||
userId,
|
||||
button,
|
||||
onTrigger,
|
||||
onlyAuthor: true,
|
||||
singleUse: singleUse);
|
||||
|
||||
public EllieInteractionBase Create<T>(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
Func<SocketMessageComponent, T, Task> onTrigger,
|
||||
in T state,
|
||||
bool singleUse = true)
|
||||
=> Create(userId,
|
||||
button,
|
||||
((Func<T, Func<SocketMessageComponent, Task>>)((data)
|
||||
=> smc => onTrigger(smc, data)))(state),
|
||||
singleUse);
|
||||
|
||||
public EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
SelectMenuBuilder menu,
|
||||
Func<SocketMessageComponent, Task> onTrigger,
|
||||
bool singleUse = true)
|
||||
=> new EllieButtonSelectInteractionHandler(_client,
|
||||
userId,
|
||||
menu,
|
||||
onTrigger,
|
||||
onlyAuthor: true,
|
||||
singleUse: singleUse);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Create an interaction which opens a modal
|
||||
/// </summary>
|
||||
/// <param name="userId">Id of the author</param>
|
||||
/// <param name="button">Button builder for the button that will open the modal</param>
|
||||
/// <param name="modal">Modal</param>
|
||||
/// <param name="onTrigger">The function that will be called when the modal is submitted</param>
|
||||
/// <param name="singleUse">Whether the button is single use</param>
|
||||
/// <returns></returns>
|
||||
public EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
ModalBuilder modal,
|
||||
Func<SocketModal, Task> onTrigger,
|
||||
bool singleUse = true)
|
||||
=> Create(userId,
|
||||
button,
|
||||
async (smc) =>
|
||||
{
|
||||
await smc.RespondWithModalAsync(modal.Build());
|
||||
var modalHandler = new EllieModalSubmitHandler(_client,
|
||||
userId,
|
||||
modal.CustomId,
|
||||
onTrigger,
|
||||
true);
|
||||
await modalHandler.RunAsync(smc.Message);
|
||||
},
|
||||
singleUse: singleUse);
|
||||
}
|
30
src/EllieBot/_common/Interaction/IEllieInteractionService.cs
Normal file
30
src/EllieBot/_common/Interaction/IEllieInteractionService.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public interface IEllieInteractionService
|
||||
{
|
||||
public EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
Func<SocketMessageComponent, Task> onTrigger,
|
||||
bool singleUse = true);
|
||||
|
||||
public EllieInteractionBase Create<T>(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
Func<SocketMessageComponent, T, Task> onTrigger,
|
||||
in T state,
|
||||
bool singleUse = true);
|
||||
|
||||
EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
SelectMenuBuilder menu,
|
||||
Func<SocketMessageComponent, Task> onTrigger,
|
||||
bool singleUse = true);
|
||||
|
||||
EllieInteractionBase Create(
|
||||
ulong userId,
|
||||
ButtonBuilder button,
|
||||
ModalBuilder modal,
|
||||
Func<SocketModal, Task> onTrigger,
|
||||
bool singleUse = true);
|
||||
}
|
7
src/EllieBot/_common/Interaction/InteractionHelpers.cs
Normal file
7
src/EllieBot/_common/Interaction/InteractionHelpers.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public static class InteractionHelpers
|
||||
{
|
||||
public static readonly IEmote ArrowLeft = Emote.Parse("<:x:1232256519844790302>");
|
||||
public static readonly IEmote ArrowRight = Emote.Parse("<:x:1232256515298295838>");
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public sealed class EllieButtonInteractionHandler : EllieInteractionBase
|
||||
{
|
||||
public EllieButtonInteractionHandler(
|
||||
DiscordSocketClient client,
|
||||
ulong authorId,
|
||||
ButtonBuilder button,
|
||||
Func<SocketMessageComponent, Task> 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);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
namespace EllieBot;
|
||||
|
||||
public sealed class EllieButtonSelectInteractionHandler : EllieInteractionBase
|
||||
{
|
||||
public EllieButtonSelectInteractionHandler(
|
||||
DiscordSocketClient client,
|
||||
ulong authorId,
|
||||
SelectMenuBuilder menu,
|
||||
Func<SocketMessageComponent, Task> onAction,
|
||||
bool onlyAuthor,
|
||||
bool singleUse = true)
|
||||
: base(client, authorId, menu.CustomId, onAction, onlyAuthor, singleUse)
|
||||
{
|
||||
Menu = menu;
|
||||
}
|
||||
|
||||
public SelectMenuBuilder Menu { get; }
|
||||
|
||||
public override void AddTo(ComponentBuilder cb)
|
||||
=> cb.WithSelectMenu(Menu);
|
||||
}
|
14
src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs
Normal file
14
src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EllieBot.Common.JsonConverters;
|
||||
|
||||
public class CultureInfoConverter : JsonConverter<CultureInfo>
|
||||
{
|
||||
public override CultureInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> new(reader.GetString() ?? "en_US");
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, CultureInfo value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.Name);
|
||||
}
|
14
src/EllieBot/_common/JsonConverters/Rgba32Converter.cs
Normal file
14
src/EllieBot/_common/JsonConverters/Rgba32Converter.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EllieBot.Common.JsonConverters;
|
||||
|
||||
public class Rgba32Converter : JsonConverter<Rgba32>
|
||||
{
|
||||
public override Rgba32 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> Rgba32.ParseHex(reader.GetString());
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Rgba32 value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToHex());
|
||||
}
|
14
src/EllieBot/_common/LbOpts.cs
Normal file
14
src/EllieBot/_common/LbOpts.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
#nullable disable
|
||||
using CommandLine;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public class LbOpts : IEllieCommandOptions
|
||||
{
|
||||
[Option('c', "clean", Default = false, HelpText = "Only show users who are on the server.")]
|
||||
public bool Clean { get; set; }
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
}
|
||||
}
|
16
src/EllieBot/_common/Linq2DbExpressions.cs
Normal file
16
src/EllieBot/_common/Linq2DbExpressions.cs
Normal file
|
@ -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<Func<ulong, int, int, bool>> GuildOnShardExpression()
|
||||
=> (guildId, totalShards, shardId)
|
||||
=> guildId / 4194304 % (ulong)totalShards == (ulong)shardId;
|
||||
}
|
52
src/EllieBot/_common/LoginErrorHandler.cs
Normal file
52
src/EllieBot/_common/LoginErrorHandler.cs
Normal file
|
@ -0,0 +1,52 @@
|
|||
#nullable disable
|
||||
using System.Net;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public class LoginErrorHandler
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Handle(Exception ex)
|
||||
=> Log.Fatal(ex, "A fatal error has occurred while attempting to connect to Discord");
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Handle(HttpException ex)
|
||||
{
|
||||
switch (ex.HttpCode)
|
||||
{
|
||||
case HttpStatusCode.Unauthorized:
|
||||
Log.Error("Your bot token is wrong.\n"
|
||||
+ "You can find the bot token under the Bot tab in the developer page.\n"
|
||||
+ "Fix your token in the credentials file and restart the bot");
|
||||
break;
|
||||
|
||||
case HttpStatusCode.BadRequest:
|
||||
Log.Error("Something has been incorrectly formatted in your credentials file.\n"
|
||||
+ "Use the JSON Guide as reference to fix it and restart the bot");
|
||||
Log.Error("If you are on Linux, make sure Redis is installed and running");
|
||||
break;
|
||||
|
||||
case HttpStatusCode.RequestTimeout:
|
||||
Log.Error("The request timed out. Make sure you have no external program blocking the bot "
|
||||
+ "from connecting to the internet");
|
||||
break;
|
||||
|
||||
case HttpStatusCode.ServiceUnavailable:
|
||||
case HttpStatusCode.InternalServerError:
|
||||
Log.Error("Discord is having internal issues. Please, try again later");
|
||||
break;
|
||||
|
||||
case HttpStatusCode.TooManyRequests:
|
||||
Log.Error("Your bot has been ratelimited by Discord. Please, try again later.\n"
|
||||
+ "Global ratelimits usually last for an hour");
|
||||
break;
|
||||
|
||||
default:
|
||||
Log.Warning("An error occurred while attempting to connect to Discord");
|
||||
break;
|
||||
}
|
||||
|
||||
Log.Fatal(ex, "Fatal error occurred while loading credentials");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
using EllieBot.Marmalade;
|
||||
|
||||
[DIIgnore]
|
||||
public sealed class BehaviorAdapter : ICustomBehavior
|
||||
{
|
||||
private readonly WeakReference<Canary> _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<Canary> 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<bool> 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<bool> ExecOnMessageAsync(IGuild? guild, IUserMessage msg)
|
||||
{
|
||||
if (!_canaryWr.TryGetTarget(out var canary))
|
||||
return false;
|
||||
|
||||
return await canary.ExecOnMessageAsync(guild, msg);
|
||||
}
|
||||
|
||||
public async Task<string?> 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;
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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<IBotStrings> _botStrings;
|
||||
private readonly Lazy<ILocalization> _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<IBotStrings>);
|
||||
_localization = new(_services.GetRequiredService<ILocalization>());
|
||||
}
|
||||
|
||||
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<object>());
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
return output;
|
||||
|
||||
return _botStrings.Value.GetText(key, cultureInfo, args);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
using EllieBot.Marmalade;
|
||||
|
||||
namespace Ellie.Marmalade.Adapters;
|
||||
|
||||
public class FilterAdapter : PreconditionAttribute
|
||||
{
|
||||
private readonly FilterAttribute _filterAttribute;
|
||||
private readonly IMarmaladeStrings _strings;
|
||||
|
||||
public FilterAdapter(FilterAttribute filterAttribute,
|
||||
IMarmaladeStrings strings)
|
||||
{
|
||||
_filterAttribute = filterAttribute;
|
||||
_strings = strings;
|
||||
}
|
||||
|
||||
public override async Task<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
var medusaContext = ContextAdapterFactory.CreateNew(context,
|
||||
_strings,
|
||||
services);
|
||||
|
||||
var result = await _filterAttribute.CheckAsync(medusaContext);
|
||||
|
||||
if (!result)
|
||||
return PreconditionResult.FromError($"Precondition '{_filterAttribute.GetType().Name}' failed.");
|
||||
|
||||
return PreconditionResult.FromSuccess();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using EllieBot.Marmalade;
|
||||
|
||||
public sealed class GuildContextAdapter : GuildContext
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ICommandContext _ctx;
|
||||
private readonly Lazy<IBotStrings> _botStrings;
|
||||
private readonly Lazy<ILocalization> _localization;
|
||||
|
||||
public override IMarmaladeStrings Strings { get; }
|
||||
public override IGuild Guild { get; }
|
||||
public override ITextChannel Channel { get; }
|
||||
public override ISelfUser Bot { get; }
|
||||
public override IUserMessage Message
|
||||
=> _ctx.Message;
|
||||
|
||||
public override IGuildUser User { get; }
|
||||
|
||||
public GuildContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services)
|
||||
{
|
||||
if (ctx.Guild is not IGuild guild || ctx.Channel is not ITextChannel channel)
|
||||
{
|
||||
throw new ArgumentException("Can't use non-guild context to create GuildContextAdapter", nameof(ctx));
|
||||
}
|
||||
|
||||
Strings = strings;
|
||||
User = (IGuildUser)ctx.User;
|
||||
Bot = ctx.Client.CurrentUser;
|
||||
|
||||
_services = services;
|
||||
_botStrings = new(_services.GetRequiredService<IBotStrings>);
|
||||
_localization = new(_services.GetRequiredService<ILocalization>());
|
||||
|
||||
(_ctx, Guild, Channel) = (ctx, guild, channel);
|
||||
}
|
||||
|
||||
public override string GetText(string key, object[]? args = null)
|
||||
{
|
||||
args ??= Array.Empty<object>();
|
||||
|
||||
var cultureInfo = _localization.Value.GetCultureInfo(_ctx.Guild.Id);
|
||||
var output = Strings.GetText(key, cultureInfo, args);
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
return output;
|
||||
|
||||
return _botStrings.Value.GetText(key, cultureInfo, args);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
using EllieBot.Marmalade;
|
||||
|
||||
public sealed class ParamParserAdapter<T> : TypeReader
|
||||
{
|
||||
private readonly ParamParser<T> _parser;
|
||||
private readonly IMarmaladeStrings _strings;
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public ParamParserAdapter(ParamParser<T> parser,
|
||||
IMarmaladeStrings strings,
|
||||
IServiceProvider services)
|
||||
{
|
||||
_parser = parser;
|
||||
_strings = strings;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
|
||||
ICommandContext context,
|
||||
string input,
|
||||
IServiceProvider services)
|
||||
{
|
||||
var marmaladeContext = ContextAdapterFactory.CreateNew(context,
|
||||
_strings,
|
||||
_services);
|
||||
|
||||
var result = await _parser.TryParseAsync(marmaladeContext, input);
|
||||
|
||||
if(result.IsSuccess)
|
||||
return Discord.Commands.TypeReaderResult.FromSuccess(result.Data);
|
||||
|
||||
return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, "Invalid input");
|
||||
}
|
||||
}
|
27
src/EllieBot/_common/Marmalade/Common/CommandContextType.cs
Normal file
27
src/EllieBot/_common/Marmalade/Common/CommandContextType.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
namespace EllieBot.Marmalade;
|
||||
|
||||
/// <summary>
|
||||
/// Enum specifying in which context the command can be executed
|
||||
/// </summary>
|
||||
public enum CommandContextType
|
||||
{
|
||||
/// <summary>
|
||||
/// Command can only be executed in a guild
|
||||
/// </summary>
|
||||
Guild,
|
||||
|
||||
/// <summary>
|
||||
/// Command can only be executed in DMs
|
||||
/// </summary>
|
||||
Dm,
|
||||
|
||||
/// <summary>
|
||||
/// Command can be executed anywhere
|
||||
/// </summary>
|
||||
Any,
|
||||
|
||||
/// <summary>
|
||||
/// Command can be executed anywhere, and it doesn't require context to be passed to it
|
||||
/// </summary>
|
||||
Unspecified
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
namespace EllieBot.Marmalade;
|
||||
|
||||
public interface IMarmaladeConfigService
|
||||
{
|
||||
IReadOnlyCollection<string> GetLoadedMarmalades();
|
||||
void AddLoadedMarmalade(string name);
|
||||
void RemoveLoadedMarmalade(string name);
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
#nullable enable
|
||||
using Cloneable;
|
||||
using EllieBot.Common.Yml;
|
||||
|
||||
namespace EllieBot.Marmalade;
|
||||
|
||||
[Cloneable]
|
||||
public sealed partial class MarmaladeConfig : ICloneable<MarmaladeConfig>
|
||||
{
|
||||
[Comment("""DO NOT CHANGE""")]
|
||||
public int Version { get; set; } = 1;
|
||||
|
||||
[Comment("""List of marmalades automatically loaded at startup""")]
|
||||
public List<string>? Loaded { get; set; }
|
||||
|
||||
public MarmaladeConfig()
|
||||
{
|
||||
Loaded = new();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
using EllieBot.Common.Configs;
|
||||
|
||||
namespace EllieBot.Marmalade;
|
||||
|
||||
public sealed class MarmaladeConfigService : ConfigServiceBase<MarmaladeConfig>, IMarmaladeConfigService
|
||||
{
|
||||
private const string FILE_PATH = "data/marmalades/marmalade.yml";
|
||||
private static readonly TypedKey<MarmaladeConfig> _changeKey = new("config.marmalade.updated");
|
||||
|
||||
public override string Name
|
||||
=> "marmalade";
|
||||
|
||||
public MarmaladeConfigService(
|
||||
IConfigSeria serializer,
|
||||
IPubSub pubSub)
|
||||
: base(FILE_PATH, serializer, pubSub, _changeKey)
|
||||
{
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> GetLoadedMarmalades()
|
||||
=> Data.Loaded?.ToList() ?? new List<string>();
|
||||
|
||||
public void AddLoadedMarmalade(string name)
|
||||
{
|
||||
ModifyConfig(conf =>
|
||||
{
|
||||
if (conf.Loaded is null)
|
||||
conf.Loaded = new();
|
||||
|
||||
if(!conf.Loaded.Contains(name))
|
||||
conf.Loaded.Add(name);
|
||||
});
|
||||
}
|
||||
|
||||
public void RemoveLoadedMarmalade(string name)
|
||||
{
|
||||
ModifyConfig(conf =>
|
||||
{
|
||||
if (conf.Loaded is null)
|
||||
conf.Loaded = new();
|
||||
|
||||
conf.Loaded.Remove(name);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace EllieBot.Marmalade;
|
||||
|
||||
public class MarmaladeAssemblyLoadContext : AssemblyLoadContext
|
||||
{
|
||||
private readonly AssemblyDependencyResolver _resolver;
|
||||
|
||||
public MarmaladeAssemblyLoadContext(string folderPath) : base(isCollectible: true)
|
||||
=> _resolver = new(folderPath);
|
||||
|
||||
// public Assembly MainAssembly { get; private set; }
|
||||
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||
if (assemblyPath != null)
|
||||
{
|
||||
var assembly = LoadFromAssemblyPath(assemblyPath);
|
||||
LoadDependencies(assembly);
|
||||
return assembly;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void LoadDependencies(Assembly assembly)
|
||||
{
|
||||
foreach (var reference in assembly.GetReferencedAssemblies())
|
||||
{
|
||||
Load(reference);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
using DryIoc;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EllieBot.Marmalade;
|
||||
|
||||
public interface IIocModule
|
||||
{
|
||||
public string Name { get; }
|
||||
public void Load();
|
||||
public void Unload();
|
||||
}
|
||||
|
||||
public sealed class MarmaladeNinjectIocModule : IIocModule, IDisposable
|
||||
{
|
||||
public string Name { get; }
|
||||
private volatile bool isLoaded = false;
|
||||
private readonly Dictionary<Type, Type[]> _types;
|
||||
private readonly IContainer _cont;
|
||||
|
||||
public MarmaladeNinjectIocModule(IContainer cont, Assembly assembly, string name)
|
||||
{
|
||||
Name = name;
|
||||
_cont = cont;
|
||||
_types = assembly.GetExportedTypes()
|
||||
.Where(t => t.IsClass)
|
||||
.Where(t => t.GetCustomAttribute<svcAttribute>() is not null)
|
||||
.ToDictionary(x => x,
|
||||
type => type.GetInterfaces().ToArray());
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (isLoaded)
|
||||
return;
|
||||
|
||||
foreach (var (type, data) in _types)
|
||||
{
|
||||
var attribute = type.GetCustomAttribute<svcAttribute>()!;
|
||||
|
||||
var reuse = attribute.Lifetime == Lifetime.Singleton
|
||||
? Reuse.Singleton
|
||||
: Reuse.Transient;
|
||||
|
||||
_cont.RegisterMany([type], reuse);
|
||||
}
|
||||
|
||||
isLoaded = true;
|
||||
}
|
||||
|
||||
public void Unload()
|
||||
{
|
||||
if (!isLoaded)
|
||||
return;
|
||||
|
||||
foreach (var type in _types.Keys)
|
||||
{
|
||||
_cont.Unregister(type);
|
||||
}
|
||||
|
||||
_types.Clear();
|
||||
|
||||
// in case the library uses System.Text.Json
|
||||
var assembly = typeof(JsonSerializerOptions).Assembly;
|
||||
var updateHandlerType = assembly.GetType("System.Text.Json.JsonSerializerOptionsUpdateHandler");
|
||||
var clearCacheMethod = updateHandlerType?.GetMethod("ClearCache", BindingFlags.Static | BindingFlags.Public);
|
||||
clearCacheMethod?.Invoke(null, [null]);
|
||||
|
||||
isLoaded = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
=> _types.Clear();
|
||||
}
|
916
src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs
Normal file
916
src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs
Normal file
|
@ -0,0 +1,916 @@
|
|||
using Discord.Commands.Builders;
|
||||
using DryIoc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Ellie.Common.Marmalade;
|
||||
using Ellie.Marmalade.Adapters;
|
||||
using EllieBot.Common.ModuleBehaviors;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace EllieBot.Marmalade;
|
||||
|
||||
// ReSharper disable RedundantAssignment
|
||||
public sealed class MarmaladeLoaderService : IMarmaladeLoaderService, IReadyExecutor, IEService
|
||||
{
|
||||
private readonly CommandService _cmdService;
|
||||
private readonly IBehaviorHandler _behHandler;
|
||||
private readonly IPubSub _pubSub;
|
||||
private readonly IMarmaladeConfigService _marmaladeConfig;
|
||||
private readonly IContainer _cont;
|
||||
|
||||
private readonly ConcurrentDictionary<string, ResolvedMarmalade> _resolved = new();
|
||||
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
|
||||
|
||||
private readonly TypedKey<string> _loadKey = new("marmalade:load");
|
||||
private readonly TypedKey<string> _unloadKey = new("marmalade:unload");
|
||||
|
||||
private readonly TypedKey<bool> _stringsReload = new("marmalade:reload_strings");
|
||||
|
||||
private const string BASE_DIR = "data/marmalades";
|
||||
|
||||
public MarmaladeLoaderService(
|
||||
CommandService cmdService,
|
||||
IContainer cont,
|
||||
IBehaviorHandler behHandler,
|
||||
IPubSub pubSub,
|
||||
IMarmaladeConfigService marmaladeConfig)
|
||||
{
|
||||
_cmdService = cmdService;
|
||||
_behHandler = behHandler;
|
||||
_pubSub = pubSub;
|
||||
_marmaladeConfig = marmaladeConfig;
|
||||
_cont = cont;
|
||||
|
||||
// has to be done this way to support this feature on sharded bots
|
||||
_pubSub.Sub(_loadKey, async name => await InternalLoadAsync(name));
|
||||
_pubSub.Sub(_unloadKey, async name => await InternalUnloadAsync(name));
|
||||
|
||||
_pubSub.Sub(_stringsReload, async _ => await ReloadStringsInternal());
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> GetAllMarmalades()
|
||||
{
|
||||
if (!Directory.Exists(BASE_DIR))
|
||||
return Array.Empty<string>();
|
||||
|
||||
return Directory.GetDirectories(BASE_DIR)
|
||||
.Select(x => Path.GetRelativePath(BASE_DIR, x))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public IReadOnlyCollection<MarmaladeStats> GetLoadedMarmalades(CultureInfo? culture)
|
||||
{
|
||||
var toReturn = new List<MarmaladeStats>(_resolved.Count);
|
||||
foreach (var (name, resolvedData) in _resolved)
|
||||
{
|
||||
var canaries = new List<CanaryStats>(resolvedData.CanaryInfos.Count);
|
||||
|
||||
foreach (var canaryInfos in resolvedData.CanaryInfos.Concat(resolvedData.CanaryInfos.SelectMany(x => x.Subcanaries)))
|
||||
{
|
||||
var commands = new List<CanaryCommandStats>();
|
||||
|
||||
foreach (var command in canaryInfos.Commands)
|
||||
{
|
||||
commands.Add(new CanaryCommandStats(command.Aliases.First()));
|
||||
}
|
||||
|
||||
canaries.Add(new CanaryStats(canaryInfos.Name, canaryInfos.Instance.Prefix, commands));
|
||||
}
|
||||
|
||||
toReturn.Add(new MarmaladeStats(name, resolvedData.Strings.GetDescription(culture), canaries));
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
foreach (var name in _marmaladeConfig.GetLoadedMarmalades())
|
||||
{
|
||||
var result = await InternalLoadAsync(name);
|
||||
if (result != MarmaladeLoadResult.Success)
|
||||
Log.Warning("Unable to load '{MarmaladeName}' marmalade", name);
|
||||
else
|
||||
Log.Warning("Loaded marmalade '{MarmaladeName}'", name);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public async Task<MarmaladeLoadResult> LoadMarmaladeAsync(string marmaladeName)
|
||||
{
|
||||
// try loading on this shard first to see if it works
|
||||
var res = await InternalLoadAsync(marmaladeName);
|
||||
if (res == MarmaladeLoadResult.Success)
|
||||
{
|
||||
// if it does publish it so that other shards can load the marmalade too
|
||||
// this method will be ran twice on this shard but it doesn't matter as
|
||||
// the second attempt will be ignored
|
||||
await _pubSub.Pub(_loadKey, marmaladeName);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public async Task<MarmaladeUnloadResult> UnloadMarmaladeAsync(string marmaladeName)
|
||||
{
|
||||
var res = await InternalUnloadAsync(marmaladeName);
|
||||
if (res == MarmaladeUnloadResult.Success)
|
||||
{
|
||||
await _pubSub.Pub(_unloadKey, marmaladeName);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture)
|
||||
{
|
||||
if (!_resolved.TryGetValue(marmaladeName, out var data))
|
||||
return Array.Empty<string>();
|
||||
|
||||
return data.Strings.GetCommandStrings(commandName, culture).Args
|
||||
?? data.CanaryInfos
|
||||
.SelectMany(x => x.Commands)
|
||||
.FirstOrDefault(x => x.Aliases.Any(alias
|
||||
=> alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase)))
|
||||
?.OptionalStrings
|
||||
.Args
|
||||
?? [string.Empty];
|
||||
}
|
||||
|
||||
public Task ReloadStrings()
|
||||
=> _pubSub.Pub(_stringsReload, true);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private void ReloadStringsSync()
|
||||
{
|
||||
foreach (var resolved in _resolved.Values)
|
||||
{
|
||||
resolved.Strings.Reload();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadStringsInternal()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
ReloadStringsSync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture)
|
||||
{
|
||||
if (!_resolved.TryGetValue(marmaladeName, out var data))
|
||||
return string.Empty;
|
||||
|
||||
return data.Strings.GetCommandStrings(commandName, culture).Desc
|
||||
?? data.CanaryInfos
|
||||
.SelectMany(x => x.Commands)
|
||||
.FirstOrDefault(x => x.Aliases.Any(alias
|
||||
=> alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase)))
|
||||
?.OptionalStrings
|
||||
.Desc
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private async ValueTask<MarmaladeLoadResult> InternalLoadAsync(string name)
|
||||
{
|
||||
if (_resolved.ContainsKey(name))
|
||||
return MarmaladeLoadResult.AlreadyLoaded;
|
||||
|
||||
var safeName = Uri.EscapeDataString(name);
|
||||
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (LoadAssemblyInternal(safeName,
|
||||
out var ctx,
|
||||
out var canaryData,
|
||||
out var iocModule,
|
||||
out var strings,
|
||||
out var typeReaders))
|
||||
{
|
||||
var moduleInfos = new List<ModuleInfo>();
|
||||
|
||||
LoadTypeReadersInternal(typeReaders);
|
||||
|
||||
foreach (var point in canaryData)
|
||||
{
|
||||
try
|
||||
{
|
||||
// initialize canary and subcanaries
|
||||
await point.Instance.InitializeAsync();
|
||||
foreach (var sub in point.Subcanaries)
|
||||
{
|
||||
await sub.Instance.InitializeAsync();
|
||||
}
|
||||
|
||||
var module = await LoadModuleInternalAsync(name, point, strings, iocModule);
|
||||
moduleInfos.Add(module);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Error loading canary {CanaryName}",
|
||||
point.Name);
|
||||
}
|
||||
}
|
||||
|
||||
var execs = GetExecsInternal(canaryData, strings);
|
||||
await _behHandler.AddRangeAsync(execs);
|
||||
|
||||
_resolved[name] = new(LoadContext: ctx,
|
||||
ModuleInfos: moduleInfos.ToImmutableArray(),
|
||||
CanaryInfos: canaryData.ToImmutableArray(),
|
||||
strings,
|
||||
typeReaders,
|
||||
execs)
|
||||
{
|
||||
IocModule = iocModule
|
||||
};
|
||||
|
||||
|
||||
_marmaladeConfig.AddLoadedMarmalade(safeName);
|
||||
return MarmaladeLoadResult.Success;
|
||||
}
|
||||
|
||||
return MarmaladeLoadResult.Empty;
|
||||
}
|
||||
catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException)
|
||||
{
|
||||
return MarmaladeLoadResult.NotFound;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "An error occurred loading a marmalade");
|
||||
return MarmaladeLoadResult.UnknownError;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private IReadOnlyCollection<ICustomBehavior> GetExecsInternal(
|
||||
IReadOnlyCollection<CanaryInfo> canaryData,
|
||||
IMarmaladeStrings strings)
|
||||
{
|
||||
var behs = new List<ICustomBehavior>();
|
||||
foreach (var canary in canaryData)
|
||||
{
|
||||
behs.Add(new BehaviorAdapter(new(canary.Instance), strings, _cont));
|
||||
|
||||
foreach (var sub in canary.Subcanaries)
|
||||
{
|
||||
behs.Add(new BehaviorAdapter(new(sub.Instance), strings, _cont));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return behs;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private void LoadTypeReadersInternal(Dictionary<Type, TypeReader> typeReaders)
|
||||
{
|
||||
var notAddedTypeReaders = new List<Type>();
|
||||
foreach (var (type, typeReader) in typeReaders)
|
||||
{
|
||||
// if type reader for this type already exists, it will not be replaced
|
||||
if (_cmdService.TypeReaders.Contains(type))
|
||||
{
|
||||
notAddedTypeReaders.Add(type);
|
||||
continue;
|
||||
}
|
||||
|
||||
_cmdService.AddTypeReader(type, typeReader);
|
||||
}
|
||||
|
||||
// remove the ones that were not added
|
||||
// to prevent them from being unloaded later
|
||||
// as they didn't come from this marmalade
|
||||
foreach (var toRemove in notAddedTypeReaders)
|
||||
{
|
||||
typeReaders.Remove(toRemove);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private bool LoadAssemblyInternal(
|
||||
string safeName,
|
||||
[NotNullWhen(true)] out WeakReference<MarmaladeAssemblyLoadContext>? ctxWr,
|
||||
[NotNullWhen(true)] out IReadOnlyCollection<CanaryInfo>? canaryData,
|
||||
[NotNullWhen(true)] out IIocModule? iocModule,
|
||||
out IMarmaladeStrings strings,
|
||||
out Dictionary<Type, TypeReader> typeReaders)
|
||||
{
|
||||
ctxWr = null;
|
||||
canaryData = null;
|
||||
|
||||
var path = Path.GetFullPath($"{BASE_DIR}/{safeName}/{safeName}.dll");
|
||||
var dir = Path.GetFullPath($"{BASE_DIR}/{safeName}");
|
||||
|
||||
if (!Directory.Exists(dir))
|
||||
throw new DirectoryNotFoundException($"Marmalade folder not found: {dir}");
|
||||
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"Marmalade dll not found: {path}");
|
||||
|
||||
strings = MarmaladeStrings.CreateDefault(dir);
|
||||
var ctx = new MarmaladeAssemblyLoadContext(Path.GetDirectoryName(path)!);
|
||||
var a = ctx.LoadFromAssemblyPath(Path.GetFullPath(path));
|
||||
ctx.LoadDependencies(a);
|
||||
|
||||
// load services
|
||||
iocModule = new MarmaladeNinjectIocModule(_cont, a, safeName);
|
||||
iocModule.Load();
|
||||
|
||||
var sis = LoadCanariesFromAssembly(safeName, a);
|
||||
typeReaders = LoadTypeReadersFromAssembly(a, strings);
|
||||
|
||||
if (sis.Count == 0)
|
||||
{
|
||||
iocModule.Unload();
|
||||
return false;
|
||||
}
|
||||
|
||||
ctxWr = new(ctx);
|
||||
canaryData = sis;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly Type _paramParserType = typeof(ParamParser<>);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private Dictionary<Type, TypeReader> LoadTypeReadersFromAssembly(
|
||||
Assembly assembly,
|
||||
IMarmaladeStrings strings)
|
||||
{
|
||||
var paramParsers = assembly.GetExportedTypes()
|
||||
.Where(x => x.IsClass
|
||||
&& !x.IsAbstract
|
||||
&& x.BaseType is not null
|
||||
&& x.BaseType.IsGenericType
|
||||
&& x.BaseType.GetGenericTypeDefinition() == _paramParserType);
|
||||
|
||||
var typeReaders = new Dictionary<Type, TypeReader>();
|
||||
foreach (var parserType in paramParsers)
|
||||
{
|
||||
var parserObj = ActivatorUtilities.CreateInstance(_cont, parserType);
|
||||
|
||||
var targetType = parserType.BaseType!.GetGenericArguments()[0];
|
||||
var typeReaderInstance = (TypeReader)Activator.CreateInstance(
|
||||
typeof(ParamParserAdapter<>).MakeGenericType(targetType),
|
||||
args: [parserObj, strings, _cont])!;
|
||||
|
||||
typeReaders.Add(targetType, typeReaderInstance);
|
||||
}
|
||||
|
||||
return typeReaders;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private async Task<ModuleInfo> LoadModuleInternalAsync(
|
||||
string marmaladeName,
|
||||
CanaryInfo canaryInfo,
|
||||
IMarmaladeStrings strings,
|
||||
IIocModule services)
|
||||
{
|
||||
var module = await _cmdService.CreateModuleAsync(canaryInfo.Instance.Prefix,
|
||||
CreateModuleFactory(marmaladeName, canaryInfo, strings, services));
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private Action<ModuleBuilder> CreateModuleFactory(
|
||||
string marmaladeName,
|
||||
CanaryInfo canaryInfo,
|
||||
IMarmaladeStrings strings,
|
||||
IIocModule iocModule)
|
||||
=> mb =>
|
||||
{
|
||||
var m = mb.WithName(canaryInfo.Name);
|
||||
|
||||
foreach (var f in canaryInfo.Filters)
|
||||
{
|
||||
m.AddPrecondition(new FilterAdapter(f, strings));
|
||||
}
|
||||
|
||||
foreach (var cmd in canaryInfo.Commands)
|
||||
{
|
||||
m.AddCommand(cmd.Aliases.First(),
|
||||
CreateCallback(cmd.ContextType,
|
||||
new(canaryInfo),
|
||||
new(cmd),
|
||||
strings),
|
||||
CreateCommandFactory(marmaladeName, cmd, strings));
|
||||
}
|
||||
|
||||
foreach (var subInfo in canaryInfo.Subcanaries)
|
||||
m.AddModule(subInfo.Instance.Prefix, CreateModuleFactory(marmaladeName, subInfo, strings, iocModule));
|
||||
};
|
||||
|
||||
private static readonly RequireContextAttribute _reqGuild = new RequireContextAttribute(ContextType.Guild);
|
||||
private static readonly RequireContextAttribute _reqDm = new RequireContextAttribute(ContextType.DM);
|
||||
|
||||
private Action<CommandBuilder> CreateCommandFactory(string marmaladeName, CanaryCommandData cmd, IMarmaladeStrings strings)
|
||||
=> (cb) =>
|
||||
{
|
||||
cb.AddAliases(cmd.Aliases.Skip(1).ToArray());
|
||||
|
||||
if (cmd.ContextType == CommandContextType.Guild)
|
||||
cb.AddPrecondition(_reqGuild);
|
||||
else if (cmd.ContextType == CommandContextType.Dm)
|
||||
cb.AddPrecondition(_reqDm);
|
||||
|
||||
foreach (var f in cmd.Filters)
|
||||
cb.AddPrecondition(new FilterAdapter(f, strings));
|
||||
|
||||
foreach (var ubp in cmd.UserAndBotPerms)
|
||||
{
|
||||
if (ubp is user_permAttribute up)
|
||||
{
|
||||
if (up.GuildPerm is { } gp)
|
||||
cb.AddPrecondition(new UserPermAttribute(gp));
|
||||
else if (up.ChannelPerm is { } cp)
|
||||
cb.AddPrecondition(new UserPermAttribute(cp));
|
||||
}
|
||||
else if (ubp is bot_permAttribute bp)
|
||||
{
|
||||
if (bp.GuildPerm is { } gp)
|
||||
cb.AddPrecondition(new BotPermAttribute(gp));
|
||||
else if (bp.ChannelPerm is { } cp)
|
||||
cb.AddPrecondition(new BotPermAttribute(cp));
|
||||
}
|
||||
else if (ubp is bot_owner_onlyAttribute)
|
||||
{
|
||||
cb.AddPrecondition(new OwnerOnlyAttribute());
|
||||
}
|
||||
}
|
||||
|
||||
cb.WithPriority(cmd.Priority);
|
||||
|
||||
// using summary to save method name
|
||||
// method name is used to retrieve desc/usages
|
||||
cb.WithRemarks($"marmalade///{marmaladeName}");
|
||||
cb.WithSummary(cmd.MethodInfo.Name.ToLowerInvariant());
|
||||
|
||||
foreach (var param in cmd.Parameters)
|
||||
{
|
||||
cb.AddParameter(param.Name, param.Type, CreateParamFactory(param));
|
||||
}
|
||||
};
|
||||
|
||||
private Action<ParameterBuilder> CreateParamFactory(ParamData paramData)
|
||||
=> (pb) =>
|
||||
{
|
||||
pb.WithIsMultiple(paramData.IsParams)
|
||||
.WithIsOptional(paramData.IsOptional)
|
||||
.WithIsRemainder(paramData.IsLeftover);
|
||||
|
||||
if (paramData.IsOptional)
|
||||
pb.WithDefault(paramData.DefaultValue);
|
||||
};
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private Func<ICommandContext, object[], IServiceProvider, CommandInfo, Task> CreateCallback(
|
||||
CommandContextType contextType,
|
||||
WeakReference<CanaryInfo> canaryDataWr,
|
||||
WeakReference<CanaryCommandData> canaryCommandDataWr,
|
||||
IMarmaladeStrings strings)
|
||||
=> async (
|
||||
context,
|
||||
parameters,
|
||||
svcs,
|
||||
_) =>
|
||||
{
|
||||
if (!canaryCommandDataWr.TryGetTarget(out var cmdData)
|
||||
|| !canaryDataWr.TryGetTarget(out var canaryData))
|
||||
{
|
||||
Log.Warning("Attempted to run an unloaded canary's command");
|
||||
return;
|
||||
}
|
||||
|
||||
var paramObjs = ParamObjs(contextType, cmdData, parameters, context, svcs, _cont, strings);
|
||||
|
||||
try
|
||||
{
|
||||
var methodInfo = cmdData.MethodInfo;
|
||||
if (methodInfo.ReturnType == typeof(Task)
|
||||
|| (methodInfo.ReturnType.IsGenericType
|
||||
&& methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)))
|
||||
{
|
||||
await (Task)methodInfo.Invoke(canaryData.Instance, paramObjs)!;
|
||||
}
|
||||
else if (methodInfo.ReturnType == typeof(ValueTask))
|
||||
{
|
||||
await ((ValueTask)methodInfo.Invoke(canaryData.Instance, paramObjs)!).AsTask();
|
||||
}
|
||||
else // if (methodInfo.ReturnType == typeof(void))
|
||||
{
|
||||
methodInfo.Invoke(canaryData.Instance, paramObjs);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
paramObjs = null;
|
||||
cmdData = null;
|
||||
|
||||
canaryData = null;
|
||||
}
|
||||
};
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static object[] ParamObjs(
|
||||
CommandContextType contextType,
|
||||
CanaryCommandData cmdData,
|
||||
object[] parameters,
|
||||
ICommandContext context,
|
||||
IServiceProvider svcs,
|
||||
IServiceProvider svcProvider,
|
||||
IMarmaladeStrings strings)
|
||||
{
|
||||
var extraParams = contextType == CommandContextType.Unspecified ? 0 : 1;
|
||||
extraParams += cmdData.InjectedParams.Count;
|
||||
|
||||
var paramObjs = new object[parameters.Length + extraParams];
|
||||
|
||||
var startAt = 0;
|
||||
if (contextType != CommandContextType.Unspecified)
|
||||
{
|
||||
paramObjs[0] = ContextAdapterFactory.CreateNew(context, strings, svcs);
|
||||
|
||||
startAt = 1;
|
||||
}
|
||||
|
||||
for (var i = 0; i < cmdData.InjectedParams.Count; i++)
|
||||
{
|
||||
var svc = svcProvider.GetService(cmdData.InjectedParams[i]);
|
||||
if (svc is null)
|
||||
{
|
||||
throw new ArgumentException($"Cannot inject a service of type {cmdData.InjectedParams[i]}");
|
||||
}
|
||||
|
||||
paramObjs[i + startAt] = svc;
|
||||
|
||||
svc = null;
|
||||
}
|
||||
|
||||
startAt += cmdData.InjectedParams.Count;
|
||||
|
||||
for (var i = 0; i < parameters.Length; i++)
|
||||
paramObjs[startAt + i] = parameters[i];
|
||||
|
||||
return paramObjs;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private async Task<MarmaladeUnloadResult> InternalUnloadAsync(string name)
|
||||
{
|
||||
if (!_resolved.Remove(name, out var lsi))
|
||||
return MarmaladeUnloadResult.NotLoaded;
|
||||
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
UnloadTypeReaders(lsi.TypeReaders);
|
||||
|
||||
foreach (var mi in lsi.ModuleInfos)
|
||||
{
|
||||
await _cmdService.RemoveModuleAsync(mi);
|
||||
}
|
||||
|
||||
await _behHandler.RemoveRangeAsync(lsi.Execs);
|
||||
|
||||
await DisposeCanaryInstances(lsi);
|
||||
|
||||
var lc = lsi.LoadContext;
|
||||
var km = lsi.IocModule;
|
||||
|
||||
lsi.IocModule.Unload();
|
||||
lsi.IocModule = null!;
|
||||
|
||||
if (km is IDisposable d)
|
||||
d.Dispose();
|
||||
|
||||
lsi = null;
|
||||
|
||||
_marmaladeConfig.RemoveLoadedMarmalade(name);
|
||||
return UnloadInternal(lc)
|
||||
? MarmaladeUnloadResult.Success
|
||||
: MarmaladeUnloadResult.PossiblyUnable;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void UnloadTypeReaders(Dictionary<Type, TypeReader> valueTypeReaders)
|
||||
{
|
||||
foreach (var tr in valueTypeReaders)
|
||||
{
|
||||
_cmdService.TryRemoveTypeReader(tr.Key, false, out _);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private async Task DisposeCanaryInstances(ResolvedMarmalade marmalade)
|
||||
{
|
||||
foreach (var si in marmalade.CanaryInfos)
|
||||
{
|
||||
try
|
||||
{
|
||||
await si.Instance.DisposeAsync();
|
||||
foreach (var sub in si.Subcanaries)
|
||||
{
|
||||
await sub.Instance.DisposeAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Failed cleanup of Canary {CanaryName}. This marmalade might not unload correctly",
|
||||
si.Instance.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// marmalades = null;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private bool UnloadInternal(WeakReference<MarmaladeAssemblyLoadContext> lsi)
|
||||
{
|
||||
UnloadContext(lsi);
|
||||
GcCleanup();
|
||||
|
||||
return !lsi.TryGetTarget(out _);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private void UnloadContext(WeakReference<MarmaladeAssemblyLoadContext> lsiLoadContext)
|
||||
{
|
||||
if (lsiLoadContext.TryGetTarget(out var ctx))
|
||||
{
|
||||
ctx.Unload();
|
||||
}
|
||||
}
|
||||
|
||||
private void GcCleanup()
|
||||
{
|
||||
// cleanup
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.WaitForFullGCComplete();
|
||||
GC.Collect();
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly Type _canaryType = typeof(Canary);
|
||||
|
||||
// [MethodImpl(MethodImplOptions.NoInlining)]
|
||||
// private MarmaladeIoCKernelModule LoadMarmaladeServicesInternal(string name, Assembly a)
|
||||
// => new MarmaladeIoCKernelModule(name, a);
|
||||
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public IReadOnlyCollection<CanaryInfo> LoadCanariesFromAssembly(string name, Assembly a)
|
||||
{
|
||||
// find all types in teh assembly
|
||||
var types = a.GetExportedTypes();
|
||||
// canary is always a public non abstract class
|
||||
var classes = types.Where(static x => x.IsClass
|
||||
&& (x.IsNestedPublic || x.IsPublic)
|
||||
&& !x.IsAbstract
|
||||
&& x.BaseType == _canaryType
|
||||
&& (x.DeclaringType is null || x.DeclaringType.IsAssignableTo(_canaryType)))
|
||||
.ToList();
|
||||
|
||||
var topModules = new Dictionary<Type, CanaryInfo>();
|
||||
|
||||
foreach (var cl in classes)
|
||||
{
|
||||
if (cl.DeclaringType is not null)
|
||||
continue;
|
||||
|
||||
// get module data, and add it to the topModules dictionary
|
||||
var module = GetModuleData(cl);
|
||||
topModules.Add(cl, module);
|
||||
}
|
||||
|
||||
foreach (var c in classes)
|
||||
{
|
||||
if (c.DeclaringType is not Type dt)
|
||||
continue;
|
||||
|
||||
// if there is no top level module which this module is a child of
|
||||
// just print a warning and skip it
|
||||
if (!topModules.TryGetValue(dt, out var parentData))
|
||||
{
|
||||
Log.Warning("Can't load submodule {SubName} because parent module {Name} does not exist",
|
||||
c.Name,
|
||||
dt.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
GetModuleData(c, parentData);
|
||||
}
|
||||
|
||||
return topModules.Values.ToArray();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private CanaryInfo GetModuleData(Type type, CanaryInfo? parentData = null)
|
||||
{
|
||||
var filters = type.GetCustomAttributes<FilterAttribute>(true)
|
||||
.ToArray();
|
||||
|
||||
var instance = (Canary)ActivatorUtilities.CreateInstance(_cont, type);
|
||||
|
||||
var module = new CanaryInfo(instance.Name,
|
||||
parentData,
|
||||
instance,
|
||||
GetCommands(instance, type),
|
||||
filters);
|
||||
|
||||
if (parentData is not null)
|
||||
parentData.Subcanaries.Add(module);
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private IReadOnlyCollection<CanaryCommandData> GetCommands(Canary instance, Type type)
|
||||
{
|
||||
var methodInfos = type
|
||||
.GetMethods(BindingFlags.Instance
|
||||
| BindingFlags.DeclaredOnly
|
||||
| BindingFlags.Public)
|
||||
.Where(static x =>
|
||||
{
|
||||
if (x.GetCustomAttribute<cmdAttribute>(true) is null)
|
||||
return false;
|
||||
|
||||
if (x.ReturnType.IsGenericType)
|
||||
{
|
||||
var genericType = x.ReturnType.GetGenericTypeDefinition();
|
||||
if (genericType == typeof(Task<>))
|
||||
return true;
|
||||
|
||||
// if (genericType == typeof(ValueTask<>))
|
||||
// return true;
|
||||
|
||||
Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}",
|
||||
x.Name,
|
||||
x.ReturnType);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var succ = x.ReturnType == typeof(Task)
|
||||
|| x.ReturnType == typeof(ValueTask)
|
||||
|| x.ReturnType == typeof(void);
|
||||
|
||||
if (!succ)
|
||||
{
|
||||
Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}",
|
||||
x.Name,
|
||||
x.ReturnType);
|
||||
}
|
||||
|
||||
return succ;
|
||||
});
|
||||
|
||||
|
||||
var cmds = new List<CanaryCommandData>();
|
||||
foreach (var method in methodInfos)
|
||||
{
|
||||
var filters = method.GetCustomAttributes<FilterAttribute>(true).ToArray();
|
||||
var userAndBotPerms = method.GetCustomAttributes<MarmaladePermAttribute>(true)
|
||||
.ToArray();
|
||||
var prio = method.GetCustomAttribute<prioAttribute>(true)?.Priority ?? 0;
|
||||
|
||||
var paramInfos = method.GetParameters();
|
||||
var cmdParams = new List<ParamData>();
|
||||
var diParams = new List<Type>();
|
||||
var cmdContext = CommandContextType.Unspecified;
|
||||
var canInject = false;
|
||||
for (var paramCounter = 0; paramCounter < paramInfos.Length; paramCounter++)
|
||||
{
|
||||
var pi = paramInfos[paramCounter];
|
||||
|
||||
var paramName = pi.Name ?? "unnamed";
|
||||
var isContext = paramCounter == 0 && pi.ParameterType.IsAssignableTo(typeof(AnyContext));
|
||||
|
||||
var leftoverAttribute = pi.GetCustomAttribute<leftoverAttribute>(true);
|
||||
var hasDefaultValue = pi.HasDefaultValue;
|
||||
var defaultValue = pi.DefaultValue;
|
||||
var isLeftover = leftoverAttribute != null;
|
||||
var isParams = pi.GetCustomAttribute<ParamArrayAttribute>() is not null;
|
||||
var paramType = pi.ParameterType;
|
||||
var isInjected = pi.GetCustomAttribute<injectAttribute>(true) is not null;
|
||||
|
||||
if (isContext)
|
||||
{
|
||||
if (hasDefaultValue || leftoverAttribute != null || isParams)
|
||||
throw new ArgumentException(
|
||||
"IContext parameter cannot be optional, leftover, constant or params. "
|
||||
+ GetErrorPath(method, pi));
|
||||
|
||||
if (paramCounter != 0)
|
||||
throw new ArgumentException($"IContext parameter has to be first. {GetErrorPath(method, pi)}");
|
||||
|
||||
canInject = true;
|
||||
|
||||
if (paramType.IsAssignableTo(typeof(GuildContext)))
|
||||
cmdContext = CommandContextType.Guild;
|
||||
else if (paramType.IsAssignableTo(typeof(DmContext)))
|
||||
cmdContext = CommandContextType.Dm;
|
||||
else
|
||||
cmdContext = CommandContextType.Any;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isInjected)
|
||||
{
|
||||
if (!canInject && paramCounter != 0)
|
||||
throw new ArgumentException($"Parameters marked as [Injected] have to come after IContext");
|
||||
|
||||
canInject = true;
|
||||
|
||||
diParams.Add(paramType);
|
||||
continue;
|
||||
}
|
||||
|
||||
canInject = false;
|
||||
|
||||
if (isParams)
|
||||
{
|
||||
if (hasDefaultValue)
|
||||
throw new NotSupportedException("Params can't have const values at the moment. "
|
||||
+ GetErrorPath(method, pi));
|
||||
// if it's params, it means it's an array, and i only need a parser for the actual type,
|
||||
// as the parser will run on each array element, it can't be null
|
||||
paramType = paramType.GetElementType()!;
|
||||
}
|
||||
|
||||
// leftover can only be the last parameter.
|
||||
if (isLeftover && paramCounter != paramInfos.Length - 1)
|
||||
{
|
||||
var path = GetErrorPath(method, pi);
|
||||
Log.Error("Only one parameter can be marked [Leftover] and it has to be the last one. {Path} ",
|
||||
path);
|
||||
throw new ArgumentException("Leftover attribute error.");
|
||||
}
|
||||
|
||||
cmdParams.Add(new ParamData(paramType, paramName, hasDefaultValue, defaultValue, isLeftover, isParams));
|
||||
}
|
||||
|
||||
|
||||
var cmdAttribute = method.GetCustomAttribute<cmdAttribute>(true)!;
|
||||
var aliases = cmdAttribute.Aliases;
|
||||
if (aliases.Length == 0)
|
||||
aliases = [method.Name.ToLowerInvariant()];
|
||||
|
||||
cmds.Add(new(
|
||||
aliases,
|
||||
method,
|
||||
instance,
|
||||
filters,
|
||||
userAndBotPerms,
|
||||
cmdContext,
|
||||
diParams,
|
||||
cmdParams,
|
||||
new(cmdAttribute.desc, cmdAttribute.args),
|
||||
prio
|
||||
));
|
||||
}
|
||||
|
||||
return cmds;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private string GetErrorPath(MethodInfo m, System.Reflection.ParameterInfo pi)
|
||||
=> $@"Module: {m.DeclaringType?.Name}
|
||||
Command: {m.Name}
|
||||
ParamName: {pi.Name}
|
||||
ParamType: {pi.ParameterType.Name}";
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
using EllieBot.Marmalade;
|
||||
using System.Reflection;
|
||||
|
||||
namespace EllieBot.Marmalade;
|
||||
|
||||
public sealed class CanaryCommandData
|
||||
{
|
||||
public CanaryCommandData(
|
||||
IReadOnlyCollection<string> aliases,
|
||||
MethodInfo methodInfo,
|
||||
Canary module,
|
||||
FilterAttribute[] filters,
|
||||
MarmaladePermAttribute[] userAndBotPerms,
|
||||
CommandContextType contextType,
|
||||
IReadOnlyList<Type> injectedParams,
|
||||
IReadOnlyList<ParamData> parameters,
|
||||
CommandStrings strings,
|
||||
int priority)
|
||||
{
|
||||
Aliases = aliases;
|
||||
MethodInfo = methodInfo;
|
||||
Module = module;
|
||||
Filters = filters;
|
||||
UserAndBotPerms = userAndBotPerms;
|
||||
ContextType = contextType;
|
||||
InjectedParams = injectedParams;
|
||||
Parameters = parameters;
|
||||
Priority = priority;
|
||||
OptionalStrings = strings;
|
||||
}
|
||||
|
||||
public MarmaladePermAttribute[] UserAndBotPerms { get; set; }
|
||||
|
||||
public CommandStrings OptionalStrings { get; set; }
|
||||
|
||||
public IReadOnlyCollection<string> Aliases { get; }
|
||||
public MethodInfo MethodInfo { get; set; }
|
||||
public Canary Module { get; set; }
|
||||
public FilterAttribute[] Filters { get; set; }
|
||||
public CommandContextType ContextType { get; }
|
||||
public IReadOnlyList<Type> InjectedParams { get; }
|
||||
public IReadOnlyList<ParamData> Parameters { get; }
|
||||
public int Priority { get; }
|
||||
}
|
11
src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs
Normal file
11
src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
namespace EllieBot.Marmalade;
|
||||
|
||||
public sealed record CanaryInfo(
|
||||
string Name,
|
||||
CanaryInfo? Parent,
|
||||
Canary Instance,
|
||||
IReadOnlyCollection<CanaryCommandData> Commands,
|
||||
IReadOnlyCollection<FilterAttribute> Filters)
|
||||
{
|
||||
public List<CanaryInfo> Subcanaries { get; set; } = new();
|
||||
}
|
10
src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs
Normal file
10
src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace EllieBot.Marmalade;
|
||||
|
||||
public sealed record ParamData(
|
||||
Type Type,
|
||||
string Name,
|
||||
bool IsOptional,
|
||||
object? DefaultValue,
|
||||
bool IsLeftover,
|
||||
bool IsParams
|
||||
);
|
|
@ -0,0 +1,15 @@
|
|||
using System.Collections.Immutable;
|
||||
|
||||
namespace EllieBot.Marmalade;
|
||||
|
||||
public sealed record ResolvedMarmalade(
|
||||
WeakReference<MarmaladeAssemblyLoadContext> LoadContext,
|
||||
IImmutableList<ModuleInfo> ModuleInfos,
|
||||
IImmutableList<CanaryInfo> CanaryInfos,
|
||||
IMarmaladeStrings Strings,
|
||||
Dictionary<Type, TypeReader> TypeReaders,
|
||||
IReadOnlyCollection<ICustomBehavior> Execs
|
||||
)
|
||||
{
|
||||
public required IIocModule IocModule { get; set; }
|
||||
}
|
24
src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs
Normal file
24
src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs
Normal file
|
@ -0,0 +1,24 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace Ellie.Common.Marmalade;
|
||||
|
||||
public interface IMarmaladeLoaderService
|
||||
{
|
||||
Task<MarmaladeLoadResult> LoadMarmaladeAsync(string marmaladeName);
|
||||
Task<MarmaladeUnloadResult> UnloadMarmaladeAsync(string marmaladeName);
|
||||
string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture);
|
||||
string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture);
|
||||
Task ReloadStrings();
|
||||
IReadOnlyCollection<string> GetAllMarmalades();
|
||||
IReadOnlyCollection<MarmaladeStats> GetLoadedMarmalades(CultureInfo? cultureInfo = null);
|
||||
}
|
||||
|
||||
public sealed record MarmaladeStats(string Name,
|
||||
string? Description,
|
||||
IReadOnlyCollection<CanaryStats> Canaries);
|
||||
|
||||
public sealed record CanaryStats(string Name,
|
||||
string? Prefix,
|
||||
IReadOnlyCollection<CanaryCommandStats> Commands);
|
||||
|
||||
public sealed record CanaryCommandStats(string Name);
|
10
src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs
Normal file
10
src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
namespace Ellie.Common.Marmalade;
|
||||
|
||||
public enum MarmaladeLoadResult
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
AlreadyLoaded,
|
||||
Empty,
|
||||
UnknownError,
|
||||
}
|
9
src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.cs
Normal file
9
src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
namespace Ellie.Common.Marmalade;
|
||||
|
||||
public enum MarmaladeUnloadResult
|
||||
{
|
||||
Success,
|
||||
NotLoaded,
|
||||
PossiblyUnable,
|
||||
NotFound,
|
||||
}
|
8
src/EllieBot/_common/MessageType.cs
Normal file
8
src/EllieBot/_common/MessageType.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace EllieBot.Common;
|
||||
|
||||
public enum MsgType
|
||||
{
|
||||
Ok,
|
||||
Pending,
|
||||
Error
|
||||
}
|
6
src/EllieBot/_common/ModuleBehaviors/IBehavior.cs
Normal file
6
src/EllieBot/_common/ModuleBehaviors/IBehavior.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
public interface IBehavior
|
||||
{
|
||||
public virtual string Name => this.GetType().Name;
|
||||
}
|
19
src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs
Normal file
19
src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Executed if no command was found for this message
|
||||
/// </summary>
|
||||
public interface IExecNoCommand : IBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Executed at the end of the lifecycle if no command was found
|
||||
/// <see cref="IExecOnMessage"/> →
|
||||
/// <see cref="IInputTransformer"/> →
|
||||
/// <see cref="IExecPreCommand"/> →
|
||||
/// [<see cref="IExecPostCommand"/> | *<see cref="IExecNoCommand"/>*]
|
||||
/// </summary>
|
||||
/// <param name="guild"></param>
|
||||
/// <param name="msg"></param>
|
||||
/// <returns>A task representing completion</returns>
|
||||
Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg);
|
||||
}
|
21
src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs
Normal file
21
src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Implemented by modules to handle non-bot messages received
|
||||
/// </summary>
|
||||
public interface IExecOnMessage : IBehavior
|
||||
{
|
||||
int Priority { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ran after a non-bot message was received
|
||||
/// *<see cref="IExecOnMessage"/>* →
|
||||
/// <see cref="IInputTransformer"/> →
|
||||
/// <see cref="IExecPreCommand"/> →
|
||||
/// [<see cref="IExecPostCommand"/> | <see cref="IExecNoCommand"/>]
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild where the message was sent</param>
|
||||
/// <param name="msg">The message that was received</param>
|
||||
/// <returns>Whether further processing of this message should be blocked</returns>
|
||||
Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage msg);
|
||||
}
|
22
src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs
Normal file
22
src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
namespace EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
/// <summary>
|
||||
/// This interface's method is executed after the command successfully finished execution.
|
||||
/// ***There is no support for this method in EllieBot services.***
|
||||
/// It is only meant to be used in marmalade system
|
||||
/// </summary>
|
||||
public interface IExecPostCommand : IBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Executed after a command was successfully executed
|
||||
/// <see cref="IExecOnMessage"/> →
|
||||
/// <see cref="IInputTransformer"/> →
|
||||
/// <see cref="IExecPreCommand"/> →
|
||||
/// [*<see cref="IExecPostCommand"/>* | <see cref="IExecNoCommand"/>]
|
||||
/// </summary>
|
||||
/// <param name="ctx">Command context</param>
|
||||
/// <param name="moduleName">Module name</param>
|
||||
/// <param name="commandName">Command name</param>
|
||||
/// <returns>A task representing completion</returns>
|
||||
ValueTask ExecPostCommandAsync(ICommandContext ctx, string moduleName, string commandName);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue