Updated Ellie.Marmalade

This commit is contained in:
Toastie 2024-03-23 18:42:46 +13:00
parent fb808dfc1d
commit a645caf495
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
29 changed files with 917 additions and 3 deletions

View file

@ -21,11 +21,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ayu.Discord.Voice", "src\ay
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Tests", "src\EllieBot.Tests\EllieBot.Tests.csproj", "{179DF3B3-AD32-4335-8231-9818338DF3A2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.Coordinator", "src\EllieBot.Coordinator\EllieBot.Coordinator.csproj", "{A631DDF0-3AD1-4CB9-8458-314B1320868A}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Coordinator", "src\EllieBot.Coordinator\EllieBot.Coordinator.csproj", "{A631DDF0-3AD1-4CB9-8458-314B1320868A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.Generators", "src\EllieBot.Generators\EllieBot.Generators.csproj", "{CB1A5307-DD85-4795-8A8A-A25D36DADC51}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Generators", "src\EllieBot.Generators\EllieBot.Generators.csproj", "{CB1A5307-DD85-4795-8A8A-A25D36DADC51}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.VotesApi", "src\EllieBot.VotesApi\EllieBot.VotesApi.csproj", "{F1A77F56-71B0-430E-AE46-94CDD7D43874}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.VotesApi", "src\EllieBot.VotesApi\EllieBot.VotesApi.csproj", "{F1A77F56-71B0-430E-AE46-94CDD7D43874}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Marmalade", "src\Ellie.Marmalade\Ellie.Marmalade.csproj", "{76AC715D-12FF-4CBE-9585-A861139A2D0C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -57,6 +59,10 @@ Global
{F1A77F56-71B0-430E-AE46-94CDD7D43874}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F1A77F56-71B0-430E-AE46-94CDD7D43874}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F1A77F56-71B0-430E-AE46-94CDD7D43874}.Release|Any CPU.Build.0 = Release|Any CPU
{76AC715D-12FF-4CBE-9585-A861139A2D0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{76AC715D-12FF-4CBE-9585-A861139A2D0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{76AC715D-12FF-4CBE-9585-A861139A2D0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{76AC715D-12FF-4CBE-9585-A861139A2D0C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -69,6 +75,7 @@ Global
{A631DDF0-3AD1-4CB9-8458-314B1320868A} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
{CB1A5307-DD85-4795-8A8A-A25D36DADC51} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
{F1A77F56-71B0-430E-AE46-94CDD7D43874} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
{76AC715D-12FF-4CBE-9585-A861139A2D0C} = {B28FB883-9688-41EB-BF5A-945F4A4EB628}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4}

View file

@ -0,0 +1,10 @@
namespace Ellie.Canary;
/// <summary>
/// Overridden to implement custom checks which commands have to pass in order to be executed.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)]
public abstract class FilterAttribute : Attribute
{
public abstract ValueTask<bool> CheckAsync(AnyContext ctx);
}

View file

@ -0,0 +1,10 @@
namespace Ellie.Canary;
/// <summary>
/// Used as a marker class for bot_perm and user_perm Attributes
/// Has no functionality.
/// </summary>
public abstract class MarmaladePermAttribute : Attribute
{
}

View file

@ -0,0 +1,7 @@
namespace Ellie.Canary;
[AttributeUsage(AttributeTargets.Method)]
public sealed class bot_owner_onlyAttribute : MarmaladePermAttribute
{
}

View file

@ -0,0 +1,22 @@
using Discord;
namespace Ellie.Canary;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class bot_permAttribute : MarmaladePermAttribute
{
public GuildPermission? GuildPerm { get; }
public ChannelPermission? ChannelPerm { get; }
public bot_permAttribute(GuildPermission perm)
{
GuildPerm = perm;
ChannelPerm = null;
}
public bot_permAttribute(ChannelPermission perm)
{
ChannelPerm = perm;
GuildPerm = null;
}
}

View file

@ -0,0 +1,37 @@
namespace Ellie.Canary;
/// <summary>
/// Marks a method as a snek command
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class cmdAttribute : Attribute
{
/// <summary>
/// Command description. Avoid using, as cmds.yml is preferred
/// </summary>
public string? desc { get; set; }
/// <summary>
/// Command args examples. Avoid using, as cmds.yml is preferred
/// </summary>
public string[]? args { get; set; }
/// <summary>
/// Command aliases
/// </summary>
public string[] Aliases { get; }
public cmdAttribute()
{
desc = null;
args = null;
Aliases = Array.Empty<string>();
}
public cmdAttribute(params string[] aliases)
{
Aliases = aliases;
desc = null;
args = null;
}
}

View file

@ -0,0 +1,10 @@
namespace Ellie.Canary;
/// <summary>
/// Marks services in command arguments for injection.
/// The injected services must come after the context and before any input parameters.
/// </summary>
public class injectAttribute : Attribute
{
}

View file

@ -0,0 +1,10 @@
namespace Ellie.Canary;
/// <summary>
/// Marks the parameter to take
/// </summary>
[AttributeUsage(AttributeTargets.Parameter)]
public class leftoverAttribute : Attribute
{
}

View file

@ -0,0 +1,20 @@
namespace Ellie.Canary;
/// <summary>
/// Sets the priority of a command in case there are multiple commands with the same name but different parameters.
/// Higher value means higher priority.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class prioAttribute : Attribute
{
public int Priority { get; }
/// <summary>
/// Snek command priority
/// </summary>
/// <param name="priority">Priority value. The higher the value, the higher the priority</param>
public prioAttribute(int priority)
{
Priority = priority;
}
}

View file

@ -0,0 +1,23 @@
namespace Ellie.Canary;
/// <summary>
/// Marks the class as a service which can be used within the same Medusa
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class svcAttribute : Attribute
{
public Lifetime Lifetime { get; }
public svcAttribute(Lifetime lifetime)
{
Lifetime = lifetime;
}
}
/// <summary>
/// Lifetime for <see cref="svcAttribute"/>
/// </summary>
public enum Lifetime
{
Singleton,
Transient
}

View file

@ -0,0 +1,22 @@
using Discord;
namespace Ellie.Canary;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class user_permAttribute : MarmaladePermAttribute
{
public GuildPermission? GuildPerm { get; }
public ChannelPermission? ChannelPerm { get; }
public user_permAttribute(GuildPermission perm)
{
GuildPerm = perm;
ChannelPerm = null;
}
public user_permAttribute(ChannelPermission perm)
{
ChannelPerm = perm;
GuildPerm = null;
}
}

View file

@ -0,0 +1,143 @@
using Discord;
namespace Ellie.Canary;
/// <summary>
/// The base class which will be loaded as a module into NadekoBot
/// Any user-defined snek has to inherit from this class.
/// Sneks get instantiated ONLY ONCE during the loading,
/// and any snek commands will be executed on the same instance.
/// </summary>
public abstract class Snek : IAsyncDisposable
{
/// <summary>
/// Name of the snek. Defaults to the lowercase class name
/// </summary>
public virtual string Name
=> GetType().Name.ToLowerInvariant();
/// <summary>
/// The prefix required before the command name. For example
/// if you set this to 'test' then a command called 'cmd' will have to be invoked by using
/// '.test cmd' instead of `.cmd`
/// </summary>
public virtual string Prefix
=> string.Empty;
/// <summary>
/// Executed once this snek has been instantiated and before any command is executed.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing completion</returns>
public virtual ValueTask InitializeAsync()
=> default;
/// <summary>
/// Override to cleanup any resources or references which might hold this snek in memory
/// </summary>
/// <returns></returns>
public virtual ValueTask DisposeAsync()
=> default;
/// <summary>
/// This method is called right after the message was received by the bot.
/// You can use this method to make the bot conditionally ignore some messages and prevent further processing.
/// <para>Execution order:</para>
/// <para>
/// *<see cref="ExecOnMessageAsync"/>* →
/// <see cref="ExecInputTransformAsync"/> →
/// <see cref="ExecPreCommandAsync"/> →
/// <see cref="ExecPostCommandAsync"/> OR <see cref="ExecOnNoCommandAsync"/>
/// </para>
/// </summary>
/// <param name="guild">Guild in which the message was sent</param>
/// <param name="msg">Message received by the bot</param>
/// <returns>A <see cref="ValueTask"/> representing whether the message should be ignored and not processed further</returns>
public virtual ValueTask<bool> ExecOnMessageAsync(IGuild? guild, IUserMessage msg)
=> default;
/// <summary>
/// Override this method to modify input before the bot searches for any commands matching the input
/// Executed after <see cref="ExecOnMessageAsync"/>
/// This is useful if you want to reinterpret the message under some conditions
/// <para>Execution order:</para>
/// <para>
/// <see cref="ExecOnMessageAsync"/> →
/// *<see cref="ExecInputTransformAsync"/>* →
/// <see cref="ExecPreCommandAsync"/> →
/// <see cref="ExecPostCommandAsync"/> OR <see cref="ExecOnNoCommandAsync"/>
/// </para>
/// </summary>
/// <param name="guild">Guild in which the message was sent</param>
/// <param name="channel">Channel in which the message was sent</param>
/// <param name="user">User who sent the message</param>
/// <param name="input">Content of the message</param>
/// <returns>A <see cref="ValueTask"/> representing new, potentially modified content</returns>
public virtual ValueTask<string?> ExecInputTransformAsync(
IGuild? guild,
IMessageChannel channel,
IUser user,
string input
)
=> default;
/// <summary>
/// This method is called after the command was found but not executed,
/// and can be used to prevent the command's execution.
/// The command information doesn't have to be from this snek as this method
/// will be called when *any* command from any module or snek was found.
/// You can choose to prevent the execution of the command by returning "true" value.
/// <para>Execution order:</para>
/// <para>
/// <see cref="ExecOnMessageAsync"/> →
/// <see cref="ExecInputTransformAsync"/> →
/// *<see cref="ExecPreCommandAsync"/>* →
/// <see cref="ExecPostCommandAsync"/> OR <see cref="ExecOnNoCommandAsync"/>
/// </para>
/// </summary>
/// <param name="context">Command context</param>
/// <param name="moduleName">Name of the snek or module from which the command originates</param>
/// <param name="commandName">Name of the command which is about to be executed</param>
/// <returns>A <see cref="ValueTask"/> representing whether the execution should be blocked</returns>
public virtual ValueTask<bool> ExecPreCommandAsync(
AnyContext context,
string moduleName,
string commandName
)
=> default;
/// <summary>
/// This method is called after the command was succesfully executed.
/// If this method was called, then <see cref="ExecOnNoCommandAsync"/> will not be executed
/// <para>Execution order:</para>
/// <para>
/// <see cref="ExecOnMessageAsync"/> →
/// <see cref="ExecInputTransformAsync"/> →
/// <see cref="ExecPreCommandAsync"/> →
/// *<see cref="ExecPostCommandAsync"/>* OR <see cref="ExecOnNoCommandAsync"/>
/// </para>
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing completion</returns>
public virtual ValueTask ExecPostCommandAsync(AnyContext ctx, string moduleName, string commandName)
=> default;
/// <summary>
/// This method is called if no command was found for the input.
/// Useful if you want to have games or features which take arbitrary input
/// but ignore any messages which were blocked or caused a command execution
/// If this method was called, then <see cref="ExecPostCommandAsync"/> will not be executed
/// <para>Execution order:</para>
/// <para>
/// <see cref="ExecOnMessageAsync"/> →
/// <see cref="ExecInputTransformAsync"/> →
/// <see cref="ExecPreCommandAsync"/> →
/// <see cref="ExecPostCommandAsync"/> OR *<see cref="ExecOnNoCommandAsync"/>*
/// </para>
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing completion</returns>
public virtual ValueTask ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg)
=> default;
}
public readonly struct ExecResponse
{
}

View file

@ -0,0 +1,52 @@
using Discord;
using EllieBot;
namespace Ellie.Canary;
/// <summary>
/// Commands which take this class as a first parameter can be executed in both DMs and Servers
/// </summary>
public abstract class AnyContext
{
/// <summary>
/// Channel from the which the command is invoked
/// </summary>
public abstract IMessageChannel Channel { get; }
/// <summary>
/// Message which triggered the command
/// </summary>
public abstract IUserMessage Message { get; }
/// <summary>
/// The user who invoked the command
/// </summary>
public abstract IUser User { get; }
/// <summary>
/// Bot user
/// </summary>
public abstract ISelfUser Bot { get; }
/// <summary>
/// Provides access to strings used by this medusa
/// </summary>
public abstract IMarmaladeStrings Strings { get; }
/// <summary>
/// Gets a formatted localized string using a key and arguments which should be formatted in
/// </summary>
/// <param name="key">The key of the string as specified in localization files</param>
/// <param name="args">Arguments (if any) to format in</param>
/// <returns>A formatted localized string</returns>
public abstract string GetText(string key, object[]? args = null);
/// <summary>
/// Creates a context-aware <see cref="IEmbedBuilder"/> instance
/// (future feature for guild-based embed colors)
/// Any code dealing with embeds should use it for future-proofness
/// instead of manually creating embedbuilder instances
/// </summary>
/// <returns>A context-aware <see cref="IEmbedBuilder"/> instance </returns>
public abstract IEmbedBuilder Embed();
}

View file

@ -0,0 +1,11 @@
using Discord;
namespace Ellie.Canary;
/// <summary>
/// Commands which take this type as the first parameter can only be executed in DMs
/// </summary>
public abstract class DmContext : AnyContext
{
public abstract override IDMChannel Channel { get; }
}

View file

@ -0,0 +1,12 @@
using Discord;
namespace Ellie.Canary;
/// <summary>
/// Commands which take this type as a first parameter can only be executed in a server
/// </summary>
public abstract class GuildContext : AnyContext
{
public abstract override ITextChannel Channel { get; }
public abstract IGuild Guild { get; }
}

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<RootNamespace>Ellie.Canary</RootNamespace>
<Authors>The EllieBot Devs</Authors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Discord.Net.Core" Version="3.104.0" />
<PackageReference Include="Serilog" Version="2.11.0" />
<PackageReference Include="YamlDotNet" Version="11.2.1" />
</ItemGroup>
<PropertyGroup Condition=" '$(Version)' == '' ">
<Version>5.0.0</Version>
</PropertyGroup>
</Project>

View file

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

View file

@ -0,0 +1,14 @@
namespace EllieBot;
public static class EmbedBuilderExtensions
{
public static IEmbedBuilder WithOkColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Ok);
public static IEmbedBuilder WithPendingColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Pending);
public static IEmbedBuilder WithErrorColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Error);
}

View file

@ -0,0 +1,66 @@
using Discord;
using Ellie.Canary;
namespace EllieBot;
public static class MarmaladeExtensions
{
public static Task<IUserMessage> EmbedAsync(this IMessageChannel ch, IEmbedBuilder embed, string msg = "")
=> ch.SendMessageAsync(msg,
embed: embed.Build(),
options: new()
{
RetryMode = RetryMode.Retry502
});
// unlocalized
public static Task<IUserMessage> SendConfirmAsync(this IMessageChannel ch, AnyContext ctx, string msg)
=> ch.EmbedAsync(ctx.Embed().WithOkColor().WithDescription(msg));
public static Task<IUserMessage> SendPendingAsync(this IMessageChannel ch, AnyContext ctx, string msg)
=> ch.EmbedAsync(ctx.Embed().WithPendingColor().WithDescription(msg));
public static Task<IUserMessage> SendErrorAsync(this IMessageChannel ch, AnyContext ctx, string msg)
=> ch.EmbedAsync(ctx.Embed().WithErrorColor().WithDescription(msg));
// unlocalized
public static Task<IUserMessage> SendConfirmAsync(this AnyContext ctx, string msg)
=> ctx.Channel.SendConfirmAsync(ctx, msg);
public static Task<IUserMessage> SendPendingAsync(this AnyContext ctx, string msg)
=> ctx.Channel.SendPendingAsync(ctx, msg);
public static Task<IUserMessage> SendErrorAsync(this AnyContext ctx, string msg)
=> ctx.Channel.SendErrorAsync(ctx, msg);
// localized
public static Task ConfirmAsync(this AnyContext ctx)
=> ctx.Message.AddReactionAsync(new Emoji("✅"));
public static Task ErrorAsync(this AnyContext ctx)
=> ctx.Message.AddReactionAsync(new Emoji("❌"));
public static Task WarningAsync(this AnyContext ctx)
=> ctx.Message.AddReactionAsync(new Emoji("⚠️"));
public static Task WaitAsync(this AnyContext ctx)
=> ctx.Message.AddReactionAsync(new Emoji("🤔"));
public static Task<IUserMessage> ErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendErrorAsync(ctx.GetText(key, args));
public static Task<IUserMessage> PendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendPendingAsync(ctx.GetText(key, args));
public static Task<IUserMessage> ConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendConfirmAsync(ctx.GetText(key, args));
public static Task<IUserMessage> ReplyErrorLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}");
public static Task<IUserMessage> ReplyPendingLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}");
public static Task<IUserMessage> ReplyConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args)
=> ctx.SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}");
}

View file

@ -0,0 +1,18 @@
using Discord;
namespace EllieBot;
public interface IEmbedBuilder
{
IEmbedBuilder WithDescription(string? desc);
IEmbedBuilder WithTitle(string? title);
IEmbedBuilder AddField(string title, object value, bool isInline = false);
IEmbedBuilder WithFooter(string text, string? iconUrl = null);
IEmbedBuilder WithAuthor(string name, string? iconUrl = null, string? url = null);
IEmbedBuilder WithColor(EmbedColor color);
IEmbedBuilder WithDiscordColor(Color color);
Embed Build();
IEmbedBuilder WithUrl(string url);
IEmbedBuilder WithImageUrl(string url);
IEmbedBuilder WithThumbnailUrl(string url);
}

View file

@ -0,0 +1,16 @@
namespace Ellie.Canary;
/// <summary>
/// Overridden to implement parsers for custom types
/// </summary>
/// <typeparam name="T">Type into which to parse the input</typeparam>
public abstract class ParamParser<T>
{
/// <summary>
/// Overridden to implement parsing logic
/// </summary>
/// <param name="ctx">Context</param>
/// <param name="input">Input to parse</param>
/// <returns>A <see cref="ParseResult{T}"/> with successful or failed status</returns>
public abstract ValueTask<ParseResult<T>> TryParseAsync(AnyContext ctx, string input);
}

View file

@ -0,0 +1,48 @@
namespace Ellie.Canary;
public readonly struct ParseResult<T>
{
/// <summary>
/// Whether the parsing was successful
/// </summary>
public bool IsSuccess { get; private init; }
/// <summary>
/// Parsed value. It should only have value if <see cref="IsSuccess"/> is set to true
/// </summary>
public T? Data { get; private init; }
/// <summary>
/// Instantiate a **successful** parse result
/// </summary>
/// <param name="data">Parsed value</param>
public ParseResult(T data)
{
Data = data;
IsSuccess = true;
}
/// <summary>
/// Create a new <see cref="ParseResult{T}"/> with IsSuccess = false
/// </summary>
/// <returns>A new <see cref="ParseResult{T}"/></returns>
public static ParseResult<T> Fail()
=> new ParseResult<T>
{
IsSuccess = false,
Data = default,
};
/// <summary>
/// Create a new <see cref="ParseResult{T}"/> with IsSuccess = true
/// </summary>
/// <param name="obj">Value of the parsed object</param>
/// <returns>A new <see cref="ParseResult{T}"/></returns>
public static ParseResult<T> Success(T obj)
=> new ParseResult<T>
{
IsSuccess = true,
Data = obj,
};
}

View file

@ -0,0 +1 @@
This is the library which is the base of any marmalade.

View file

@ -0,0 +1,24 @@
using YamlDotNet.Serialization;
namespace Ellie.Canary;
public readonly struct CommandStrings
{
public CommandStrings(string? desc, string[]? args)
{
Desc = desc;
Args = args;
}
[YamlMember(Alias = "desc")]
public string? Desc { get; init; }
[YamlMember(Alias = "args")]
public string[]? Args { get; init; }
public void Deconstruct(out string? desc, out string[]? args)
{
desc = Desc;
args = Args;
}
}

View file

@ -0,0 +1,15 @@
using System.Globalization;
namespace Ellie.Canary;
/// <summary>
/// Defines methods to retrieve and reload marmalade strings
/// </summary>
public interface IMarmaladeStrings
{
// string GetText(string key, ulong? guildId = null, params object[] data);
string? GetText(string key, CultureInfo locale, params object[] data);
void Reload();
CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo);
string? GetDescription(CultureInfo? locale);
}

View file

@ -0,0 +1,28 @@
namespace Ellie.Canary;
/// <summary>
/// Implemented by classes which provide localized strings in their own ways
/// </summary>
public interface IMarmaladeStringsProvider
{
/// <summary>
/// Gets localized string
/// </summary>
/// <param name="localeName">Language name</param>
/// <param name="key">String key</param>
/// <returns>Localized string</returns>
string? GetText(string localeName, string key);
/// <summary>
/// Reloads string cache
/// </summary>
void Reload();
// /// <summary>
// /// Gets command arg examples and description
// /// </summary>
// /// <param name="localeName">Language name</param>
// /// <param name="commandName">Command name</param>
// CommandStrings GetCommandStrings(string localeName, string commandName);
CommandStrings? GetCommandStrings(string localeName, string commandName);
}

View file

@ -0,0 +1,40 @@
namespace Ellie.Canary;
public class LocalMarmaladeStringsProvider : IMarmaladeStringsProvider
{
private readonly StringsLoader _source;
private IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> _responseStrings;
private IReadOnlyDictionary<string, IReadOnlyDictionary<string, CommandStrings>> _commandStrings;
public LocalMarmaladeStringsProvider(StringsLoader source)
{
_source = source;
_responseStrings = _source.GetResponseStrings();
_commandStrings = _source.GetCommandStrings();
}
public void Reload()
{
_responseStrings = _source.GetResponseStrings();
_commandStrings = _source.GetCommandStrings();
}
public string? GetText(string localeName, string key)
{
if (_responseStrings.TryGetValue(localeName.ToLowerInvariant(), out var langStrings)
&& langStrings.TryGetValue(key.ToLowerInvariant(), out var text))
return text;
return null;
}
public CommandStrings? GetCommandStrings(string localeName, string commandName)
{
if (_commandStrings.TryGetValue(localeName.ToLowerInvariant(), out var langStrings)
&& langStrings.TryGetValue(commandName.ToLowerInvariant(), out var strings))
return strings;
return null;
}
}

View file

@ -0,0 +1,79 @@
using System.Globalization;
using Serilog;
namespace Ellie.Canary;
public class MarmaladeStrings : IMarmaladeStrings
{
/// <summary>
/// Used as failsafe in case response key doesn't exist in the selected or default language.
/// </summary>
private readonly CultureInfo _usCultureInfo = new("en-US");
private readonly IMarmaladeStringsProvider _stringsProvider;
public MarmaladeStrings(IMarmaladeStringsProvider stringsProvider)
{
_stringsProvider = stringsProvider;
}
private string? GetString(string key, CultureInfo cultureInfo)
=> _stringsProvider.GetText(cultureInfo.Name, key);
public string? GetText(string key, CultureInfo cultureInfo)
=> GetString(key, cultureInfo)
?? GetString(key, _usCultureInfo);
public string? GetText(string key, CultureInfo cultureInfo, params object[] data)
{
var text = GetText(key, cultureInfo);
if (string.IsNullOrWhiteSpace(text))
return null;
try
{
return string.Format(text, data);
}
catch (FormatException)
{
Log.Warning(" Key '{Key}' is not properly formatted in '{LanguageName}' response strings",
key,
cultureInfo.Name);
return $"⚠️ Response string key '{key}' is not properly formatted. Please report this.\n\n{text}";
}
}
public CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo)
{
var cmdStrings = _stringsProvider.GetCommandStrings(cultureInfo.Name, commandName);
if (cmdStrings is null)
{
if (cultureInfo.Name == _usCultureInfo.Name)
{
Log.Warning("'{CommandName}' doesn't exist in 'en-US' command strings for one of the marmalades",
commandName);
return new(null, null);
}
Log.Information("Missing '{CommandName}' command strings for the '{LocaleName}' locale",
commandName,
cultureInfo.Name);
return GetCommandStrings(commandName, _usCultureInfo);
}
return cmdStrings.Value;
}
public string? GetDescription(CultureInfo? locale = null)
=> GetText("marmalades.description", locale ?? _usCultureInfo);
public static MarmaladeStrings CreateDefault(string basePath)
=> new MarmaladeStrings(new LocalMarmaladeStringsProvider(new(basePath)));
public void Reload()
=> _stringsProvider.Reload();
}

View file

@ -0,0 +1,137 @@
using System.Diagnostics.CodeAnalysis;
using Serilog;
using YamlDotNet.Serialization;
namespace Ellie.Canary;
/// <summary>
/// Loads strings from the shortcut or localizable path
/// </summary>
public class StringsLoader
{
private readonly string _localizableResponsesPath;
private readonly string _shortcutResponsesFile;
private readonly string _localizableCommandsPath;
private readonly string _shortcutCommandsFile;
public StringsLoader(string basePath)
{
_localizableResponsesPath = Path.Join(basePath, "strings/res");
_shortcutResponsesFile = Path.Join(basePath, "res.yml");
_localizableCommandsPath = Path.Join(basePath, "strings/cmds");
_shortcutCommandsFile = Path.Join(basePath, "cmds.yml");
}
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, CommandStrings>> GetCommandStrings()
{
var outputDict = new Dictionary<string, IReadOnlyDictionary<string, CommandStrings>>();
if (File.Exists(_shortcutCommandsFile))
{
if (TryLoadCommandsFromFile(_shortcutCommandsFile, out var dict, out _))
{
outputDict["en-us"] = dict;
}
return outputDict;
}
if (Directory.Exists(_localizableCommandsPath))
{
foreach (var cmdsFile in Directory.EnumerateFiles(_localizableCommandsPath))
{
if (TryLoadCommandsFromFile(cmdsFile, out var dict, out var locale) && locale is not null)
{
outputDict[locale.ToLowerInvariant()] = dict;
}
}
}
return outputDict;
}
private static readonly IDeserializer _deserializer = new DeserializerBuilder().Build();
private static bool TryLoadCommandsFromFile(string file,
[NotNullWhen(true)] out IReadOnlyDictionary<string, CommandStrings>? strings,
out string? localeName)
{
try
{
var text = File.ReadAllText(file);
strings = _deserializer.Deserialize<Dictionary<string, CommandStrings>?>(text)
?? new();
localeName = GetLocaleName(file);
return true;
}
catch (Exception ex)
{
Log.Error(ex, "Error loading {FileName} command strings: {ErrorMessage}", file, ex.Message);
}
strings = null;
localeName = null;
return false;
}
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> GetResponseStrings()
{
var outputDict = new Dictionary<string, IReadOnlyDictionary<string, string>>();
// try to load a shortcut file
if (File.Exists(_shortcutResponsesFile))
{
if (TryLoadResponsesFromFile(_shortcutResponsesFile, out var dict, out _))
{
outputDict["en-us"] = dict;
}
return outputDict;
}
if (!Directory.Exists(_localizableResponsesPath))
return outputDict;
// if shortcut file doesn't exist, try to load localizable files
foreach (var file in Directory.GetFiles(_localizableResponsesPath))
{
if (TryLoadResponsesFromFile(file, out var strings, out var localeName) && localeName is not null)
{
outputDict[localeName.ToLowerInvariant()] = strings;
}
}
return outputDict;
}
private static bool TryLoadResponsesFromFile(string file,
[NotNullWhen(true)] out IReadOnlyDictionary<string, string>? strings,
out string? localeName)
{
try
{
strings = _deserializer.Deserialize<Dictionary<string, string>?>(File.ReadAllText(file));
if (strings is null)
{
localeName = null;
return false;
}
localeName = GetLocaleName(file).ToLowerInvariant();
return true;
}
catch (Exception ex)
{
Log.Error(ex, "Error loading {FileName} response strings: {ErrorMessage}", file, ex.Message);
strings = null;
localeName = null;
return false;
}
}
private static string GetLocaleName(string fileName)
=> Path.GetFileNameWithoutExtension(fileName);
}