Added common files

This took way too long
This commit is contained in:
Toastie (DCS Team) 2024-06-18 23:44:07 +12:00
parent 06f399ff63
commit 547aa8b34d
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
214 changed files with 11046 additions and 0 deletions

View file

@ -0,0 +1,10 @@
#nullable disable
namespace EllieBot.Common;
public enum AddRemove
{
Add = int.MinValue,
Remove = int.MinValue + 1,
Rem = int.MinValue + 1,
Rm = int.MinValue + 1
}

View file

@ -0,0 +1,12 @@
using System.Runtime.CompilerServices;
namespace EllieBot.Common.Attributes;
[AttributeUsage(AttributeTargets.Method)]
public sealed class AliasesAttribute : AliasAttribute
{
public AliasesAttribute([CallerMemberName] string memberName = "")
: base(CommandNameLoadHelper.GetAliasesFor(memberName))
{
}
}

View file

@ -0,0 +1,18 @@
using System.Runtime.CompilerServices;
namespace EllieBot.Common.Attributes;
[AttributeUsage(AttributeTargets.Method)]
public sealed class CmdAttribute : CommandAttribute
{
public string MethodName { get; }
public CmdAttribute([CallerMemberName] string memberName = "")
: base(CommandNameLoadHelper.GetCommandNameFor(memberName))
{
MethodName = memberName.ToLowerInvariant();
Aliases = CommandNameLoadHelper.GetAliasesFor(memberName);
Remarks = memberName.ToLowerInvariant();
Summary = memberName.ToLowerInvariant();
}
}

View file

@ -0,0 +1,11 @@
#nullable disable
namespace EllieBot.Common;
/// <summary>
/// Classed marked with this attribute will not be added to the service provider
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class DIIgnoreAttribute : Attribute
{
}

View file

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

View file

@ -0,0 +1,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
}
}

View file

@ -0,0 +1,21 @@
#nullable disable
using System.Diagnostics.CodeAnalysis;
namespace EllieBot.Common;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
[SuppressMessage("Style", "IDE0022:Use expression body for methods")]
public sealed class OnlyPublicBotAttribute : PreconditionAttribute
{
public override Task<PreconditionResult> CheckPermissionsAsync(
ICommandContext context,
CommandInfo command,
IServiceProvider services)
{
#if GLOBAL_ELLIE || DEBUG
return Task.FromResult(PreconditionResult.FromSuccess());
#else
return Task.FromResult(PreconditionResult.FromError("Only available on the public bot."));
#endif
}
}

View file

@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
namespace EllieBot.Common.Attributes;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class OwnerOnlyAttribute : PreconditionAttribute
{
public override Task<PreconditionResult> CheckPermissionsAsync(
ICommandContext context,
CommandInfo command,
IServiceProvider services)
{
var creds = services.GetRequiredService<IBotCredsProvider>().GetCreds();
return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id
? PreconditionResult.FromSuccess()
: PreconditionResult.FromError("Not owner"));
}
}

View file

@ -0,0 +1,37 @@
using Microsoft.Extensions.DependencyInjection;
namespace EllieBot.Common.Attributes;
[AttributeUsage(AttributeTargets.Method)]
public sealed class RatelimitAttribute : PreconditionAttribute
{
public int Seconds { get; }
public RatelimitAttribute(int seconds)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(seconds);
Seconds = seconds;
}
public override async Task<PreconditionResult> CheckPermissionsAsync(
ICommandContext context,
CommandInfo command,
IServiceProvider services)
{
if (Seconds == 0)
return PreconditionResult.FromSuccess();
var cache = services.GetRequiredService<IBotCache>();
var rem = await cache.GetRatelimitAsync(
new($"precondition:{context.User.Id}:{command.Name}"),
Seconds.Seconds());
if (rem is null)
return PreconditionResult.FromSuccess();
var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s.";
return PreconditionResult.FromError(msgContent);
}
}

View file

@ -0,0 +1,29 @@
using Microsoft.Extensions.DependencyInjection;
namespace Discord;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class UserPermAttribute : RequireUserPermissionAttribute
{
public UserPermAttribute(GuildPerm permission)
: base(permission)
{
}
public UserPermAttribute(ChannelPerm permission)
: base(permission)
{
}
public override Task<PreconditionResult> CheckPermissionsAsync(
ICommandContext context,
CommandInfo command,
IServiceProvider services)
{
var permService = services.GetRequiredService<IDiscordPermOverrideService>();
if (permService.TryGetOverrides(context.Guild?.Id ?? 0, command.Name.ToUpperInvariant(), out _))
return Task.FromResult(PreconditionResult.FromSuccess());
return base.CheckPermissionsAsync(context, command, services);
}
}

View file

@ -0,0 +1,30 @@
#nullable disable
namespace EllieBot.Common.TypeReaders;
public sealed class CommandTypeReader : EllieTypeReader<CommandInfo>
{
private readonly CommandService _cmds;
private readonly ICommandHandler _handler;
public CommandTypeReader(ICommandHandler handler, CommandService cmds)
{
_handler = handler;
_cmds = cmds;
}
public override ValueTask<TypeReaderResult<CommandInfo>> ReadAsync(ICommandContext ctx, string input)
{
input = input.ToUpperInvariant();
var prefix = _handler.GetPrefix(ctx.Guild);
if (!input.StartsWith(prefix.ToUpperInvariant(), StringComparison.InvariantCulture))
return new(TypeReaderResult.FromError<CommandInfo>(CommandError.ParseFailed, "No such command found."));
input = input[prefix.Length..];
var cmd = _cmds.Commands.FirstOrDefault(c => c.Aliases.Select(a => a.ToUpperInvariant()).Contains(input));
if (cmd is null)
return new(TypeReaderResult.FromError<CommandInfo>(CommandError.ParseFailed, "No such command found."));
return new(TypeReaderResult.FromSuccess(cmd));
}
}

View file

@ -0,0 +1,25 @@
#nullable disable
namespace EllieBot.Common;
public abstract class CleanupModuleBase : EllieModule
{
protected async Task ConfirmActionInternalAsync(string name, Func<Task> action)
{
try
{
var embed = _sender.CreateEmbed()
.WithTitle(GetText(strs.sql_confirm_exec))
.WithDescription(name);
if (!await PromptUserConfirmAsync(embed))
return;
await action();
await ctx.OkAsync();
}
catch (Exception ex)
{
await Response().Error(ex.ToString()).SendAsync();
}
}
}

View file

@ -0,0 +1,10 @@
#nullable disable
using System.Runtime.InteropServices;
namespace EllieBot.Modules.Permissions;
[StructLayout(LayoutKind.Sequential, Size = 1)]
public readonly struct CleverBotResponseStr
{
public const string CLEVERBOT_RESPONSE = "cleverbot:response";
}

View file

@ -0,0 +1,17 @@
#nullable disable
using Newtonsoft.Json;
namespace EllieBot.Common;
public class CmdStrings
{
public string[] Usages { get; }
public string Description { get; }
[JsonConstructor]
public CmdStrings([JsonProperty("args")] string[] usages, [JsonProperty("desc")] string description)
{
Usages = usages;
Description = description;
}
}

View file

@ -0,0 +1,9 @@
#nullable disable
namespace EllieBot.Common;
public class CommandData
{
public string Cmd { get; set; }
public string Desc { get; set; }
public string[] Usage { get; set; }
}

View file

@ -0,0 +1,40 @@
using EllieBot.Common.Yml;
using YamlDotNet.Serialization;
namespace EllieBot.Common.Attributes;
public static class CommandNameLoadHelper
{
private static readonly IDeserializer _deserializer = new Deserializer();
private static readonly Lazy<Dictionary<string, string[]>> _lazyCommandAliases
= new(() => LoadAliases());
public static Dictionary<string, string[]> LoadAliases(string aliasesFilePath = "data/aliases.yml")
{
var text = File.ReadAllText(aliasesFilePath);
return _deserializer.Deserialize<Dictionary<string, string[]>>(text);
}
public static Dictionary<string, CommandStrings> LoadCommandStrings(
string commandsFilePath = "data/strings/commands.yml")
{
var text = File.ReadAllText(commandsFilePath);
return Yaml.Deserializer.Deserialize<Dictionary<string, CommandStrings>>(text);
}
public static string[] GetAliasesFor(string methodName)
=> _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1
? aliases.ToArray()
: Array.Empty<string>();
public static string GetCommandNameFor(string methodName)
{
methodName = methodName.ToLowerInvariant();
var toReturn = _lazyCommandAliases.Value.TryGetValue(methodName, out var aliases) && aliases.Length > 0
? aliases[0]
: methodName;
return toReturn;
}
}

View file

@ -0,0 +1,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
}

View file

@ -0,0 +1,18 @@
namespace EllieBot.Common.Configs;
/// <summary>
/// Base interface for available config serializers
/// </summary>
public interface IConfigSeria
{
/// <summary>
/// Serialize the object to string
/// </summary>
public string Serialize<T>(T obj)
where T : notnull;
/// <summary>
/// Deserialize string data into an object of the specified type
/// </summary>
public T Deserialize<T>(string data);
}

View file

@ -0,0 +1,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; }
}

View file

@ -0,0 +1,6 @@
namespace EllieBot.Services.Currency;
public enum CurrencyType
{
Default
}

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

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

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

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

View file

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

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

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

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

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

View file

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

View file

@ -0,0 +1,4 @@
namespace Ellie.Econ;
public sealed record class RegularCard(RegularSuit Suit, RegularValue Value)
: NewCard<RegularSuit, RegularValue>(Suit, Value);

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

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

View file

@ -0,0 +1,9 @@
namespace Ellie.Econ;
public enum RegularSuit
{
Hearts,
Diamonds,
Clubs,
Spades
}

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

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

View file

@ -0,0 +1,38 @@
#nullable disable
namespace EllieBot.Common;
public class DownloadTracker : IEService
{
private ConcurrentDictionary<ulong, DateTime> LastDownloads { get; } = new();
private readonly SemaphoreSlim _downloadUsersSemaphore = new(1, 1);
/// <summary>
/// Ensures all users on the specified guild were downloaded within the last hour.
/// </summary>
/// <param name="guild">Guild to check and potentially download users from</param>
/// <returns>Task representing download state</returns>
public async Task EnsureUsersDownloadedAsync(IGuild guild)
{
#if GLOBAL_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();
}
}
}

View file

@ -0,0 +1,108 @@
#nullable disable
using System.Globalization;
// ReSharper disable InconsistentNaming
namespace EllieBot.Common;
[UsedImplicitly(ImplicitUseTargetFlags.Default
| ImplicitUseTargetFlags.WithInheritors
| ImplicitUseTargetFlags.WithMembers)]
public abstract class EllieModule : ModuleBase
{
protected CultureInfo Culture { get; set; }
// Injected by Discord.net
public IBotStrings Strings { get; set; }
public ICommandHandler _cmdHandler { get; set; }
public ILocalization _localization { get; set; }
public IEllieInteractionService _inter { get; set; }
public IReplacementService repSvc { get; set; }
public IMessageSenderService _sender { get; set; }
public BotConfigService _bcs { get; set; }
protected string prefix
=> _cmdHandler.GetPrefix(ctx.Guild);
protected ICommandContext ctx
=> Context;
public ResponseBuilder Response()
=> new ResponseBuilder(Strings, _bcs, (DiscordSocketClient)ctx.Client)
.Context(ctx);
protected override void BeforeExecute(CommandInfo command)
=> Culture = _localization.GetCultureInfo(ctx.Guild?.Id);
protected string GetText(in LocStr data)
=> Strings.GetText(data, Culture);
// localized normal
public async Task<bool> PromptUserConfirmAsync(EmbedBuilder embed)
{
embed.WithPendingColor()
.WithFooter("yes/no");
var msg = await Response().Embed(embed).SendAsync();
try
{
var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
input = input?.ToUpperInvariant();
if (input != "YES" && input != "Y")
return false;
return true;
}
finally
{
_ = Task.Run(() => msg.DeleteAsync());
}
}
// TypeConverter typeConverter = TypeDescriptor.GetConverter(propType); ?
public async Task<string> GetUserInputAsync(ulong userId, ulong channelId, Func<string, bool> validate = null)
{
var userInputTask = new TaskCompletionSource<string>();
var dsc = (DiscordSocketClient)ctx.Client;
try
{
dsc.MessageReceived += MessageReceived;
if (await Task.WhenAny(userInputTask.Task, Task.Delay(10000)) != userInputTask.Task)
return null;
return await userInputTask.Task;
}
finally
{
dsc.MessageReceived -= MessageReceived;
}
Task MessageReceived(SocketMessage arg)
{
_ = Task.Run(() =>
{
if (arg is not SocketUserMessage userMsg
|| userMsg.Channel is not ITextChannel
|| userMsg.Author.Id != userId
|| userMsg.Channel.Id != channelId)
return Task.CompletedTask;
if (validate is not null && !validate(arg.Content))
return Task.CompletedTask;
if (userInputTask.TrySetResult(arg.Content))
userMsg.DeleteAfter(1);
return Task.CompletedTask;
});
return Task.CompletedTask;
}
}
}
public abstract class EllieModule<TService> : EllieModule
{
public TService _service { get; set; }
}

View file

@ -0,0 +1,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);
}

View file

@ -0,0 +1,7 @@
namespace EllieBot.Modules.Gambling.Betdraw;
public enum BetdrawColorGuess
{
Red,
Black
}

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

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

View file

@ -0,0 +1,7 @@
namespace EllieBot.Modules.Gambling.Betdraw;
public enum BetdrawResultType
{
Win,
Lose
}

View file

@ -0,0 +1,7 @@
namespace EllieBot.Modules.Gambling.Betdraw;
public enum BetdrawValueGuess
{
High,
Low,
}

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

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

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

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

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

View 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,
};
}
*/

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

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

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

View file

@ -0,0 +1,13 @@
#nullable disable
namespace EllieBot.Common;
public static class Helpers
{
public static void ReadErrorAndExit(int exitCode)
{
if (!Console.IsInputRedirected)
Console.ReadKey();
Environment.Exit(exitCode);
}
}

View file

@ -0,0 +1,12 @@
#nullable disable
using EllieBot.Db.Models;
namespace EllieBot;
public interface IBot
{
IReadOnlyList<ulong> GetCurrentGuildIds();
event Func<GuildConfig, Task> JoinedGuild;
IReadOnlyCollection<GuildConfig> AllGuildConfigs { get; }
bool IsReady { get; }
}

View file

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

View file

@ -0,0 +1,29 @@
using System.Globalization;
using System.Numerics;
namespace EllieBot.Common;
public interface ICurrencyProvider
{
string GetCurrencySign();
}
public static class CurrencyHelper
{
public static string N<T>(T cur, IFormatProvider format)
where T : INumber<T>
=> cur.ToString("C0", format);
public static string N<T>(T cur, CultureInfo culture, string currencySign)
where T : INumber<T>
=> N(cur, GetCurrencyFormat(culture, currencySign));
private static IFormatProvider GetCurrencyFormat(CultureInfo culture, string currencySign)
{
var flowersCurrencyCulture = (CultureInfo)culture.Clone();
flowersCurrencyCulture.NumberFormat.CurrencySymbol = currencySign;
flowersCurrencyCulture.NumberFormat.CurrencyNegativePattern = 5;
return flowersCurrencyCulture;
}
}

View file

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

View file

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

View file

@ -0,0 +1,34 @@
using EllieBot.Db.Models;
namespace EllieBot.Common;
public interface ILogCommandService
{
void AddDeleteIgnore(ulong xId);
Task LogServer(ulong guildId, ulong channelId, bool actionValue);
bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType);
LogSetting? GetGuildLogSettings(ulong guildId);
bool Log(ulong guildId, ulong? channelId, LogType type);
}
public enum LogType
{
Other,
MessageUpdated,
MessageDeleted,
UserJoined,
UserLeft,
UserBanned,
UserUnbanned,
UserUpdated,
ChannelCreated,
ChannelDestroyed,
ChannelUpdated,
UserPresence,
VoicePresence,
UserMuted,
UserWarned,
ThreadDeleted,
ThreadCreated
}

View file

@ -0,0 +1,37 @@
using OneOf;
namespace EllieBot.Common;
public interface IPermissionChecker
{
Task<PermCheckResult> CheckPermsAsync(IGuild guild,
IMessageChannel channel,
IUser author,
string module,
string? cmd);
}
[GenerateOneOf]
public partial class PermCheckResult
: OneOfBase<PermAllowed, PermCooldown, PermGlobalBlock, PermDisallowed>
{
public bool IsAllowed
=> IsT0;
public bool IsCooldown
=> IsT1;
public bool IsGlobalBlock
=> IsT2;
public bool IsDisallowed
=> IsT3;
}
public readonly record struct PermAllowed;
public readonly record struct PermCooldown;
public readonly record struct PermGlobalBlock;
public readonly record struct PermDisallowed(int PermIndex, string PermText, bool IsVerbose);

View file

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

View file

@ -0,0 +1,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; }
}
}

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

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

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

View file

@ -0,0 +1,7 @@
namespace EllieBot;
public static class InteractionHelpers
{
public static readonly IEmote ArrowLeft = Emote.Parse("<:x:1232256519844790302>");
public static readonly IEmote ArrowRight = Emote.Parse("<:x:1232256515298295838>");
}

View file

@ -0,0 +1,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);
}

View file

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

View file

@ -0,0 +1,21 @@
namespace EllieBot;
public sealed class EllieButtonSelectInteractionHandler : EllieInteractionBase
{
public EllieButtonSelectInteractionHandler(
DiscordSocketClient client,
ulong authorId,
SelectMenuBuilder menu,
Func<SocketMessageComponent, Task> onAction,
bool onlyAuthor,
bool singleUse = true)
: base(client, authorId, menu.CustomId, onAction, onlyAuthor, singleUse)
{
Menu = menu;
}
public SelectMenuBuilder Menu { get; }
public override void AddTo(ComponentBuilder cb)
=> cb.WithSelectMenu(Menu);
}

View file

@ -0,0 +1,14 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace EllieBot.Common.JsonConverters;
public class CultureInfoConverter : JsonConverter<CultureInfo>
{
public override CultureInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new(reader.GetString() ?? "en_US");
public override void Write(Utf8JsonWriter writer, CultureInfo value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.Name);
}

View file

@ -0,0 +1,14 @@
using SixLabors.ImageSharp.PixelFormats;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace EllieBot.Common.JsonConverters;
public class Rgba32Converter : JsonConverter<Rgba32>
{
public override Rgba32 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> Rgba32.ParseHex(reader.GetString());
public override void Write(Utf8JsonWriter writer, Rgba32 value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToHex());
}

View file

@ -0,0 +1,14 @@
#nullable disable
using CommandLine;
namespace EllieBot.Common;
public class LbOpts : IEllieCommandOptions
{
[Option('c', "clean", Default = false, HelpText = "Only show users who are on the server.")]
public bool Clean { get; set; }
public void NormalizeOptions()
{
}
}

View file

@ -0,0 +1,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;
}

View file

@ -0,0 +1,52 @@
#nullable disable
using System.Net;
using System.Runtime.CompilerServices;
namespace EllieBot.Common;
public class LoginErrorHandler
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Handle(Exception ex)
=> Log.Fatal(ex, "A fatal error has occurred while attempting to connect to Discord");
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Handle(HttpException ex)
{
switch (ex.HttpCode)
{
case HttpStatusCode.Unauthorized:
Log.Error("Your bot token is wrong.\n"
+ "You can find the bot token under the Bot tab in the developer page.\n"
+ "Fix your token in the credentials file and restart the bot");
break;
case HttpStatusCode.BadRequest:
Log.Error("Something has been incorrectly formatted in your credentials file.\n"
+ "Use the JSON Guide as reference to fix it and restart the bot");
Log.Error("If you are on Linux, make sure Redis is installed and running");
break;
case HttpStatusCode.RequestTimeout:
Log.Error("The request timed out. Make sure you have no external program blocking the bot "
+ "from connecting to the internet");
break;
case HttpStatusCode.ServiceUnavailable:
case HttpStatusCode.InternalServerError:
Log.Error("Discord is having internal issues. Please, try again later");
break;
case HttpStatusCode.TooManyRequests:
Log.Error("Your bot has been ratelimited by Discord. Please, try again later.\n"
+ "Global ratelimits usually last for an hour");
break;
default:
Log.Warning("An error occurred while attempting to connect to Discord");
break;
}
Log.Fatal(ex, "Fatal error occurred while loading credentials");
}
}

View file

@ -0,0 +1,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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,8 @@
namespace EllieBot.Marmalade;
public interface IMarmaladeConfigService
{
IReadOnlyCollection<string> GetLoadedMarmalades();
void AddLoadedMarmalade(string name);
void RemoveLoadedMarmalade(string name);
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

@ -0,0 +1,10 @@
namespace EllieBot.Marmalade;
public sealed record ParamData(
Type Type,
string Name,
bool IsOptional,
object? DefaultValue,
bool IsLeftover,
bool IsParams
);

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
namespace EllieBot.Common.ModuleBehaviors;
/// <summary>
/// Executed if no command was found for this message
/// </summary>
public interface IExecNoCommand : IBehavior
{
/// <summary>
/// Executed at the end of the lifecycle if no command was found
/// <see cref="IExecOnMessage"/> →
/// <see cref="IInputTransformer"/> →
/// <see cref="IExecPreCommand"/> →
/// [<see cref="IExecPostCommand"/> | *<see cref="IExecNoCommand"/>*]
/// </summary>
/// <param name="guild"></param>
/// <param name="msg"></param>
/// <returns>A task representing completion</returns>
Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg);
}

View file

@ -0,0 +1,21 @@
namespace EllieBot.Common.ModuleBehaviors;
/// <summary>
/// Implemented by modules to handle non-bot messages received
/// </summary>
public interface IExecOnMessage : IBehavior
{
int Priority { get; }
/// <summary>
/// Ran after a non-bot message was received
/// *<see cref="IExecOnMessage"/>* →
/// <see cref="IInputTransformer"/> →
/// <see cref="IExecPreCommand"/> →
/// [<see cref="IExecPostCommand"/> | <see cref="IExecNoCommand"/>]
/// </summary>
/// <param name="guild">Guild where the message was sent</param>
/// <param name="msg">The message that was received</param>
/// <returns>Whether further processing of this message should be blocked</returns>
Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage msg);
}

View file

@ -0,0 +1,22 @@
namespace EllieBot.Common.ModuleBehaviors;
/// <summary>
/// This interface's method is executed after the command successfully finished execution.
/// ***There is no support for this method in EllieBot services.***
/// It is only meant to be used in 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