diff --git a/src/Ellie.Bot.Common/Abstractions/creds/IBotCredentials.cs b/src/Ellie.Bot.Common/Abstractions/creds/IBotCredentials.cs new file mode 100644 index 0000000..19c5b52 --- /dev/null +++ b/src/Ellie.Bot.Common/Abstractions/creds/IBotCredentials.cs @@ -0,0 +1,78 @@ +#nullable disable +namespace Ellie; + +public interface IBotCredentials +{ + string Token { get; } + string GoogleApiKey { get; } + ICollection OwnerIds { get; set; } + bool UsePrivilegedIntents { get; } + string RapidApiKey { get; } + + Creds.DbOptions Db { get; } + string OsuApiKey { get; } + int TotalShards { get; } + Creds.PatreonSettings Patreon { get; } + string CleverbotApiKey { get; } + string Gpt3ApiKey { get; } + RestartConfig RestartCommand { get; } + Creds.VotesSettings Votes { get; } + string BotListToken { get; } + string RedisOptions { get; } + string LocationIqApiKey { get; } + string TimezoneDbApiKey { get; } + string CoinmarketcapApiKey { get; } + string TrovoClientId { get; } + string CoordinatorUrl { get; set; } + string TwitchClientId { get; set; } + string TwitchClientSecret { get; set; } + GoogleApiConfig Google { get; set; } + BotCacheImplemenation BotCache { get; set; } +} + +public interface IVotesSettings +{ + string TopggServiceUrl { get; set; } + string TopggKey { get; set; } + string DiscordsServiceUrl { get; set; } + string DiscordsKey { get; set; } +} + +public interface IPatreonSettings +{ + public string ClientId { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string ClientSecret { get; set; } + public string CampaignId { get; set; } +} + +public interface IRestartConfig +{ + string Cmd { get; set; } + string Args { get; set; } +} + +public class RestartConfig : IRestartConfig +{ + public string Cmd { get; set; } + public string Args { get; set; } +} + +public enum BotCacheImplemenation +{ + Memory, + Redis +} + +public interface IDbOptions +{ + string Type { get; set; } + string ConnectionString { get; set; } +} + +public interface IGoogleApiConfig +{ + string SearchId { get; init; } + string ImageSearchId { get; init; } +} diff --git a/src/Ellie.Bot.Common/Abstractions/creds/IBotCredsProvider.cs b/src/Ellie.Bot.Common/Abstractions/creds/IBotCredsProvider.cs new file mode 100644 index 0000000..a37ad61 --- /dev/null +++ b/src/Ellie.Bot.Common/Abstractions/creds/IBotCredsProvider.cs @@ -0,0 +1,8 @@ +namespace Ellie; + +public interface IBotCredsProvider +{ + public void Reload(); + public IBotCredentials GetCreds(); + public void ModifyCredsFile(Action func); +} diff --git a/src/Ellie.Bot.Common/Abstractions/strings/CommandStrings.cs b/src/Ellie.Bot.Common/Abstractions/strings/CommandStrings.cs new file mode 100644 index 0000000..5d25b5f --- /dev/null +++ b/src/Ellie.Bot.Common/Abstractions/strings/CommandStrings.cs @@ -0,0 +1,13 @@ +#nullable disable +using YamlDotNet.Serialization; + +namespace Ellie.Services; + +public sealed class CommandStrings +{ + [YamlMember(Alias = "desc")] + public string Desc { get; set; } + + [YamlMember(Alias = "args")] + public string[] Args { get; set; } +} diff --git a/src/Ellie.Bot.Common/Abstractions/strings/IBotStrings.cs b/src/Ellie.Bot.Common/Abstractions/strings/IBotStrings.cs new file mode 100644 index 0000000..76e280a --- /dev/null +++ b/src/Ellie.Bot.Common/Abstractions/strings/IBotStrings.cs @@ -0,0 +1,16 @@ +#nullable disable +using System.Globalization; + +namespace Ellie.Common; + +/// +/// Defines methods to retrieve and reload bot strings +/// +public interface IBotStrings +{ + string GetText(string key, ulong? guildId = null, params object[] data); + string GetText(string key, CultureInfo locale, params object[] data); + void Reload(); + CommandStrings GetCommandStrings(string commandName, ulong? guildId = null); + CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo); +} diff --git a/src/Ellie.Bot.Common/Abstractions/strings/IBotStringsExtensions.cs b/src/Ellie.Bot.Common/Abstractions/strings/IBotStringsExtensions.cs new file mode 100644 index 0000000..b0e0ab8 --- /dev/null +++ b/src/Ellie.Bot.Common/Abstractions/strings/IBotStringsExtensions.cs @@ -0,0 +1,17 @@ +#nullable disable +using System.Globalization; + +namespace Ellie.Common; + +public static class BotStringsExtensions +{ + // this one is for pipe fun, see PipeExtensions.cs + public static string GetText(this IBotStrings strings, in LocStr str, in ulong guildId) + => strings.GetText(str.Key, guildId, str.Params); + + public static string GetText(this IBotStrings strings, in LocStr str, ulong? guildId = null) + => strings.GetText(str.Key, guildId, str.Params); + + public static string GetText(this IBotStrings strings, in LocStr str, CultureInfo culture) + => strings.GetText(str.Key, culture, str.Params); +} diff --git a/src/Ellie.Bot.Common/Abstractions/strings/IBotStringsProvider.cs b/src/Ellie.Bot.Common/Abstractions/strings/IBotStringsProvider.cs new file mode 100644 index 0000000..88181e1 --- /dev/null +++ b/src/Ellie.Bot.Common/Abstractions/strings/IBotStringsProvider.cs @@ -0,0 +1,28 @@ +#nullable disable +namespace Ellie.Services; + +/// +/// Implemented by classes which provide localized strings in their own ways +/// +public interface IBotStringsProvider +{ + /// + /// Gets localized string + /// + /// Language name + /// String key + /// Localized string + string GetText(string localeName, string key); + + /// + /// Reloads string cache + /// + void Reload(); + + /// + /// Gets command arg examples and description + /// + /// Language name + /// Command name + CommandStrings GetCommandStrings(string localeName, string commandName); +} diff --git a/src/Ellie.Bot.Common/Abstractions/strings/IStringSource.cs b/src/Ellie.Bot.Common/Abstractions/strings/IStringSource.cs new file mode 100644 index 0000000..e163bdf --- /dev/null +++ b/src/Ellie.Bot.Common/Abstractions/strings/IStringSource.cs @@ -0,0 +1,17 @@ +#nullable disable + +namespace Ellie.Services; + +/// +/// Basic interface used for classes implementing strings loading mechanism +/// +public interface IStringsSource +{ + /// + /// Gets all response strings + /// + /// Dictionary(localename, Dictionary(key, response)) + Dictionary> GetResponseStrings(); + + Dictionary> GetCommandStrings(); +} diff --git a/src/Ellie.Bot.Common/Abstractions/strings/LocStr.cs b/src/Ellie.Bot.Common/Abstractions/strings/LocStr.cs new file mode 100644 index 0000000..2d737c0 --- /dev/null +++ b/src/Ellie.Bot.Common/Abstractions/strings/LocStr.cs @@ -0,0 +1,13 @@ +namespace Ellie; + +public readonly struct LocStr +{ + public readonly string Key; + public readonly object[] Params; + + public LocStr(string key, params object[] data) + { + Key = key; + Params = data; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Attributes/AliasesAttribute.cs b/src/Ellie.Bot.Common/Attributes/AliasesAttribute.cs new file mode 100644 index 0000000..8745c7e --- /dev/null +++ b/src/Ellie.Bot.Common/Attributes/AliasesAttribute.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace Ellie.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class AliasesAttribute : AliasAttribute +{ + public AliasesAttribute([CallerMemberName] string memberName = "") + : base(CommandNameLoadHelper.GetAliasesFor(memberName)) + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Attributes/CmdAttribute.cs b/src/Ellie.Bot.Common/Attributes/CmdAttribute.cs new file mode 100644 index 0000000..4e60afe --- /dev/null +++ b/src/Ellie.Bot.Common/Attributes/CmdAttribute.cs @@ -0,0 +1,18 @@ +using System.Runtime.CompilerServices; + +namespace Ellie.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class CmdAttribute : CommandAttribute +{ + public string MethodName { get; } + + public CmdAttribute([CallerMemberName] string memberName = "") + : base(CommandNameLoadHelper.GetCommandNameFor(memberName)) + { + MethodName = memberName.ToLowerInvariant(); + Aliases = CommandNameLoadHelper.GetAliasesFor(memberName); + Remarks = memberName.ToLowerInvariant(); + Summary = memberName.ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Attributes/DIIgnoreAttribute.cs b/src/Ellie.Bot.Common/Attributes/DIIgnoreAttribute.cs new file mode 100644 index 0000000..2a24a1d --- /dev/null +++ b/src/Ellie.Bot.Common/Attributes/DIIgnoreAttribute.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace Ellie.Common; + +/// +/// Classed marked with this attribute will not be added to the service provider +/// +[AttributeUsage(AttributeTargets.Class)] +public class DIIgnoreAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Attributes/EllieOptionsAttribute.cs b/src/Ellie.Bot.Common/Attributes/EllieOptionsAttribute.cs new file mode 100644 index 0000000..49e39c4 --- /dev/null +++ b/src/Ellie.Bot.Common/Attributes/EllieOptionsAttribute.cs @@ -0,0 +1,7 @@ +namespace Ellie.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class NadekoOptionsAttribute : Attribute + where TOption: IEllieCommandOptions +{ +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Attributes/NoPublicBotAttribute.cs b/src/Ellie.Bot.Common/Attributes/NoPublicBotAttribute.cs new file mode 100644 index 0000000..d1a0b75 --- /dev/null +++ b/src/Ellie.Bot.Common/Attributes/NoPublicBotAttribute.cs @@ -0,0 +1,21 @@ +#nullable disable +using System.Diagnostics.CodeAnalysis; + +namespace Ellie.Common; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +[SuppressMessage("Style", "IDE0022:Use expression body for methods")] +public sealed class NoPublicBotAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { +#if GLOBAL_ELLIE + return Task.FromResult(PreconditionResult.FromError("Not available on the public bot. To learn how to selfhost a private bot, click [here](https://docs.elliebot.net).")); +#else + return Task.FromResult(PreconditionResult.FromSuccess()); +#endif + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Attributes/OnlyPublicBotAttribute.cs b/src/Ellie.Bot.Common/Attributes/OnlyPublicBotAttribute.cs new file mode 100644 index 0000000..ad0192c --- /dev/null +++ b/src/Ellie.Bot.Common/Attributes/OnlyPublicBotAttribute.cs @@ -0,0 +1,21 @@ +#nullable disable +using System.Diagnostics.CodeAnalysis; + +namespace Ellie.Common; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +[SuppressMessage("Style", "IDE0022:Use expression body for methods")] +public sealed class OnlyPublicBotAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { +#if GLOBAL_NADEKO || DEBUG + return Task.FromResult(PreconditionResult.FromSuccess()); +#else + return Task.FromResult(PreconditionResult.FromError("Only available on the public bot.")); +#endif + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Attributes/OwnerOnlyAttribute.cs b/src/Ellie.Bot.Common/Attributes/OwnerOnlyAttribute.cs new file mode 100644 index 0000000..007238e --- /dev/null +++ b/src/Ellie.Bot.Common/Attributes/OwnerOnlyAttribute.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Ellie.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class OwnerOnlyAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var creds = services.GetRequiredService().GetCreds(); + + return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id + ? PreconditionResult.FromSuccess() + : PreconditionResult.FromError("Not owner")); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Attributes/RatelimitAttribute.cs b/src/Ellie.Bot.Common/Attributes/RatelimitAttribute.cs new file mode 100644 index 0000000..cfd5a08 --- /dev/null +++ b/src/Ellie.Bot.Common/Attributes/RatelimitAttribute.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Ellie.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class RatelimitAttribute : PreconditionAttribute +{ + public int Seconds { get; } + + public RatelimitAttribute(int seconds) + { + if (seconds <= 0) + throw new ArgumentOutOfRangeException(nameof(seconds)); + + Seconds = seconds; + } + + public override async Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + if (Seconds == 0) + return PreconditionResult.FromSuccess(); + + var cache = services.GetRequiredService(); + var rem = await cache.GetRatelimitAsync( + new($"precondition:{context.User.Id}:{command.Name}"), + Seconds.Seconds()); + + if (rem is null) + return PreconditionResult.FromSuccess(); + + var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s."; + + return PreconditionResult.FromError(msgContent); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Attributes/UserPermAttribute.cs b/src/Ellie.Bot.Common/Attributes/UserPermAttribute.cs new file mode 100644 index 0000000..2e0af03 --- /dev/null +++ b/src/Ellie.Bot.Common/Attributes/UserPermAttribute.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Discord; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class UserPermAttribute : RequireUserPermissionAttribute +{ + public UserPermAttribute(GuildPerm permission) + : base(permission) + { + } + + public UserPermAttribute(ChannelPerm permission) + : base(permission) + { + } + + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var permService = services.GetRequiredService(); + if (permService.TryGetOverrides(context.Guild?.Id ?? 0, command.Name.ToUpperInvariant(), out _)) + return Task.FromResult(PreconditionResult.FromSuccess()); + + return base.CheckPermissionsAsync(context, command, services); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/BotCommandTypeReader.cs b/src/Ellie.Bot.Common/BotCommandTypeReader.cs new file mode 100644 index 0000000..d4f70d1 --- /dev/null +++ b/src/Ellie.Bot.Common/BotCommandTypeReader.cs @@ -0,0 +1,30 @@ +#nullable disable +namespace Ellie.Common.TypeReaders; + +public sealed class CommandTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + private readonly ICommandHandler _handler; + + public CommandTypeReader(ICommandHandler handler, CommandService cmds) + { + _handler = handler; + _cmds = cmds; + } + + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + { + input = input.ToUpperInvariant(); + var prefix = _handler.GetPrefix(ctx.Guild); + if (!input.StartsWith(prefix.ToUpperInvariant(), StringComparison.InvariantCulture)) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such command found.")); + + input = input[prefix.Length..]; + + var cmd = _cmds.Commands.FirstOrDefault(c => c.Aliases.Select(a => a.ToUpperInvariant()).Contains(input)); + if (cmd is null) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such command found.")); + + return new(TypeReaderResult.FromSuccess(cmd)); + } +} diff --git a/src/Ellie.Bot.Common/CleaverBotResponseStr.cs b/src/Ellie.Bot.Common/CleaverBotResponseStr.cs new file mode 100644 index 0000000..95b0a14 --- /dev/null +++ b/src/Ellie.Bot.Common/CleaverBotResponseStr.cs @@ -0,0 +1,10 @@ +#nullable disable +using System.Runtime.InteropServices; + +namespace Ellie.Modules.Permissions; + +[StructLayout(LayoutKind.Sequential, Size = 1)] +public readonly struct CleverBotResponseStr +{ + public const string CLEVERBOT_RESPONSE = "cleverbot:response"; +} diff --git a/src/Ellie.Bot.Common/CommandNameLoadHelper.cs b/src/Ellie.Bot.Common/CommandNameLoadHelper.cs new file mode 100644 index 0000000..b963de6 --- /dev/null +++ b/src/Ellie.Bot.Common/CommandNameLoadHelper.cs @@ -0,0 +1,31 @@ +using YamlDotNet.Serialization; + +namespace Ellie.Common.Attributes; + +public static class CommandNameLoadHelper +{ + private static readonly IDeserializer _deserializer = new Deserializer(); + + private static readonly Lazy> _lazyCommandAliases + = new(() => LoadAliases()); + + public static Dictionary LoadAliases(string aliasesFilePath = "data/aliases.yml") + { + var text = File.ReadAllText(aliasesFilePath); + return _deserializer.Deserialize>(text); + } + + public static string[] GetAliasesFor(string methodName) + => _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1 + ? aliases.Skip(1).ToArray() + : Array.Empty(); + + public static string GetCommandNameFor(string methodName) + { + methodName = methodName.ToLowerInvariant(); + var toReturn = _lazyCommandAliases.Value.TryGetValue(methodName, out var aliases) && aliases.Length > 0 + ? aliases[0] + : methodName; + return toReturn; + } +} diff --git a/src/Ellie.Bot.Common/Common/AddRemove.cs b/src/Ellie.Bot.Common/Common/AddRemove.cs new file mode 100644 index 0000000..4d1dd30 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/AddRemove.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace Ellie.Common; + +public enum AddRemove +{ + Add = int.MinValue, + Remove = int.MinValue + 1, + Rem = int.MinValue + 1, + Rm = int.MinValue + 1 +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/CmdStrings.cs b/src/Ellie.Bot.Common/Common/CmdStrings.cs new file mode 100644 index 0000000..a2fef95 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/CmdStrings.cs @@ -0,0 +1,17 @@ +#nullable disable +using Newtonsoft.Json; + +namespace Ellie.Common; + +public class CmdStrings +{ + public string[] Usages { get; } + public string Description { get; } + + [JsonConstructor] + public CmdStrings([JsonProperty("args")] string[] usages, [JsonProperty("desc")] string description) + { + Usages = usages; + Description = description; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/CommandData.cs b/src/Ellie.Bot.Common/Common/CommandData.cs new file mode 100644 index 0000000..a1dbe8d --- /dev/null +++ b/src/Ellie.Bot.Common/Common/CommandData.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace Ellie.Common; + +public class CommandData +{ + public string Cmd { get; set; } + public string Desc { get; set; } + public string[] Usage { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/DownloadTracker.cs b/src/Ellie.Bot.Common/Common/DownloadTracker.cs new file mode 100644 index 0000000..239abe1 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/DownloadTracker.cs @@ -0,0 +1,38 @@ +#nullable disable +namespace Ellie.Common; + +public class DownloadTracker : IEService +{ + private ConcurrentDictionary LastDownloads { get; } = new(); + private readonly SemaphoreSlim _downloadUsersSemaphore = new(1, 1); + + /// + /// Ensures all users on the specified guild were downloaded within the last hour. + /// + /// Guild to check and potentially download users from + /// Task representing download state + public async Task EnsureUsersDownloadedAsync(IGuild guild) + { +#if GLOBAL_NADEKO + return; +#endif + await _downloadUsersSemaphore.WaitAsync(); + try + { + var now = DateTime.UtcNow; + + // download once per hour at most + var added = LastDownloads.AddOrUpdate(guild.Id, + now, + (_, old) => now - old > TimeSpan.FromHours(1) ? now : old); + + // means that this entry was just added - download the users + if (added == now) + await guild.DownloadUsersAsync(); + } + finally + { + _downloadUsersSemaphore.Release(); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/Helpers.cs b/src/Ellie.Bot.Common/Common/Helpers.cs new file mode 100644 index 0000000..797484d --- /dev/null +++ b/src/Ellie.Bot.Common/Common/Helpers.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace Ellie.Common; + +public static class Helpers +{ + public static void ReadErrorAndExit(int exitCode) + { + if (!Console.IsInputRedirected) + Console.ReadKey(); + + Environment.Exit(exitCode); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/ImageUrls.cs b/src/Ellie.Bot.Common/Common/ImageUrls.cs new file mode 100644 index 0000000..402f11b --- /dev/null +++ b/src/Ellie.Bot.Common/Common/ImageUrls.cs @@ -0,0 +1,51 @@ +#nullable disable +using Ellie.Common.Yml; +using Cloneable; + +namespace Ellie.Common; + +[Cloneable] +public partial class ImageUrls : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 3; + + public CoinData Coins { get; set; } + public Uri[] Currency { get; set; } + public Uri[] Dice { get; set; } + public RategirlData Rategirl { get; set; } + public XpData Xp { get; set; } + + //new + public RipData Rip { get; set; } + public SlotData Slots { get; set; } + + public class RipData + { + public Uri Bg { get; set; } + public Uri Overlay { get; set; } + } + + public class SlotData + { + public Uri[] Emojis { get; set; } + public Uri Bg { get; set; } + } + + public class CoinData + { + public Uri[] Heads { get; set; } + public Uri[] Tails { get; set; } + } + + public class RategirlData + { + public Uri Matrix { get; set; } + public Uri Dot { get; set; } + } + + public class XpData + { + public Uri Bg { get; set; } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/JsonConverters/CultureInfoConverter.cs b/src/Ellie.Bot.Common/Common/JsonConverters/CultureInfoConverter.cs new file mode 100644 index 0000000..cda7dfe --- /dev/null +++ b/src/Ellie.Bot.Common/Common/JsonConverters/CultureInfoConverter.cs @@ -0,0 +1,14 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ellie.Common.JsonConverters; + +public class CultureInfoConverter : JsonConverter +{ + public override CultureInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new(reader.GetString() ?? "en-US"); + + public override void Write(Utf8JsonWriter writer, CultureInfo value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Name); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/JsonConverters/Rgba32Converter.cs b/src/Ellie.Bot.Common/Common/JsonConverters/Rgba32Converter.cs new file mode 100644 index 0000000..959a0e6 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/JsonConverters/Rgba32Converter.cs @@ -0,0 +1,14 @@ +using SixLabors.ImageSharp.PixelFormats; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ellie.Common.JsonConverters; + +public class Rgba32Converter : JsonConverter +{ + public override Rgba32 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Rgba32.ParseHex(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, Rgba32 value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToHex()); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/LbOpts.cs b/src/Ellie.Bot.Common/Common/LbOpts.cs new file mode 100644 index 0000000..dca77cd --- /dev/null +++ b/src/Ellie.Bot.Common/Common/LbOpts.cs @@ -0,0 +1,14 @@ +#nullable disable +using CommandLine; + +namespace Ellie.Common; + +public class LbOpts : IEllieCommandOptions +{ + [Option('c', "clean", Default = false, HelpText = "Only show users who are on the server.")] + public bool Clean { get; set; } + + public void NormalizeOptions() + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/Linq2DbExpressions.cs b/src/Ellie.Bot.Common/Common/Linq2DbExpressions.cs new file mode 100644 index 0000000..d066440 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/Linq2DbExpressions.cs @@ -0,0 +1,16 @@ +#nullable disable +using LinqToDB; +using System.Linq.Expressions; + +namespace Ellie.Common; + +public static class Linq2DbExpressions +{ + [ExpressionMethod(nameof(GuildOnShardExpression))] + public static bool GuildOnShard(ulong guildId, int totalShards, int shardId) + => throw new NotSupportedException(); + + private static Expression> GuildOnShardExpression() + => (guildId, totalShards, shardId) + => guildId / 4194304 % (ulong)totalShards == (ulong)shardId; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/LoginErrorHandler.cs b/src/Ellie.Bot.Common/Common/LoginErrorHandler.cs new file mode 100644 index 0000000..8aafc38 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/LoginErrorHandler.cs @@ -0,0 +1,52 @@ +#nullable disable +using System.Net; +using System.Runtime.CompilerServices; + +namespace Ellie.Common; + +public class LoginErrorHandler +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Handle(Exception ex) + => Log.Fatal(ex, "A fatal error has occurred while attempting to connect to Discord"); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Handle(HttpException ex) + { + switch (ex.HttpCode) + { + case HttpStatusCode.Unauthorized: + Log.Error("Your bot token is wrong.\n" + + "You can find the bot token under the Bot tab in the developer page.\n" + + "Fix your token in the credentials file and restart the bot"); + break; + + case HttpStatusCode.BadRequest: + Log.Error("Something has been incorrectly formatted in your credentials file.\n" + + "Use the JSON Guide as reference to fix it and restart the bot"); + Log.Error("If you are on Linux, make sure Redis is installed and running"); + break; + + case HttpStatusCode.RequestTimeout: + Log.Error("The request timed out. Make sure you have no external program blocking the bot " + + "from connecting to the internet"); + break; + + case HttpStatusCode.ServiceUnavailable: + case HttpStatusCode.InternalServerError: + Log.Error("Discord is having internal issues. Please, try again later"); + break; + + case HttpStatusCode.TooManyRequests: + Log.Error("Your bot has been ratelimited by Discord. Please, try again later.\n" + + "Global ratelimits usually last for an hour"); + break; + + default: + Log.Warning("An error occurred while attempting to connect to Discord"); + break; + } + + Log.Fatal(ex, "Fatal error occurred while loading credentials"); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/OldCreds.cs b/src/Ellie.Bot.Common/Common/OldCreds.cs new file mode 100644 index 0000000..519d10e --- /dev/null +++ b/src/Ellie.Bot.Common/Common/OldCreds.cs @@ -0,0 +1,46 @@ +#nullable disable +namespace Ellie.Common; + +public class OldCreds +{ + public string Token { get; set; } = string.Empty; + public ulong[] OwnerIds { get; set; } = new ulong[1]; + public string LoLApiKey { get; set; } = string.Empty; + public string GoogleApiKey { get; set; } = string.Empty; + public string MashapeKey { get; set; } = string.Empty; + public string OsuApiKey { get; set; } = string.Empty; + public string SoundCloudClientId { get; set; } = string.Empty; + public string CleverbotApiKey { get; set; } = string.Empty; + public string CarbonKey { get; set; } = string.Empty; + public int TotalShards { get; set; } = 1; + public string PatreonAccessToken { get; set; } = string.Empty; + public string PatreonCampaignId { get; set; } = "334038"; + public RestartConfig RestartCommand { get; set; } + + public string ShardRunCommand { get; set; } = string.Empty; + public string ShardRunArguments { get; set; } = string.Empty; + public int? ShardRunPort { get; set; } + public string MiningProxyUrl { get; set; } = string.Empty; + public string MiningProxyCreds { get; set; } = string.Empty; + + public string BotListToken { get; set; } = string.Empty; + public string TwitchClientId { get; set; } = string.Empty; + public string VotesToken { get; set; } = string.Empty; + public string VotesUrl { get; set; } = string.Empty; + public string RedisOptions { get; set; } = string.Empty; + public string LocationIqApiKey { get; set; } = string.Empty; + public string TimezoneDbApiKey { get; set; } = string.Empty; + public string CoinmarketcapApiKey { get; set; } = string.Empty; + + public class RestartConfig + { + public string Cmd { get; set; } + public string Args { get; set; } + + public RestartConfig(string cmd, string args) + { + Cmd = cmd; + Args = args; + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/OptionsParser.cs b/src/Ellie.Bot.Common/Common/OptionsParser.cs new file mode 100644 index 0000000..e4263f3 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/OptionsParser.cs @@ -0,0 +1,23 @@ +using CommandLine; + +namespace Ellie.Common; + +public static class OptionsParser +{ + public static T ParseFrom(string[]? args) + where T : IEllieCommandOptions, new() + => ParseFrom(new T(), args).Item1; + + public static (T, bool) ParseFrom(T options, string[]? args) + where T : IEllieCommandOptions + { + using var p = new Parser(x => + { + x.HelpWriter = null; + }); + var res = p.ParseArguments(args); + var output = res.MapResult(x => x, _ => options); + output.NormalizeOptions(); + return (output, res.Tag == ParserResultType.Parsed); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/OsuMapData.cs b/src/Ellie.Bot.Common/Common/OsuMapData.cs new file mode 100644 index 0000000..ebf9367 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/OsuMapData.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace Ellie.Common; + +public class OsuMapData +{ + public string Title { get; set; } + public string Artist { get; set; } + public string Version { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/OsuUserBets.cs b/src/Ellie.Bot.Common/Common/OsuUserBets.cs new file mode 100644 index 0000000..67898fc --- /dev/null +++ b/src/Ellie.Bot.Common/Common/OsuUserBets.cs @@ -0,0 +1,58 @@ +#nullable disable +using Newtonsoft.Json; + +namespace Ellie.Common; + +public class OsuUserBests +{ + [JsonProperty("beatmap_id")] + public string BeatmapId { get; set; } + + [JsonProperty("score_id")] + public string ScoreId { get; set; } + + [JsonProperty("score")] + public string Score { get; set; } + + [JsonProperty("maxcombo")] + public string Maxcombo { get; set; } + + [JsonProperty("count50")] + public double Count50 { get; set; } + + [JsonProperty("count100")] + public double Count100 { get; set; } + + [JsonProperty("count300")] + public double Count300 { get; set; } + + [JsonProperty("countmiss")] + public int Countmiss { get; set; } + + [JsonProperty("countkatu")] + public double Countkatu { get; set; } + + [JsonProperty("countgeki")] + public double Countgeki { get; set; } + + [JsonProperty("perfect")] + public string Perfect { get; set; } + + [JsonProperty("enabled_mods")] + public int EnabledMods { get; set; } + + [JsonProperty("user_id")] + public string UserId { get; set; } + + [JsonProperty("date")] + public string Date { get; set; } + + [JsonProperty("rank")] + public string Rank { get; set; } + + [JsonProperty("pp")] + public double Pp { get; set; } + + [JsonProperty("replay_available")] + public string ReplayAvailable { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/Pokemon/PokemonNameId.cs b/src/Ellie.Bot.Common/Common/Pokemon/PokemonNameId.cs new file mode 100644 index 0000000..13e8360 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/Pokemon/PokemonNameId.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace Ellie.Common.Pokemon; + +public class PokemonNameId +{ + public int Id { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/Pokemon/SearchPokemon.cs b/src/Ellie.Bot.Common/Common/Pokemon/SearchPokemon.cs new file mode 100644 index 0000000..eb861b3 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/Pokemon/SearchPokemon.cs @@ -0,0 +1,42 @@ +#nullable disable + +using System.Text.Json.Serialization; + +namespace Ellie.Common.Pokemon; + +public class SearchPokemon +{ + [JsonPropertyName("num")] + public int Id { get; set; } + + public string Species { get; set; } + public string[] Types { get; set; } + public GenderRatioClass GenderRatio { get; set; } + public BaseStatsClass BaseStats { get; set; } + public Dictionary Abilities { get; set; } + public float HeightM { get; set; } + public float WeightKg { get; set; } + public string Color { get; set; } + public string[] Evos { get; set; } + public string[] EggGroups { get; set; } + + public class GenderRatioClass + { + public float M { get; set; } + public float F { get; set; } + } + + public class BaseStatsClass + { + public int Hp { get; set; } + public int Atk { get; set; } + public int Def { get; set; } + public int Spa { get; set; } + public int Spd { get; set; } + public int Spe { get; set; } + + public override string ToString() + => $@"💚**HP:** {Hp,-4} ⚔**ATK:** {Atk,-4} 🛡**DEF:** {Def,-4} +✨**SPA:** {Spa,-4} 🎇**SPD:** {Spd,-4} 💨**SPE:** {Spe,-4}"; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/Pokemon/SearchPokemonAbility.cs b/src/Ellie.Bot.Common/Common/Pokemon/SearchPokemonAbility.cs new file mode 100644 index 0000000..c0d79be --- /dev/null +++ b/src/Ellie.Bot.Common/Common/Pokemon/SearchPokemonAbility.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace Ellie.Common.Pokemon; + +public class SearchPokemonAbility +{ + public string Desc { get; set; } + public string ShortDesc { get; set; } + public string Name { get; set; } + public float Rating { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/RequireObjectPropertiesContractResolver.cs b/src/Ellie.Bot.Common/Common/RequireObjectPropertiesContractResolver.cs new file mode 100644 index 0000000..e187198 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/RequireObjectPropertiesContractResolver.cs @@ -0,0 +1,15 @@ +#nullable disable +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Ellie.Common; + +public class RequireObjectPropertiesContractResolver : DefaultContractResolver +{ + protected override JsonObjectContract CreateObjectContract(Type objectType) + { + var contract = base.CreateObjectContract(objectType); + contract.ItemRequired = Required.DisallowNull; + return contract; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TriviaQuestionModel.cs b/src/Ellie.Bot.Common/Common/TriviaQuestionModel.cs new file mode 100644 index 0000000..3568706 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TriviaQuestionModel.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace Ellie.Modules.Games.Common.Trivia; + +public sealed class TriviaQuestionModel +{ + public string Category { get; init; } + public string Question { get; init; } + public string ImageUrl { get; init; } + public string AnswerImageUrl { get; init; } + public string Answer { get; init; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/EmoteTypeReader.cs b/src/Ellie.Bot.Common/Common/TypeReaders/EmoteTypeReader.cs new file mode 100644 index 0000000..0df3344 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/EmoteTypeReader.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace Ellie.Common.TypeReaders; + +public sealed class EmoteTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (!Emote.TryParse(input, out var emote)) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "Input is not a valid emote")); + + return new(TypeReaderResult.FromSuccess(emote)); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/GuildTypeReader.cs b/src/Ellie.Bot.Common/Common/TypeReaders/GuildTypeReader.cs new file mode 100644 index 0000000..deafde2 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/GuildTypeReader.cs @@ -0,0 +1,24 @@ +#nullable disable +namespace Ellie.Common.TypeReaders; + +public sealed class GuildTypeReader : EllieTypeReader +{ + private readonly DiscordSocketClient _client; + + public GuildTypeReader(DiscordSocketClient client) + => _client = client; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.Trim().ToUpperInvariant(); + var guilds = _client.Guilds; + IGuild guild = guilds.FirstOrDefault(g => g.Id.ToString().Trim().ToUpperInvariant() == input) //by id + ?? guilds.FirstOrDefault(g => g.Name.Trim().ToUpperInvariant() == input); //by name + + if (guild is not null) + return new(TypeReaderResult.FromSuccess(guild)); + + return new( + TypeReaderResult.FromError(CommandError.ParseFailed, "No guild by that name or Id found")); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/GuildUserTypeReader.cs b/src/Ellie.Bot.Common/Common/TypeReaders/GuildUserTypeReader.cs new file mode 100644 index 0000000..2dcf5ed --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/GuildUserTypeReader.cs @@ -0,0 +1,33 @@ +namespace Ellie.Common.TypeReaders; + +public sealed class GuildUserTypeReader : EllieTypeReader +{ + public override async ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (ctx.Guild is null) + return TypeReaderResult.FromError(CommandError.Unsuccessful, "Must be in a guild."); + + input = input.Trim(); + IGuildUser? user = null; + if (MentionUtils.TryParseUser(input, out var id)) + user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload); + + if (ulong.TryParse(input, out id)) + user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload); + + if (user is null) + { + var users = await ctx.Guild.GetUsersAsync(CacheMode.CacheOnly); + user = users.FirstOrDefault(x => x.Username == input) + ?? users.FirstOrDefault(x => + string.Equals(x.ToString(), input, StringComparison.InvariantCultureIgnoreCase)) + ?? users.FirstOrDefault(x => + string.Equals(x.Username, input, StringComparison.InvariantCultureIgnoreCase)); + } + + if (user is null) + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "User not found."); + + return TypeReaderResult.FromSuccess(user); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/KwumTypeReader.cs b/src/Ellie.Bot.Common/Common/TypeReaders/KwumTypeReader.cs new file mode 100644 index 0000000..6ddab55 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/KwumTypeReader.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace Ellie.Common.TypeReaders; + +public sealed class KwumTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + if (kwum.TryParse(input, out var val)) + return new(TypeReaderResult.FromSuccess(val)); + + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "Input is not a valid kwum")); + } +} + +public sealed class SmartTextTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + => new(TypeReaderResult.FromSuccess(SmartText.CreateFrom(input))); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/Models/PermissionAction.cs b/src/Ellie.Bot.Common/Common/TypeReaders/Models/PermissionAction.cs new file mode 100644 index 0000000..22736ed --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/Models/PermissionAction.cs @@ -0,0 +1,27 @@ +#nullable disable +namespace Ellie.Common.TypeReaders.Models; + +public class PermissionAction +{ + public static PermissionAction Enable + => new(true); + + public static PermissionAction Disable + => new(false); + + public bool Value { get; } + + public PermissionAction(bool value) + => Value = value; + + public override bool Equals(object obj) + { + if (obj is null || GetType() != obj.GetType()) + return false; + + return Value == ((PermissionAction)obj).Value; + } + + public override int GetHashCode() + => Value.GetHashCode(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/Models/StoopidTime.cs b/src/Ellie.Bot.Common/Common/TypeReaders/Models/StoopidTime.cs new file mode 100644 index 0000000..555fe64 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/Models/StoopidTime.cs @@ -0,0 +1,55 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace Ellie.Common.TypeReaders.Models; + +public class StoopidTime +{ + private static readonly Regex _regex = new( + @"^(?:(?\d)mo)?(?:(?\d{1,2})w)?(?:(?\d{1,2})d)?(?:(?\d{1,4})h)?(?:(?\d{1,5})m)?(?:(?\d{1,6})s)?$", + RegexOptions.Compiled | RegexOptions.Multiline); + + public string Input { get; set; } + public TimeSpan Time { get; set; } + + private StoopidTime() { } + + public static StoopidTime FromInput(string input) + { + var m = _regex.Match(input); + + if (m.Length == 0) + throw new ArgumentException("Invalid string input format."); + + var namesAndValues = new Dictionary(); + + foreach (var groupName in _regex.GetGroupNames()) + { + if (groupName == "0") + continue; + if (!int.TryParse(m.Groups[groupName].Value, out var value)) + { + namesAndValues[groupName] = 0; + continue; + } + + if (value < 1) + throw new ArgumentException($"Invalid {groupName} value."); + + namesAndValues[groupName] = value; + } + + var ts = new TimeSpan((30 * namesAndValues["months"]) + (7 * namesAndValues["weeks"]) + namesAndValues["days"], + namesAndValues["hours"], + namesAndValues["minutes"], + namesAndValues["seconds"]); + if (ts > TimeSpan.FromDays(90)) + throw new ArgumentException("Time is too long."); + + return new() + { + Input = input, + Time = ts + }; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/ModuleTypeReader.cs b/src/Ellie.Bot.Common/Common/TypeReaders/ModuleTypeReader.cs new file mode 100644 index 0000000..74de6e3 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/ModuleTypeReader.cs @@ -0,0 +1,50 @@ +#nullable disable +namespace Ellie.Common.TypeReaders; + +public sealed class ModuleTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + + public ModuleTypeReader(CommandService cmds) + => _cmds = cmds; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.ToUpperInvariant(); + var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule()) + .FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input) + ?.Key; + if (module is null) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such module found.")); + + return new(TypeReaderResult.FromSuccess(module)); + } +} + +public sealed class ModuleOrCrTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + + public ModuleOrCrTypeReader(CommandService cmds) + => _cmds = cmds; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.ToUpperInvariant(); + var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule()) + .FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input) + ?.Key; + if (module is null && input != "ACTUALEXPRESSIONS") + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such module found.")); + + return new(TypeReaderResult.FromSuccess(new ModuleOrCrInfo + { + Name = input + })); + } +} + +public sealed class ModuleOrCrInfo +{ + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/PermissionActionTypeReader.cs b/src/Ellie.Bot.Common/Common/TypeReaders/PermissionActionTypeReader.cs new file mode 100644 index 0000000..27a0111 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/PermissionActionTypeReader.cs @@ -0,0 +1,39 @@ +#nullable disable +using Ellie.Common.TypeReaders.Models; + +namespace Ellie.Common.TypeReaders; + +/// +/// Used instead of bool for more flexible keywords for true/false only in the permission module +/// +public sealed class PermissionActionTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.ToUpperInvariant(); + switch (input) + { + case "1": + case "T": + case "TRUE": + case "ENABLE": + case "ENABLED": + case "ALLOW": + case "PERMIT": + case "UNBAN": + return new(TypeReaderResult.FromSuccess(PermissionAction.Enable)); + case "0": + case "F": + case "FALSE": + case "DENY": + case "DISABLE": + case "DISABLED": + case "DISALLOW": + case "BAN": + return new(TypeReaderResult.FromSuccess(PermissionAction.Disable)); + default: + return new(TypeReaderResult.FromError(CommandError.ParseFailed, + "Did not receive a valid boolean value")); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/Rgba32TypeReader.cs b/src/Ellie.Bot.Common/Common/TypeReaders/Rgba32TypeReader.cs new file mode 100644 index 0000000..899586d --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/Rgba32TypeReader.cs @@ -0,0 +1,20 @@ +using Color = SixLabors.ImageSharp.Color; + +#nullable disable +namespace Ellie.Common.TypeReaders; + +public sealed class Rgba32TypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.Replace("#", "", StringComparison.InvariantCulture); + try + { + return ValueTask.FromResult(TypeReaderResult.FromSuccess(Color.ParseHex(input))); + } + catch + { + return ValueTask.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Parameter is not a valid color hex.")); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Common/TypeReaders/StoopidTimeTypeReader.cs b/src/Ellie.Bot.Common/Common/TypeReaders/StoopidTimeTypeReader.cs new file mode 100644 index 0000000..feb4f84 --- /dev/null +++ b/src/Ellie.Bot.Common/Common/TypeReaders/StoopidTimeTypeReader.cs @@ -0,0 +1,22 @@ +#nullable disable +using Ellie.Common.TypeReaders.Models; + +namespace Ellie.Common.TypeReaders; + +public sealed class StoopidTimeTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + if (string.IsNullOrWhiteSpace(input)) + return new(TypeReaderResult.FromError(CommandError.Unsuccessful, "Input is empty.")); + try + { + var time = StoopidTime.FromInput(input); + return new(TypeReaderResult.FromSuccess(time)); + } + catch (Exception ex) + { + return new(TypeReaderResult.FromError(CommandError.Exception, ex.Message)); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Configs/BotConfig.cs b/src/Ellie.Bot.Common/Configs/BotConfig.cs new file mode 100644 index 0000000..3fd22b1 --- /dev/null +++ b/src/Ellie.Bot.Common/Configs/BotConfig.cs @@ -0,0 +1,203 @@ +#nullable disable + +using Cloneable; +using Ellie.Common.Yml; +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace Ellie.Common.Configs; + +[Cloneable] +public sealed partial class BotConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 5; + + [Comment(""" + Most commands, when executed, have a small colored line + next to the response. The color depends whether the command + is completed, errored or in progress (pending) + Color settings below are for the color of those lines. + To get color's hex, you can go here https://htmlcolorcodes.com/ + and copy the hex code fo your selected color (marked as #) + """)] + public ColorConfig Color { get; set; } + + [Comment("Default bot language. It has to be in the list of supported languages (.langli)")] + public CultureInfo DefaultLocale { get; set; } + + [Comment(""" + Style in which executed commands will show up in the console. + Allowed values: Simple, Normal, None + """)] + public ConsoleOutputType ConsoleOutputType { get; set; } + + [Comment("""Whether the bot will check for new releases every hour""")] + public bool CheckForUpdates { get; set; } = true; + + [Comment("""Do you want any messages sent by users in Bot's DM to be forwarded to the owner(s)?""")] + public bool ForwardMessages { get; set; } + + [Comment(""" + Do you want the message to be forwarded only to the first owner specified in the list of owners (in creds.yml), + or all owners? (this might cause the bot to lag if there's a lot of owners specified) + """)] + public bool ForwardToAllOwners { get; set; } + + [Comment(""" + Any messages sent by users in Bot's DM to be forwarded to the specified channel. + This option will only work when ForwardToAllOwners is set to false + """)] + public ulong? ForwardToChannel { get; set; } + + [Comment(""" + When a user DMs the bot with a message which is not a command + they will receive this message. Leave empty for no response. The string which will be sent whenever someone DMs the bot. + Supports embeds. How it looks: https://puu.sh/B0BLV.png + """)] + [YamlMember(ScalarStyle = ScalarStyle.Literal)] + public string DmHelpText { get; set; } + + [Comment(""" + Only users who send a DM to the bot containing one of the specified words will get a DmHelpText response. + Case insensitive. + Leave empty to reply with DmHelpText to every DM. + """)] + public List DmHelpTextKeywords { get; set; } + + [Comment("""This is the response for the .h command""")] + [YamlMember(ScalarStyle = ScalarStyle.Literal)] + public string HelpText { get; set; } + + [Comment("""List of modules and commands completely blocked on the bot""")] + public BlockedConfig Blocked { get; set; } + + [Comment("""Which string will be used to recognize the commands""")] + public string Prefix { get; set; } + + [Comment(""" + Toggles whether your bot will group greet/bye messages into a single message every 5 seconds. + 1st user who joins will get greeted immediately + If more users join within the next 5 seconds, they will be greeted in groups of 5. + This will cause %user.mention% and other placeholders to be replaced with multiple users. + Keep in mind this might break some of your embeds - for example if you have %user.avatar% in the thumbnail, + it will become invalid, as it will resolve to a list of avatars of grouped users. + note: This setting is primarily used if you're afraid of raids, or you're running medium/large bots where some + servers might get hundreds of people join at once. This is used to prevent the bot from getting ratelimited, + and (slightly) reduce the greet spam in those servers. + """)] + public bool GroupGreets { get; set; } + + [Comment(""" + Whether the bot will rotate through all specified statuses. + This setting can be changed via .ropl command. + See RotatingStatuses submodule in Administration. + """)] + public bool RotateStatuses { get; set; } + + public BotConfig() + { + var color = new ColorConfig(); + Color = color; + DefaultLocale = new("en-US"); + ConsoleOutputType = ConsoleOutputType.Normal; + ForwardMessages = false; + ForwardToAllOwners = false; + DmHelpText = """{"description": "Type `%prefix%h` for help."}"""; + HelpText = """ + { + "title": "To invite me to your server, use this link", + "description": "https://discordapp.com/oauth2/authorize?client_id={0}&scope=bot&permissions=66186303", + "color": 53380, + "thumbnail": "https://i.imgur.com/nKYyqMK.png", + "fields": [ + { + "name": "Useful help commands", + "value": "`%bot.prefix%modules` Lists all bot modules. + `%prefix%h CommandName` Shows some help about a specific command. + `%prefix%commands ModuleName` Lists all commands in a module.", + "inline": false + }, + { + "name": "List of all Commands", + "value": "https://commands.elliebot.net", + "inline": false + }, + { + "name": "Ellie Support Server", + "value": "https://discord.elliebot.net/ ", + "inline": true + } + ] + } + """; + var blocked = new BlockedConfig(); + Blocked = blocked; + Prefix = "."; + RotateStatuses = false; + GroupGreets = false; + DmHelpTextKeywords = new() + { + "help", + "commands", + "cmds", + "module", + "can you do" + }; + } + + // [Comment(@"Whether the prefix will be a suffix, or prefix. + // For example, if your prefix is ! you will run a command called 'cash' by typing either + // '!cash @Someone' if your prefixIsSuffix: false or + // 'cash @Someone!' if your prefixIsSuffix: true")] + // public bool PrefixIsSuffix { get; set; } + + // public string Prefixed(string text) => PrefixIsSuffix + // ? text + Prefix + // : Prefix + text; + + public string Prefixed(string text) + => Prefix + text; +} + +[Cloneable] +public sealed partial class BlockedConfig +{ + public HashSet Commands { get; set; } + public HashSet Modules { get; set; } + + public BlockedConfig() + { + Modules = new(); + Commands = new(); + } +} + +[Cloneable] +public partial class ColorConfig +{ + [Comment("""Color used for embed responses when command successfully executes""")] + public Rgba32 Ok { get; set; } + + [Comment("""Color used for embed responses when command has an error""")] + public Rgba32 Error { get; set; } + + [Comment("""Color used for embed responses while command is doing work or is in progress""")] + public Rgba32 Pending { get; set; } + + public ColorConfig() + { + Ok = Rgba32.ParseHex("00e584"); + Error = Rgba32.ParseHex("ee281f"); + Pending = Rgba32.ParseHex("faa61a"); + } +} + +public enum ConsoleOutputType +{ + Normal = 0, + Simple = 1, + None = 2 +} diff --git a/src/Ellie.Bot.Common/Configs/IConfigSeria.cs b/src/Ellie.Bot.Common/Configs/IConfigSeria.cs new file mode 100644 index 0000000..4aea72e --- /dev/null +++ b/src/Ellie.Bot.Common/Configs/IConfigSeria.cs @@ -0,0 +1,18 @@ +namespace Ellie.Common.Configs; + +/// +/// Base interface for available config serializers +/// +public interface IConfigSeria +{ + /// + /// Serialize the object to string + /// + public string Serialize(T obj) + where T : notnull; + + /// + /// Deserialize string data into an object of the specified type + /// + public T Deserialize(string data); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Creds.cs b/src/Ellie.Bot.Common/Creds.cs new file mode 100644 index 0000000..a8354c9 --- /dev/null +++ b/src/Ellie.Bot.Common/Creds.cs @@ -0,0 +1,273 @@ +#nullable disable +using Ellie.Common.Yml; +using Microsoft.EntityFrameworkCore; + +namespace Ellie.Common; + +public sealed class Creds : IBotCredentials +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } + + [Comment("""Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/""")] + public string Token { get; set; } + + [Comment(""" + List of Ids of the users who have bot owner permissions + **DO NOT ADD PEOPLE YOU DON'T TRUST** + """)] + public ICollection OwnerIds { get; set; } + + [Comment("Keep this on 'true' unless you're sure your bot shouldn't use privileged intents or you're waiting to be accepted")] + public bool UsePrivilegedIntents { get; set; } + + [Comment(""" + The number of shards that the bot will be running on. + Leave at 1 if you don't know what you're doing. + + note: If you are planning to have more than one shard, then you must change botCache to 'redis'. + Also, in that case you should be using Ellie.Coordinator to start the bot, and it will correctly override this value. + """)] + public int TotalShards { get; set; } + + [Comment( + """ + Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it. + Then, go to APIs and Services -> Credentials and click Create credentials -> API key. + Used only for Youtube Data Api (at the moment). + """)] + public string GoogleApiKey { get; set; } + + [Comment( + """ + Create a new custom search here https://programmablesearchengine.google.com/cse/create/new + Enable SafeSearch + Remove all Sites to Search + Enable Search the entire web + Copy the 'Search Engine ID' to the SearchId field + + Do all steps again but enable image search for the ImageSearchId + """)] + public GoogleApiConfig Google { get; set; } + + [Comment("""Settings for voting system for discordbots. Meant for use on global Ellie.""")] + public VotesSettings Votes { get; set; } + + [Comment(""" + Patreon auto reward system settings. + go to https://www.patreon.com/portal -> my clients -> create client + """)] + public PatreonSettings Patreon { get; set; } + + [Comment("""Api key for sending stats to DiscordBotList.""")] + public string BotListToken { get; set; } + + [Comment("""Official cleverbot api key.""")] + public string CleverbotApiKey { get; set; } + + [Comment(@"Official GPT-3 api key.")] + public string Gpt3ApiKey { get; set; } + + [Comment(""" + Which cache implementation should bot use. + 'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset. + 'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml + """)] + public BotCacheImplemenation BotCache { get; set; } + + [Comment(""" + Redis connection string. Don't change if you don't know what you're doing. + Only used if botCache is set to 'redis' + """)] + public string RedisOptions { get; set; } + + [Comment("""Database options. Don't change if you don't know what you're doing. Leave null for default values""")] + public DbOptions Db { get; set; } + + [Comment(""" + Address and port of the coordinator endpoint. Leave empty for default. + Change only if you've changed the coordinator address or port. + """)] + public string CoordinatorUrl { get; set; } + + [Comment( + """Api key obtained on https://rapidapi.com (go to MyApps -> Add New App -> Enter Name -> Application key)""")] + public string RapidApiKey { get; set; } + + [Comment(""" + https://locationiq.com api key (register and you will receive the token in the email). + Used only for .time command. + """)] + public string LocationIqApiKey { get; set; } + + [Comment(""" + https://timezonedb.com api key (register and you will receive the token in the email). + Used only for .time command + """)] + public string TimezoneDbApiKey { get; set; } + + [Comment(""" + https://pro.coinmarketcap.com/account/ api key. There is a free plan for personal use. + Used for cryptocurrency related commands. + """)] + public string CoinmarketcapApiKey { get; set; } + + // [Comment(@"https://polygon.io/dashboard/api-keys api key. Free plan allows for 5 queries per minute. + // Used for stocks related commands.")] + // public string PolygonIoApiKey { get; set; } + + [Comment("""Api key used for Osu related commands. Obtain this key at https://osu.ppy.sh/p/api""")] + public string OsuApiKey { get; set; } + + [Comment(""" + Optional Trovo client id. + You should use this if Trovo stream notifications stopped working or you're getting ratelimit errors. + """)] + public string TrovoClientId { get; set; } + + [Comment("""Obtain by creating an application at https://dev.twitch.tv/console/apps""")] + public string TwitchClientId { get; set; } + + [Comment("""Obtain by creating an application at https://dev.twitch.tv/console/apps""")] + public string TwitchClientSecret { get; set; } + + [Comment(""" + Command and args which will be used to restart the bot. + Only used if bot is executed directly (NOT through the coordinator) + placeholders: + {0} -> shard id + {1} -> total shards + Linux default + cmd: dotnet + args: "Ellie.dll -- {0}" + Windows default + cmd: Ellie.exe + args: "{0}" + """)] + public RestartConfig RestartCommand { get; set; } + + public Creds() + { + Version = 7; + Token = string.Empty; + UsePrivilegedIntents = true; + OwnerIds = new List(); + TotalShards = 1; + GoogleApiKey = string.Empty; + Votes = new VotesSettings(string.Empty, string.Empty, string.Empty, string.Empty); + Patreon = new PatreonSettings(string.Empty, string.Empty, string.Empty, string.Empty); + BotListToken = string.Empty; + CleverbotApiKey = string.Empty; + Gpt3ApiKey = string.Empty; + BotCache = BotCacheImplemenation.Memory; + RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password="; + Db = new DbOptions() + { + Type = "sqlite", + ConnectionString = "Data Source=data/Ellie.db" + }; + + CoordinatorUrl = "http://localhost:3442"; + + RestartCommand = new RestartConfig(); + Google = new GoogleApiConfig(); + } + + public class DbOptions + : IDbOptions + { + [Comment(""" + Database type. "sqlite", "mysql" and "postgresql" are supported. + Default is "sqlite" + """)] + public string Type { get; set; } + + [Comment(""" + Database connection string. + You MUST change this if you're not using "sqlite" type. + Default is "Data Source=data/Ellie.db" + Example for mysql: "Server=localhost;Port=3306;Uid=root;Pwd=my_super_secret_mysql_password;Database=ellie" + Example for postgresql: "Server=localhost;Port=5432;User Id=postgres;Password=my_super_secret_postgres_password;Database=ellie;" + """)] + public string ConnectionString { get; set; } + } + + public sealed record PatreonSettings : IPatreonSettings + { + public string ClientId { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string ClientSecret { get; set; } + + [Comment( + """Campaign ID of your patreon page. Go to your patreon page (make sure you're logged in) and type "prompt('Campaign ID', window.patreon.bootstrap.creator.data.id);" in the console. (ctrl + shift + i)""")] + public string CampaignId { get; set; } + + public PatreonSettings( + string accessToken, + string refreshToken, + string clientSecret, + string campaignId) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + ClientSecret = clientSecret; + CampaignId = campaignId; + } + + public PatreonSettings() + { + } + } + + public sealed record VotesSettings : IVotesSettings + { + [Comment(""" + top.gg votes service url + This is the url of your instance of the Ellie.Votes api + Example: https://votes.my.cool.bot.com + """)] + public string TopggServiceUrl { get; set; } + + [Comment(""" + Authorization header value sent to the TopGG service url with each request + This should be equivalent to the TopggKey in your Ellie.Votes api appsettings.json file + """)] + public string TopggKey { get; set; } + + [Comment(""" + discords.com votes service url + This is the url of your instance of the Ellie.Votes api + Example: https://votes.my.cool.bot.com + """)] + public string DiscordsServiceUrl { get; set; } + + [Comment(""" + Authorization header value sent to the Discords service url with each request + This should be equivalent to the DiscordsKey in your Ellie.Votes api appsettings.json file + """)] + public string DiscordsKey { get; set; } + + public VotesSettings() + { + } + + public VotesSettings( + string topggServiceUrl, + string topggKey, + string discordsServiceUrl, + string discordsKey) + { + TopggServiceUrl = topggServiceUrl; + TopggKey = topggKey; + DiscordsServiceUrl = discordsServiceUrl; + DiscordsKey = discordsKey; + } + } +} + +public class GoogleApiConfig : IGoogleApiConfig +{ + public string SearchId { get; init; } + public string ImageSearchId { get; init; } +} diff --git a/src/Ellie.Bot.Common/Currency/CurrencyType.cs b/src/Ellie.Bot.Common/Currency/CurrencyType.cs new file mode 100644 index 0000000..a3b6fcf --- /dev/null +++ b/src/Ellie.Bot.Common/Currency/CurrencyType.cs @@ -0,0 +1,5 @@ +namespace Ellie.Services.Currency; + +public enum CurrencyType{ + Default +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Currency/IBankService.cs b/src/Ellie.Bot.Common/Currency/IBankService.cs new file mode 100644 index 0000000..caca06d --- /dev/null +++ b/src/Ellie.Bot.Common/Currency/IBankService.cs @@ -0,0 +1,10 @@ +namespace Ellie.Modules.Gambling.Bank; + +public interface IBankService +{ + Task DepositAsync(ulong userId, long amount); + Task WithdrawAsync(ulong userId, long amount); + Task GetBalanceAsync(ulong userId); + Task AwardAsync(ulong userId, long amount); + Task TakeAsync(ulong userId, long amount); +} diff --git a/src/Ellie.Bot.Common/Currency/ICurrencyService.cs b/src/Ellie.Bot.Common/Currency/ICurrencyService.cs new file mode 100644 index 0000000..332e890 --- /dev/null +++ b/src/Ellie.Bot.Common/Currency/ICurrencyService.cs @@ -0,0 +1,40 @@ +using Ellie.Services.Currency; + +namespace Ellie.Services; + +public interface ICurrencyService +{ + Task GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default); + + Task AddBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData? txData, + CurrencyType type = CurrencyType.Default); + + Task RemoveBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData? txData, + CurrencyType type = CurrencyType.Default); + + Task AddAsync( + ulong userId, + long amount, + TxData? txData); + + Task AddAsync( + IUser user, + long amount, + TxData? txData); + + Task RemoveAsync( + ulong userId, + long amount, + TxData? txData); + + Task RemoveAsync( + IUser user, + long amount, + TxData? txData); +} diff --git a/src/Ellie.Bot.Common/Currency/ITxTracker.cs b/src/Ellie.Bot.Common/Currency/ITxTracker.cs new file mode 100644 index 0000000..c0d3f7a --- /dev/null +++ b/src/Ellie.Bot.Common/Currency/ITxTracker.cs @@ -0,0 +1,9 @@ +using Ellie.Services.Currency; + +namespace Ellie.Services; + +public interface ITxTracker +{ + Task TrackAdd(long amount, TxData? txData); + Task TrackRemove(long amount, TxData? txData); +} diff --git a/src/Ellie.Bot.Common/Currency/IWallet.cs b/src/Ellie.Bot.Common/Currency/IWallet.cs new file mode 100644 index 0000000..332c8d2 --- /dev/null +++ b/src/Ellie.Bot.Common/Currency/IWallet.cs @@ -0,0 +1,40 @@ +namespace Ellie.Services.Currency; + +public interface IWallet +{ + public ulong UserId { get; } + + public Task GetBalance(); + public Task Take(long amount, TxData? txData); + public Task Add(long amount, TxData? txData); + + public async Task Transfer( + long amount, + IWallet to, + TxData? txData) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0."); + + if (txData is not null) + txData = txData with + { + OtherId = to.UserId + }; + + var succ = await Take(amount, txData); + + if (!succ) + return false; + + if (txData is not null) + txData = txData with + { + OtherId = UserId + }; + + await to.Add(amount, txData); + + return true; + } +} diff --git a/src/Ellie.Bot.Common/Currency/TxData.cs b/src/Ellie.Bot.Common/Currency/TxData.cs new file mode 100644 index 0000000..55bc40b --- /dev/null +++ b/src/Ellie.Bot.Common/Currency/TxData.cs @@ -0,0 +1,7 @@ +namespace Ellie.Services.Currency; + +public record class TxData( + string Type, + string Extra, + string? Note = "", + ulong? OtherId = null); diff --git a/src/Ellie.Bot.Common/DbService.cs b/src/Ellie.Bot.Common/DbService.cs new file mode 100644 index 0000000..0355dfd --- /dev/null +++ b/src/Ellie.Bot.Common/DbService.cs @@ -0,0 +1,18 @@ +#nullable disable +using LinqToDB.Common; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Ellie.Services.Database; + +namespace Ellie.Services; + +public abstract class DbService +{ + /// + /// Call this to apply all migrations + /// + public abstract Task SetupAsync(); + + public abstract DbContext CreateRawDbContext(string dbType, string connString); + public abstract DbContext GetDbContext(); +} diff --git a/src/Ellie.Bot.Common/DoAsUserMessage.cs b/src/Ellie.Bot.Common/DoAsUserMessage.cs new file mode 100644 index 0000000..fa9b6e4 --- /dev/null +++ b/src/Ellie.Bot.Common/DoAsUserMessage.cs @@ -0,0 +1,138 @@ +using MessageType = Discord.MessageType; + +namespace Ellie.Modules.Administration; + +public sealed class DoAsUserMessage : IUserMessage +{ + private readonly string _message; + private IUserMessage _msg; + private readonly IUser _user; + + public DoAsUserMessage(SocketUserMessage msg, IUser user, string message) + { + _msg = msg; + _user = user; + _message = message; + } + + public ulong Id => _msg.Id; + + public DateTimeOffset CreatedAt => _msg.CreatedAt; + + public Task DeleteAsync(RequestOptions? options = null) + { + return _msg.DeleteAsync(options); + } + + public Task AddReactionAsync(IEmote emote, RequestOptions? options = null) + { + return _msg.AddReactionAsync(emote, options); + } + + public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions? options = null) + { + return _msg.RemoveReactionAsync(emote, user, options); + } + + public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions? options = null) + { + return _msg.RemoveReactionAsync(emote, userId, options); + } + + public Task RemoveAllReactionsAsync(RequestOptions? options = null) + { + return _msg.RemoveAllReactionsAsync(options); + } + + public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions? options = null) + { + return _msg.RemoveAllReactionsForEmoteAsync(emote, options); + } + + public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, + RequestOptions? options = null) + { + return _msg.GetReactionUsersAsync(emoji, limit, options); + } + + public MessageType Type => _msg.Type; + + public MessageSource Source => _msg.Source; + + public bool IsTTS => _msg.IsTTS; + + public bool IsPinned => _msg.IsPinned; + + public bool IsSuppressed => _msg.IsSuppressed; + + public bool MentionedEveryone => _msg.MentionedEveryone; + + public string Content => _message; + + public string CleanContent => _msg.CleanContent; + + public DateTimeOffset Timestamp => _msg.Timestamp; + + public DateTimeOffset? EditedTimestamp => _msg.EditedTimestamp; + + public IMessageChannel Channel => _msg.Channel; + + public IUser Author => _user; + + public IReadOnlyCollection Attachments => _msg.Attachments; + + public IReadOnlyCollection Embeds => _msg.Embeds; + + public IReadOnlyCollection Tags => _msg.Tags; + + public IReadOnlyCollection MentionedChannelIds => _msg.MentionedChannelIds; + + public IReadOnlyCollection MentionedRoleIds => _msg.MentionedRoleIds; + + public IReadOnlyCollection MentionedUserIds => _msg.MentionedUserIds; + + public MessageActivity Activity => _msg.Activity; + + public MessageApplication Application => _msg.Application; + + public MessageReference Reference => _msg.Reference; + + public IReadOnlyDictionary Reactions => _msg.Reactions; + + public IReadOnlyCollection Components => _msg.Components; + + public IReadOnlyCollection Stickers => _msg.Stickers; + + public MessageFlags? Flags => _msg.Flags; + + public IMessageInteraction Interaction => _msg.Interaction; + + public Task ModifyAsync(Action func, RequestOptions? options = null) + { + return _msg.ModifyAsync(func, options); + } + + public Task PinAsync(RequestOptions? options = null) + { + return _msg.PinAsync(options); + } + + public Task UnpinAsync(RequestOptions? options = null) + { + return _msg.UnpinAsync(options); + } + + public Task CrosspostAsync(RequestOptions? options = null) + { + return _msg.CrosspostAsync(options); + } + + public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, + TagHandling roleHandling = TagHandling.Name, + TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + { + return _msg.Resolve(userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + } + + public IUserMessage ReferencedMessage => _msg.ReferencedMessage; +} diff --git a/src/Ellie.Bot.Common/Ellie.Bot.Common.csproj b/src/Ellie.Bot.Common/Ellie.Bot.Common.csproj new file mode 100644 index 0000000..5a4866e --- /dev/null +++ b/src/Ellie.Bot.Common/Ellie.Bot.Common.csproj @@ -0,0 +1,40 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + + + + + + all + True + + + + + + + + + + + + + + + diff --git a/src/Ellie.Bot.Common/EllieModule.cs b/src/Ellie.Bot.Common/EllieModule.cs new file mode 100644 index 0000000..f9e1202 --- /dev/null +++ b/src/Ellie.Bot.Common/EllieModule.cs @@ -0,0 +1,141 @@ +#nullable disable +using System.Globalization; + +// ReSharper disable InconsistentNaming + +namespace Ellie.Common; + +[UsedImplicitly(ImplicitUseTargetFlags.Default + | ImplicitUseTargetFlags.WithInheritors + | ImplicitUseTargetFlags.WithMembers)] +public abstract class EllieModule : ModuleBase +{ + protected CultureInfo Culture { get; set; } + + // Injected by Discord.net + public IBotStrings Strings { get; set; } + public ICommandHandler _cmdHandler { get; set; } + public ILocalization _localization { get; set; } + public IEmbedBuilderService _eb { get; set; } + public IEllieInteractionService _inter { get; set; } + + protected string prefix + => _cmdHandler.GetPrefix(ctx.Guild); + + protected ICommandContext ctx + => Context; + + protected override void BeforeExecute(CommandInfo command) + => Culture = _localization.GetCultureInfo(ctx.Guild?.Id); + + protected string GetText(in LocStr data) + => Strings.GetText(data, Culture); + + public Task SendErrorAsync( + string title, + string error, + string url = null, + string footer = null, + EllieInteraction inter = null) + => ctx.Channel.SendErrorAsync(_eb, title, error, url, footer); + + public Task SendConfirmAsync( + string title, + string text, + string url = null, + string footer = null) + => ctx.Channel.SendConfirmAsync(_eb, title, text, url, footer); + + // + public Task SendErrorAsync(string text, EllieInteraction inter = null) + => ctx.Channel.SendAsync(_eb, text, MsgType.Error, inter); + public Task SendConfirmAsync(string text, EllieInteraction inter = null) + => ctx.Channel.SendAsync(_eb, text, MsgType.Ok, inter); + public Task SendPendingAsync(string text, EllieInteraction inter = null) + => ctx.Channel.SendAsync(_eb, text, MsgType.Pending, inter); + + + // localized normal + public Task ErrorLocalizedAsync(LocStr str, EllieInteraction inter = null) + => SendErrorAsync(GetText(str), inter); + + public Task PendingLocalizedAsync(LocStr str, EllieInteraction inter = null) + => SendPendingAsync(GetText(str), inter); + + public Task ConfirmLocalizedAsync(LocStr str, EllieInteraction inter = null) + => SendConfirmAsync(GetText(str), inter); + + // localized replies + public Task ReplyErrorLocalizedAsync(LocStr str, EllieInteraction inter = null) + => SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter); + + public Task ReplyPendingLocalizedAsync(LocStr str, EllieInteraction inter = null) + => SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter); + + public Task ReplyConfirmLocalizedAsync(LocStr str, EllieInteraction inter = null) + => SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter); + + public async Task PromptUserConfirmAsync(IEmbedBuilder embed) + { + embed.WithPendingColor().WithFooter("yes/no"); + + var msg = await ctx.Channel.EmbedAsync(embed); + try + { + var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id); + input = input?.ToUpperInvariant(); + + if (input != "YES" && input != "Y") + return false; + + return true; + } + finally + { + _ = Task.Run(() => msg.DeleteAsync()); + } + } + + // TypeConverter typeConverter = TypeDescriptor.GetConverter(propType); ? + public async Task GetUserInputAsync(ulong userId, ulong channelId) + { + var userInputTask = new TaskCompletionSource(); + var dsc = (DiscordSocketClient)ctx.Client; + try + { + dsc.MessageReceived += MessageReceived; + + if (await Task.WhenAny(userInputTask.Task, Task.Delay(10000)) != userInputTask.Task) + return null; + + return await userInputTask.Task; + } + finally + { + dsc.MessageReceived -= MessageReceived; + } + + Task MessageReceived(SocketMessage arg) + { + _ = Task.Run(() => + { + if (arg is not SocketUserMessage userMsg + || userMsg.Channel is not ITextChannel + || userMsg.Author.Id != userId + || userMsg.Channel.Id != channelId) + return Task.CompletedTask; + + if (userInputTask.TrySetResult(arg.Content)) + userMsg.DeleteAfter(1); + + return Task.CompletedTask; + }); + return Task.CompletedTask; + } + } +} + +public abstract class EllieModule : EllieModule +{ + public TService _service { get; set; } +} diff --git a/src/Ellie.Bot.Common/EllieTypeReader.cs b/src/Ellie.Bot.Common/EllieTypeReader.cs new file mode 100644 index 0000000..14af2c3 --- /dev/null +++ b/src/Ellie.Bot.Common/EllieTypeReader.cs @@ -0,0 +1,15 @@ +#nullable disable + +namespace Ellie.Common.TypeReaders; + +[MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)] +public abstract class EllieTypeReader : TypeReader +{ + public abstract ValueTask> ReadAsync(ICommandContext ctx, string input); + + public override async Task ReadAsync( + ICommandContext ctx, + string input, + IServiceProvider services) + => await ReadAsync(ctx, input); +} diff --git a/src/Ellie.Bot.Common/Extensions/DbExtensions.cs b/src/Ellie.Bot.Common/Extensions/DbExtensions.cs new file mode 100644 index 0000000..9fc595a --- /dev/null +++ b/src/Ellie.Bot.Common/Extensions/DbExtensions.cs @@ -0,0 +1,12 @@ +using Ellie.Db; +using Ellie.Db.Models; +// todo fix these namespaces. It should only be Nadeko.Bot.Db +using Ellie.Services.Database; + +namespace Ellie.Extensions; + +public static class DbExtensions +{ + public static DiscordUser GetOrCreateUser(this EllieBaseContext ctx, IUser original, Func, IQueryable>? includes = null) + => ctx.GetOrCreateUser(original.Id, original.Username, original.Discriminator, original.AvatarId, includes); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Extensions/ImagesharpExtensions.cs b/src/Ellie.Bot.Common/Extensions/ImagesharpExtensions.cs new file mode 100644 index 0000000..1db8ab0 --- /dev/null +++ b/src/Ellie.Bot.Common/Extensions/ImagesharpExtensions.cs @@ -0,0 +1,97 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Color = Discord.Color; + +namespace Ellie.Extensions; + +public static class ImagesharpExtensions +{ + // https://github.com/SixLabors/Samples/blob/master/ImageSharp/AvatarWithRoundedCorner/Program.cs + public static IImageProcessingContext ApplyRoundedCorners(this IImageProcessingContext ctx, float cornerRadius) + { + var size = ctx.GetCurrentSize(); + var corners = BuildCorners(size.Width, size.Height, cornerRadius); + + ctx.SetGraphicsOptions(new GraphicsOptions + { + Antialias = true, + // enforces that any part of this shape that has color is punched out of the background + AlphaCompositionMode = PixelAlphaCompositionMode.DestOut + }); + + foreach (var c in corners) + ctx = ctx.Fill(SixLabors.ImageSharp.Color.Red, c); + + return ctx; + } + + private static IPathCollection BuildCorners(int imageWidth, int imageHeight, float cornerRadius) + { + // first create a square + var rect = new RectangularPolygon(-0.5f, -0.5f, cornerRadius, cornerRadius); + + // then cut out of the square a circle so we are left with a corner + var cornerTopLeft = rect.Clip(new EllipsePolygon(cornerRadius - 0.5f, cornerRadius - 0.5f, cornerRadius)); + + // corner is now a corner shape positions top left + //lets make 3 more positioned correctly, we can do that by translating the original around the center of the image + + var rightPos = imageWidth - cornerTopLeft.Bounds.Width + 1; + var bottomPos = imageHeight - cornerTopLeft.Bounds.Height + 1; + + // move it across the width of the image - the width of the shape + var cornerTopRight = cornerTopLeft.RotateDegree(90).Translate(rightPos, 0); + var cornerBottomLeft = cornerTopLeft.RotateDegree(-90).Translate(0, bottomPos); + var cornerBottomRight = cornerTopLeft.RotateDegree(180).Translate(rightPos, bottomPos); + + return new PathCollection(cornerTopLeft, cornerBottomLeft, cornerTopRight, cornerBottomRight); + } + + public static Color ToDiscordColor(this Rgba32 color) + => new(color.R, color.G, color.B); + + public static MemoryStream ToStream(this Image img, IImageFormat? format = null) + { + var imageStream = new MemoryStream(); + if (format?.Name == "GIF") + img.SaveAsGif(imageStream); + else + { + img.SaveAsPng(imageStream, + new() + { + ColorType = PngColorType.RgbWithAlpha, + CompressionLevel = PngCompressionLevel.DefaultCompression + }); + } + + imageStream.Position = 0; + return imageStream; + } + + public static async Task ToStreamAsync(this Image img, IImageFormat? format = null) + { + var imageStream = new MemoryStream(); + if (format?.Name == "GIF") + { + await img.SaveAsGifAsync(imageStream); + } + else + { + await img.SaveAsPngAsync(imageStream, + new PngEncoder() + { + ColorType = PngColorType.RgbWithAlpha, + CompressionLevel = PngCompressionLevel.DefaultCompression + }); + } + + imageStream.Position = 0; + return imageStream; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/GlobalUsings.cs b/src/Ellie.Bot.Common/GlobalUsings.cs new file mode 100644 index 0000000..789a2ab --- /dev/null +++ b/src/Ellie.Bot.Common/GlobalUsings.cs @@ -0,0 +1,31 @@ +// // global using System.Collections.Concurrent; +global using NonBlocking; +// +// // packages +global using Humanizer; +// +// // ellie +global using Ellie; +global using Ellie.Services; +global using Ellise.Common; // new project +global using Ellie.Common; // old + ellie specific things +global using Ellie.Common.Attributes; +global using Ellie.Extensions; + +// discord +global using Discord; +global using Discord.Commands; +global using Discord.Net; +global using Discord.WebSocket; + +// aliases +global using GuildPerm = Discord.GuildPermission; +global using ChannelPerm = Discord.ChannelPermission; +global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute; +global using LeftoverAttribute = Discord.Commands.RemainderAttribute; + +// non-essential +global using JetBrains.Annotations; + + +global using Serilog; \ No newline at end of file diff --git a/src/Ellie.Bot.Common/IBot.cs b/src/Ellie.Bot.Common/IBot.cs new file mode 100644 index 0000000..7473226 --- /dev/null +++ b/src/Ellie.Bot.Common/IBot.cs @@ -0,0 +1,12 @@ +#nullable disable +using Ellie.Services.Database.Models; + +namespace Ellie; + +public interface IBot +{ + IReadOnlyList GetCurrentGuildIds(); + event Func JoinedGuild; + IReadOnlyCollection AllGuildConfigs { get; } + bool IsReady { get; } +} diff --git a/src/Ellie.Bot.Common/ICloneable.cs b/src/Ellie.Bot.Common/ICloneable.cs new file mode 100644 index 0000000..89b3114 --- /dev/null +++ b/src/Ellie.Bot.Common/ICloneable.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace Ellie.Common; + +public interface ICloneable + where T : new() +{ + public T Clone(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/ICurrencyProvider.cs b/src/Ellie.Bot.Common/ICurrencyProvider.cs new file mode 100644 index 0000000..a6b1a2c --- /dev/null +++ b/src/Ellie.Bot.Common/ICurrencyProvider.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using System.Numerics; + +namespace Ellie.Bot.Common; + +public interface ICurrencyProvider +{ + string GetCurrencySign(); +} + +public static class CurrencyHelper +{ + public static string N(T cur, IFormatProvider format) + where T : INumber + => cur.ToString("C0", format); + + public static string N(T cur, CultureInfo culture, string currencySign) + where T : INumber + => N(cur, GetCurrencyFormat(culture, currencySign)); + + private static IFormatProvider GetCurrencyFormat(CultureInfo culture, string currencySign) + { + var flowersCurrencyCulture = (CultureInfo)culture.Clone(); + flowersCurrencyCulture.NumberFormat.CurrencySymbol = currencySign; + flowersCurrencyCulture.NumberFormat.CurrencyNegativePattern = 5; + + return flowersCurrencyCulture; + } +} diff --git a/src/Ellie.Bot.Common/IDiscordPermOverrideService.cs b/src/Ellie.Bot.Common/IDiscordPermOverrideService.cs new file mode 100644 index 0000000..7b36d13 --- /dev/null +++ b/src/Ellie.Bot.Common/IDiscordPermOverrideService.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace Ellise.Common; + +public interface IDiscordPermOverrideService +{ + bool TryGetOverrides(ulong guildId, string commandName, out Ellie.Bot.Db.GuildPerm? perm); +} diff --git a/src/Ellie.Bot.Common/IEllieCommandOptions.cs b/src/Ellie.Bot.Common/IEllieCommandOptions.cs new file mode 100644 index 0000000..f3f60dd --- /dev/null +++ b/src/Ellie.Bot.Common/IEllieCommandOptions.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace Ellie.Common; + +public interface IEllieCommandOptions +{ + void NormalizeOptions(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/ILogCommandService.cs b/src/Ellie.Bot.Common/ILogCommandService.cs new file mode 100644 index 0000000..127435f --- /dev/null +++ b/src/Ellie.Bot.Common/ILogCommandService.cs @@ -0,0 +1,35 @@ +using Ellie.Services.Database.Models; + +namespace Ellie.Common; + +public interface ILogCommandService +{ + void AddDeleteIgnore(ulong xId); + Task LogServer(ulong guildId, ulong channelId, bool actionValue); + bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType); + LogSetting? GetGuildLogSettings(ulong guildId); + bool Log(ulong guildId, ulong? channelId, LogType type); +} + +public enum LogType +{ + Other, + MessageUpdated, + MessageDeleted, + UserJoined, + UserLeft, + UserBanned, + UserUnbanned, + UserUpdated, + ChannelCreated, + ChannelDestroyed, + ChannelUpdated, + UserPresence, + VoicePresence, + VoicePresenceTts, + UserMuted, + UserWarned, + + ThreadDeleted, + ThreadCreated +} diff --git a/src/Ellie.Bot.Common/IPermissionChecker.cs b/src/Ellie.Bot.Common/IPermissionChecker.cs new file mode 100644 index 0000000..de9e14d --- /dev/null +++ b/src/Ellie.Bot.Common/IPermissionChecker.cs @@ -0,0 +1,13 @@ +using OneOf; +using OneOf.Types; + +namespace Ellie.Bot.Common; + +public interface IPermissionChecker +{ + Task>> CheckAsync(IGuild guild, + IMessageChannel channel, + IUser author, + string module, + string? cmd); +} diff --git a/src/Ellie.Bot.Common/IPlaceholderProvider.cs b/src/Ellie.Bot.Common/IPlaceholderProvider.cs new file mode 100644 index 0000000..c1a88c1 --- /dev/null +++ b/src/Ellie.Bot.Common/IPlaceholderProvider.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace Ellie.Common; + +public interface IPlaceholderProvider +{ + public IEnumerable<(string Name, Func Func)> GetPlaceholders(); +} diff --git a/src/Ellie.Bot.Common/Interaction/EllieInteraction.cs b/src/Ellie.Bot.Common/Interaction/EllieInteraction.cs new file mode 100644 index 0000000..5085253 --- /dev/null +++ b/src/Ellie.Bot.Common/Interaction/EllieInteraction.cs @@ -0,0 +1,82 @@ +namespace Ellie; + +public sealed class EllieInteraction +{ + private readonly ulong _authorId; + private readonly ButtonBuilder _button; + private readonly Func _onClick; + private readonly bool _onlyAuthor; + public DiscordSocketClient Client { get; } + + private readonly TaskCompletionSource _interactionCompletedSource; + + private IUserMessage message = null!; + + public EllieInteraction(DiscordSocketClient client, + ulong authorId, + ButtonBuilder button, + Func onClick, + bool onlyAuthor) + { + _authorId = authorId; + _button = button; + _onClick = onClick; + _onlyAuthor = onlyAuthor; + _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Client = client; + } + + public async Task RunAsync(IUserMessage msg) + { + message = msg; + + Client.InteractionCreated += OnInteraction; + await Task.WhenAny(Task.Delay(15_000), _interactionCompletedSource.Task); + Client.InteractionCreated -= OnInteraction; + + await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build()); + } + + private Task OnInteraction(SocketInteraction arg) + { + if (arg is not SocketMessageComponent smc) + return Task.CompletedTask; + + if (smc.Message.Id != message.Id) + return Task.CompletedTask; + + if (_onlyAuthor && smc.User.Id != _authorId) + return Task.CompletedTask; + + if (smc.Data.CustomId != _button.CustomId) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + await ExecuteOnActionAsync(smc); + + // this should only be a thing on single-response buttons + _interactionCompletedSource.TrySetResult(true); + + if (!smc.HasResponded) + { + await smc.DeferAsync(); + } + }); + + return Task.CompletedTask; + } + + + public MessageComponent CreateComponent() + { + var comp = new ComponentBuilder() + .WithButton(_button); + + return comp.Build(); + } + + public Task ExecuteOnActionAsync(SocketMessageComponent smc) + => _onClick(smc); +} diff --git a/src/Ellie.Bot.Common/Interaction/EllieInteractionData.cs b/src/Ellie.Bot.Common/Interaction/EllieInteractionData.cs new file mode 100644 index 0000000..51c4bd1 --- /dev/null +++ b/src/Ellie.Bot.Common/Interaction/EllieInteractionData.cs @@ -0,0 +1,8 @@ +namespace Ellie; + +/// +/// Represents essential interacation data +/// +/// Emote which will show on a button +/// Custom interaction id +public record EllieInteractionData(IEmote Emote, string CustomId, string? Text = null); diff --git a/src/Ellie.Bot.Common/Interaction/EllieInteractionService.cs b/src/Ellie.Bot.Common/Interaction/EllieInteractionService.cs new file mode 100644 index 0000000..2030b0d --- /dev/null +++ b/src/Ellie.Bot.Common/Interaction/EllieInteractionService.cs @@ -0,0 +1,20 @@ +namespace Ellie; + +public class EllieInteractionService : IEllieInteractionService, IEService +{ + private readonly DiscordSocketClient _client; + + public EllieInteractionService(DiscordSocketClient client) + { + _client = client; + } + + public EllieInteraction Create( + ulong userId, + SimpleInteraction inter) + => new EllieInteraction(_client, + userId, + inter.Button, + inter.TriggerAsync, + onlyAuthor: true); +} diff --git a/src/Ellie.Bot.Common/Interaction/IEllieInteractionService.cs b/src/Ellie.Bot.Common/Interaction/IEllieInteractionService.cs new file mode 100644 index 0000000..7ed3878 --- /dev/null +++ b/src/Ellie.Bot.Common/Interaction/IEllieInteractionService.cs @@ -0,0 +1,8 @@ +namespace Ellie; + +public interface IEllieInteractionService +{ + public EllieInteraction Create( + ulong userId, + SimpleInteraction inter); +} diff --git a/src/Ellie.Bot.Common/Interaction/SimpleInteraction.cs b/src/Ellie.Bot.Common/Interaction/SimpleInteraction.cs new file mode 100644 index 0000000..e2c66ea --- /dev/null +++ b/src/Ellie.Bot.Common/Interaction/SimpleInteraction.cs @@ -0,0 +1,20 @@ +namespace Ellie; + +public class SimpleInteraction +{ + public ButtonBuilder Button { get; } + private readonly Func _onClick; + private readonly T? _state; + + public SimpleInteraction(ButtonBuilder button, Func onClick, T? state = default) + { + Button = button; + _onClick = onClick; + _state = state; + } + + public async Task TriggerAsync(SocketMessageComponent smc) + { + await _onClick(smc, _state!); + } +} diff --git a/src/Ellie.Bot.Common/Marmalade/IMarmaladeLoaderService.cs b/src/Ellie.Bot.Common/Marmalade/IMarmaladeLoaderService.cs new file mode 100644 index 0000000..b34bf46 --- /dev/null +++ b/src/Ellie.Bot.Common/Marmalade/IMarmaladeLoaderService.cs @@ -0,0 +1,24 @@ +using System.Globalization; + +namespace Ellie.Marmalade; + +public interface IMarmaladeLoaderSevice +{ + Task LoadMarmaladeAsync(string marmaladeName); + Task UnloadMarmaladeAsync(string marmaladeName); + string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture); + string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture); + Task ReloadStrings(); + IReadOnlyCollection GetAllMarmalades(); + IReadOnlyCollection GetLoadedMarmalades(CultureInfo? cultureInfo = null); +} + +public sealed record MarmaladeStats(string Name, + string? Description, + IReadOnlyCollection Canaries); + +public sealed record CanaryStats(string Name, + string? Prefix, + IReadOnlyCollection Commands); + +public sealed record CanaryCommandStats(string Name); \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Marmalade/MarmaladeLoadResult.cs b/src/Ellie.Bot.Common/Marmalade/MarmaladeLoadResult.cs new file mode 100644 index 0000000..8ae4850 --- /dev/null +++ b/src/Ellie.Bot.Common/Marmalade/MarmaladeLoadResult.cs @@ -0,0 +1,10 @@ +namespace Ellie.Marmalade; + +public enum MarmaladeLoadResult +{ + Success, + NotFound, + AlreadyLoaded, + Empty, + UnknownError, +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Marmalade/MarmaladeUnloadResult.cs b/src/Ellie.Bot.Common/Marmalade/MarmaladeUnloadResult.cs new file mode 100644 index 0000000..bb43ffb --- /dev/null +++ b/src/Ellie.Bot.Common/Marmalade/MarmaladeUnloadResult.cs @@ -0,0 +1,9 @@ +namespace Ellie.Marmalade; + +public enum MarmaladeUnloadResult +{ + Success, + NotLoaded, + PossiblyUnable, + NotFound, +} diff --git a/src/Ellie.Bot.Common/MessageType.cs b/src/Ellie.Bot.Common/MessageType.cs new file mode 100644 index 0000000..0eea29a --- /dev/null +++ b/src/Ellie.Bot.Common/MessageType.cs @@ -0,0 +1,8 @@ +namespace Ellie.Common; + +public enum MsgType +{ + Ok, + Pending, + Error +} diff --git a/src/Ellie.Bot.Common/ModuleBehaviors/IBehavior.cs b/src/Ellie.Bot.Common/ModuleBehaviors/IBehavior.cs new file mode 100644 index 0000000..1912c54 --- /dev/null +++ b/src/Ellie.Bot.Common/ModuleBehaviors/IBehavior.cs @@ -0,0 +1,6 @@ +namespace Ellie.Common.ModuleBehaviors; + +public interface IBehavior +{ + public virtual string Name => this.GetType().Name; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/ModuleBehaviors/IExecNoCommand.cs b/src/Ellie.Bot.Common/ModuleBehaviors/IExecNoCommand.cs new file mode 100644 index 0000000..d8f5bc7 --- /dev/null +++ b/src/Ellie.Bot.Common/ModuleBehaviors/IExecNoCommand.cs @@ -0,0 +1,19 @@ +namespace Ellie.Common.ModuleBehaviors; + +/// +/// Executed if no command was found for this message +/// +public interface IExecNoCommand : IBehavior +{ + /// + /// Executed at the end of the lifecycle if no command was found + /// → + /// → + /// → + /// [ | **] + /// + /// + /// + /// A task representing completion + Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg); +} diff --git a/src/Ellie.Bot.Common/ModuleBehaviors/IExecOnMessage.cs b/src/Ellie.Bot.Common/ModuleBehaviors/IExecOnMessage.cs new file mode 100644 index 0000000..1802d56 --- /dev/null +++ b/src/Ellie.Bot.Common/ModuleBehaviors/IExecOnMessage.cs @@ -0,0 +1,21 @@ +namespace Ellie.Common.ModuleBehaviors; + +/// +/// Implemented by modules to handle non-bot messages received +/// +public interface IExecOnMessage : IBehavior +{ + int Priority { get; } + + /// + /// Ran after a non-bot message was received + /// ** → + /// → + /// → + /// [ | ] + /// + /// Guild where the message was sent + /// The message that was received + /// Whether further processing of this message should be blocked + Task ExecOnMessageAsync(IGuild guild, IUserMessage msg); +} diff --git a/src/Ellie.Bot.Common/ModuleBehaviors/IExecPostCommand.cs b/src/Ellie.Bot.Common/ModuleBehaviors/IExecPostCommand.cs new file mode 100644 index 0000000..7491cfe --- /dev/null +++ b/src/Ellie.Bot.Common/ModuleBehaviors/IExecPostCommand.cs @@ -0,0 +1,22 @@ +namespace Ellie.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after the command successfully finished execution. +/// ***There is no support for this method in NadekoBot services.*** +/// It is only meant to be used in medusa system +/// +public interface IExecPostCommand : IBehavior +{ + /// + /// Executed after a command was successfully executed + /// → + /// → + /// → + /// [** | ] + /// + /// Command context + /// Module name + /// Command name + /// A task representing completion + ValueTask ExecPostCommandAsync(ICommandContext ctx, string moduleName, string commandName); +} diff --git a/src/Ellie.Bot.Common/ModuleBehaviors/IExecPreCommand.cs b/src/Ellie.Bot.Common/ModuleBehaviors/IExecPreCommand.cs new file mode 100644 index 0000000..dc3139c --- /dev/null +++ b/src/Ellie.Bot.Common/ModuleBehaviors/IExecPreCommand.cs @@ -0,0 +1,25 @@ +namespace Ellie.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after a command was found but before it was executed. +/// Able to block further processing of a command +/// +public interface IExecPreCommand : IBehavior +{ + public int Priority { get; } + + /// + /// + /// Ran after a command was found but before execution. + /// + /// → + /// → + /// ** → + /// [ | ] + /// + /// Command context + /// Name of the module + /// Command info + /// Whether further processing of the command is blocked + Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command); +} diff --git a/src/Ellie.Bot.Common/ModuleBehaviors/IInputTransformer.cs b/src/Ellie.Bot.Common/ModuleBehaviors/IInputTransformer.cs new file mode 100644 index 0000000..0b3df45 --- /dev/null +++ b/src/Ellie.Bot.Common/ModuleBehaviors/IInputTransformer.cs @@ -0,0 +1,25 @@ +namespace Ellie.Common.ModuleBehaviors; + +/// +/// Implemented by services which may transform input before a command is searched for +/// +public interface IInputTransformer : IBehavior +{ + /// + /// Ran after a non-bot message was received + /// -> + /// ** -> + /// -> + /// [ OR ] + /// + /// Guild + /// Channel in which the message was sent + /// User who sent the message + /// Content of the message + /// New input, if any, otherwise null + Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input); +} diff --git a/src/Ellie.Bot.Common/ModuleBehaviors/IReadyExecutor.cs b/src/Ellie.Bot.Common/ModuleBehaviors/IReadyExecutor.cs new file mode 100644 index 0000000..ba5717c --- /dev/null +++ b/src/Ellie.Bot.Common/ModuleBehaviors/IReadyExecutor.cs @@ -0,0 +1,13 @@ +namespace Ellie.Common.ModuleBehaviors; + +/// +/// All services which need to execute something after +/// the bot is ready should implement this interface +/// +public interface IReadyExecutor : IBehavior +{ + /// + /// Executed when bot is ready + /// + public Task OnReadyAsync(); +} diff --git a/src/Ellie.Bot.Common/Patronage/FeatureLimitKey.cs b/src/Ellie.Bot.Common/Patronage/FeatureLimitKey.cs new file mode 100644 index 0000000..d813ef6 --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/FeatureLimitKey.cs @@ -0,0 +1,7 @@ +namespace Ellie.Modules.Patronage; + +public readonly struct FeatureLimitKey +{ + public string PrettyName { get; init; } + public string Key { get; init; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/FeatureQuotaStats.cs b/src/Ellie.Bot.Common/Patronage/FeatureQuotaStats.cs new file mode 100644 index 0000000..6cfcf54 --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/FeatureQuotaStats.cs @@ -0,0 +1,8 @@ +namespace Ellie.Modules.Patronage; + +public readonly struct FeatureQuotaStats +{ + public (uint Cur, uint Max) Hourly { get; init; } + public (uint Cur, uint Max) Daily { get; init; } + public (uint Cur, uint Max) Monthly { get; init; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/IPatronData.cs b/src/Ellie.Bot.Common/Patronage/IPatronData.cs new file mode 100644 index 0000000..66812ba --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/IPatronData.cs @@ -0,0 +1,11 @@ +namespace Ellie.Modules.Patronage; + +public interface ISubscriberData +{ + public string UniquePlatformUserId { get; } + public ulong UserId { get; } + public int Cents { get; } + + public DateTime? LastCharge { get; } + public SubscriptionChargeStatus ChargeStatus { get; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/IPatronageService.cs b/src/Ellie.Bot.Common/Patronage/IPatronageService.cs new file mode 100644 index 0000000..b6e7f01 --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/IPatronageService.cs @@ -0,0 +1,56 @@ +using Ellie.Db.Models; +using OneOf; + +namespace Ellie.Modules.Patronage; + +/// +/// Manages patrons and provides access to their data +/// +public interface IPatronageService +{ + /// + /// Called when the payment is made. + /// Either as a single payment for that patron, + /// or as a recurring monthly donation. + /// + public event Func OnNewPatronPayment; + + /// + /// Called when the patron changes the pledge amount + /// (Patron old, Patron new) => Task + /// + public event Func OnPatronUpdated; + + /// + /// Called when the patron refunds the purchase or it's marked as fraud + /// + public event Func OnPatronRefunded; + + /// + /// Gets a Patron with the specified userId + /// + /// UserId for which to get the patron data for. + /// A patron with the specifeid userId + public Task GetPatronAsync(ulong userId); + + /// + /// Gets the quota statistic for the user/patron specified by the userId + /// + /// UserId of the user for which to get the quota statistic for + /// Quota stats for the specified user + Task GetUserQuotaStatistic(ulong userId); + + + Task TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue); + + ValueTask> TryIncrementQuotaCounterAsync( + ulong userId, + bool isSelf, + FeatureType featureType, + string featureName, + uint? maybeHourly, + uint? maybeDaily, + uint? maybeMonthly); + + PatronConfigData GetConfig(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/ISubscriptionHandler.cs b/src/Ellie.Bot.Common/Patronage/ISubscriptionHandler.cs new file mode 100644 index 0000000..83ee2ba --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/ISubscriptionHandler.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace Ellie.Modules.Patronage; + +/// +/// Services implementing this interface are handling pledges/subscriptions/payments coming +/// from a payment platform. +/// +public interface ISubscriptionHandler +{ + /// + /// Get Current patrons in batches. + /// This will only return patrons who have their discord account connected + /// + /// Batched patrons + public IAsyncEnumerable> GetPatronsAsync(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/Patron.cs b/src/Ellie.Bot.Common/Patronage/Patron.cs new file mode 100644 index 0000000..d8d5713 --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/Patron.cs @@ -0,0 +1,38 @@ +namespace Ellie.Modules.Patronage; + +public readonly struct Patron +{ + /// + /// Unique id assigned to this patron by the payment platform + /// + public string UniquePlatformUserId { get; init; } + + /// + /// Discord UserId to which this is connected to + /// + public ulong UserId { get; init; } + + /// + /// Amount the Patron is currently pledging or paid + /// + public int Amount { get; init; } + + /// + /// Current Tier of the patron + /// (do not question it in consumer classes, as the calculation should be always internal and may change) + /// + public PatronTier Tier { get; init; } + + /// + /// When was the last time this was paid + /// + public DateTime PaidAt { get; init; } + + /// + /// After which date does the user's Patronage benefit end + /// + public DateTime ValidThru { get; init; } + + public bool IsActive + => !ValidThru.IsBeforeToday(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/PatronConfigData.cs b/src/Ellie.Bot.Common/Patronage/PatronConfigData.cs new file mode 100644 index 0000000..c0a14b5 --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/PatronConfigData.cs @@ -0,0 +1,37 @@ +using Ellie.Common.Yml; +using Cloneable; + +namespace Ellie.Modules.Patronage; + +[Cloneable] +public partial class PatronConfigData : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 2; + + [Comment("Whether the patronage feature is enabled")] + public bool IsEnabled { get; set; } + + [Comment("List of patron only features and relevant quota data")] + public FeatureQuotas Quotas { get; set; } + + public PatronConfigData() + { + Quotas = new(); + } + + public class FeatureQuotas + { + [Comment("Dictionary of feature names with their respective limits. Set to null for unlimited")] + public Dictionary> Features { get; set; } = new(); + + [Comment("Dictionary of commands with their respective quota data")] + public Dictionary?>> Commands { get; set; } = new(); + + [Comment("Dictionary of groups with their respective quota data")] + public Dictionary?>> Groups { get; set; } = new(); + + [Comment("Dictionary of modules with their respective quota data")] + public Dictionary?>> Modules { get; set; } = new(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/PatronExtensions.cs b/src/Ellie.Bot.Common/Patronage/PatronExtensions.cs new file mode 100644 index 0000000..93ba48e --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/PatronExtensions.cs @@ -0,0 +1,33 @@ +namespace Ellie.Modules.Patronage; + +public static class PatronExtensions +{ + public static string ToFullName(this PatronTier tier) + => tier switch + { + _ => $"Patron Tier {tier}", + }; + + public static string ToFullName(this QuotaPer per) + => per.Humanize(LetterCasing.LowerCase); + + public static DateTime DayOfNextMonth(this DateTime date, int day) + { + var nextMonth = date.AddMonths(1); + var dt = DateTime.SpecifyKind(new(nextMonth.Year, nextMonth.Month, day), DateTimeKind.Utc); + return dt; + } + + public static DateTime FirstOfNextMonth(this DateTime date) + => date.DayOfNextMonth(1); + + public static DateTime SecondOfNextMonth(this DateTime date) + => date.DayOfNextMonth(2); + + public static string ToShortAndRelativeTimestampTag(this DateTime date) + { + var fullResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.ShortDateTime); + var relativeResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.Relative); + return $"{fullResetStr}\n{relativeResetStr}"; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/PatronTier.cs b/src/Ellie.Bot.Common/Patronage/PatronTier.cs new file mode 100644 index 0000000..e0cab8a --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/PatronTier.cs @@ -0,0 +1,14 @@ +// ReSharper disable InconsistentNaming +namespace Ellie.Modules.Patronage; + +public enum PatronTier +{ + None, + I, + V, + X, + XX, + L, + C, + ComingSoon +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/QuotaLimit.cs b/src/Ellie.Bot.Common/Patronage/QuotaLimit.cs new file mode 100644 index 0000000..b8f600c --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/QuotaLimit.cs @@ -0,0 +1,66 @@ +using Ellie.Db.Models; + +namespace Ellie.Modules.Patronage; + +/// +/// Represents information about why the user has triggered a quota limit +/// +public readonly struct QuotaLimit +{ + /// + /// Amount of usages reached, which is the limit + /// + public uint Quota { get; init; } + + /// + /// Which period is this quota limit for (hourly, daily, monthly, etc...) + /// + public QuotaPer QuotaPeriod { get; init; } + + /// + /// When does this quota limit reset + /// + public DateTime ResetsAt { get; init; } + + /// + /// Type of the feature this quota limit is for + /// + public FeatureType FeatureType { get; init; } + + /// + /// Name of the feature this quota limit is for + /// + public string Feature { get; init; } + + /// + /// Whether it is the user's own quota (true), or server owners (false) + /// + public bool IsOwnQuota { get; init; } +} + + +/// +/// Respresent information about the feature limit +/// +public readonly struct FeatureLimit +{ + + /// + /// Whether this limit comes from the patronage system + /// + public bool IsPatronLimit { get; init; } = false; + + /// + /// Maximum limit allowed + /// + public int? Quota { get; init; } = null; + + /// + /// Name of the limit + /// + public string Name { get; init; } = string.Empty; + + public FeatureLimit() + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/QuotaPer.cs b/src/Ellie.Bot.Common/Patronage/QuotaPer.cs new file mode 100644 index 0000000..1849dd8 --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/QuotaPer.cs @@ -0,0 +1,8 @@ +namespace Ellie.Modules.Patronage; + +public enum QuotaPer +{ + PerHour, + PerDay, + PerMonth, +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/SubscriptionChargeStatus.cs b/src/Ellie.Bot.Common/Patronage/SubscriptionChargeStatus.cs new file mode 100644 index 0000000..acd2852 --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/SubscriptionChargeStatus.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace Ellie.Modules.Patronage; + +public enum SubscriptionChargeStatus +{ + Paid, + Refunded, + Unpaid, + Other, +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Patronage/UserQuotaStats.cs b/src/Ellie.Bot.Common/Patronage/UserQuotaStats.cs new file mode 100644 index 0000000..13028cc --- /dev/null +++ b/src/Ellie.Bot.Common/Patronage/UserQuotaStats.cs @@ -0,0 +1,25 @@ +namespace Ellie.Modules.Patronage; + +public readonly struct UserQuotaStats +{ + private static readonly IReadOnlyDictionary _emptyDictionary + = new Dictionary(); + public PatronTier Tier { get; init; } + = PatronTier.None; + + public IReadOnlyDictionary Features { get; init; } + = _emptyDictionary; + + public IReadOnlyDictionary Commands { get; init; } + = _emptyDictionary; + + public IReadOnlyDictionary Groups { get; init; } + = _emptyDictionary; + + public IReadOnlyDictionary Modules { get; init; } + = _emptyDictionary; + + public UserQuotaStats() + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Replacements/ReplacementBuilder.cs b/src/Ellie.Bot.Common/Replacements/ReplacementBuilder.cs new file mode 100644 index 0000000..b842e63 --- /dev/null +++ b/src/Ellie.Bot.Common/Replacements/ReplacementBuilder.cs @@ -0,0 +1,164 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace Ellie.Common; + +public class ReplacementBuilder +{ + private static readonly Regex _rngRegex = new("%rng(?:(?(?:-)?\\d+)-(?(?:-)?\\d+))?%", + RegexOptions.Compiled); + + private readonly ConcurrentDictionary> _regex = new(); + + private readonly ConcurrentDictionary> _reps = new(); + + public ReplacementBuilder() + => WithRngRegex(); + + public ReplacementBuilder WithDefault( + IUser usr, + IMessageChannel ch, + SocketGuild g, + DiscordSocketClient client) + => WithUser(usr).WithChannel(ch).WithServer(client, g).WithClient(client); + + public ReplacementBuilder WithDefault(ICommandContext ctx) + => WithDefault(ctx.User, ctx.Channel, ctx.Guild as SocketGuild, (DiscordSocketClient)ctx.Client); + + public ReplacementBuilder WithMention(DiscordSocketClient client) + { + _reps.TryAdd("%bot.mention%", () => client.CurrentUser.Mention); + return this; + } + + public ReplacementBuilder WithClient(DiscordSocketClient client) + { + WithMention(client); + + _reps.TryAdd("%bot.status%", () => client.Status.ToString()); + _reps.TryAdd("%bot.latency%", () => client.Latency.ToString()); + _reps.TryAdd("%bot.name%", () => client.CurrentUser.Username); + _reps.TryAdd("%bot.fullname%", () => client.CurrentUser.ToString()); + _reps.TryAdd("%bot.time%", + () => DateTime.Now.ToString("HH:mm " + TimeZoneInfo.Local.StandardName.GetInitials())); + _reps.TryAdd("%bot.discrim%", () => client.CurrentUser.Discriminator); + _reps.TryAdd("%bot.id%", () => client.CurrentUser.Id.ToString()); + _reps.TryAdd("%bot.avatar%", () => client.CurrentUser.RealAvatarUrl().ToString()); + + WithStats(client); + return this; + } + + public ReplacementBuilder WithServer(DiscordSocketClient client, SocketGuild g) + { + _reps.TryAdd("%server%", () => g is null ? "DM" : g.Name); + _reps.TryAdd("%server.id%", () => g is null ? "DM" : g.Id.ToString()); + _reps.TryAdd("%server.name%", () => g is null ? "DM" : g.Name); + _reps.TryAdd("%server.icon%", () => g is null ? null : g.IconUrl); + _reps.TryAdd("%server.members%", () => g is { } sg ? sg.MemberCount.ToString() : "?"); + _reps.TryAdd("%server.boosters%", () => g.PremiumSubscriptionCount.ToString()); + _reps.TryAdd("%server.boost_level%", () => ((int)g.PremiumTier).ToString()); + // todo fix + // _reps.TryAdd("%server.time%", + // () => + // { + // var to = TimeZoneInfo.Local; + // if (g is not null) + // { + // if (GuildTimezoneService.AllServices.TryGetValue(client.CurrentUser.Id, out var tz)) + // to = tz.GetTimeZoneOrDefault(g.Id) ?? TimeZoneInfo.Local; + // } + // + // return TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.Utc, to).ToString("HH:mm ") + // + to.StandardName.GetInitials(); + // }); + return this; + } + + public ReplacementBuilder WithChannel(IMessageChannel ch) + { + _reps.TryAdd("%channel%", () => ch.Name); + _reps.TryAdd("%channel.mention%", () => (ch as ITextChannel)?.Mention ?? "#" + ch.Name); + _reps.TryAdd("%channel.name%", () => ch.Name); + _reps.TryAdd("%channel.id%", () => ch.Id.ToString()); + _reps.TryAdd("%channel.created%", () => ch.CreatedAt.ToString("HH:mm dd.MM.yyyy")); + _reps.TryAdd("%channel.nsfw%", () => (ch as ITextChannel)?.IsNsfw.ToString() ?? "-"); + _reps.TryAdd("%channel.topic%", () => (ch as ITextChannel)?.Topic ?? "-"); + return this; + } + + public ReplacementBuilder WithUser(IUser user) + { + WithManyUsers(new[] { user }); + return this; + } + + public ReplacementBuilder WithManyUsers(IEnumerable users) + { + _reps.TryAdd("%user%", () => string.Join(" ", users.Select(user => user.Mention))); + _reps.TryAdd("%user.mention%", () => string.Join(" ", users.Select(user => user.Mention))); + _reps.TryAdd("%user.fullname%", () => string.Join(" ", users.Select(user => user.ToString()))); + _reps.TryAdd("%user.name%", () => string.Join(" ", users.Select(user => user.Username))); + _reps.TryAdd("%user.discrim%", () => string.Join(" ", users.Select(user => user.Discriminator))); + _reps.TryAdd("%user.avatar%", () => string.Join(" ", users.Select(user => user.RealAvatarUrl().ToString()))); + _reps.TryAdd("%user.id%", () => string.Join(" ", users.Select(user => user.Id.ToString()))); + _reps.TryAdd("%user.created_time%", + () => string.Join(" ", users.Select(user => user.CreatedAt.ToString("HH:mm")))); + _reps.TryAdd("%user.created_date%", + () => string.Join(" ", users.Select(user => user.CreatedAt.ToString("dd.MM.yyyy")))); + _reps.TryAdd("%user.joined_time%", + () => string.Join(" ", users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("HH:mm") ?? "-"))); + _reps.TryAdd("%user.joined_date%", + () => string.Join(" ", + users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("dd.MM.yyyy") ?? "-"))); + return this; + } + + private ReplacementBuilder WithStats(DiscordSocketClient c) + { + _reps.TryAdd("%shard.servercount%", () => c.Guilds.Count.ToString()); + _reps.TryAdd("%shard.usercount%", () => c.Guilds.Sum(g => g.MemberCount).ToString()); + _reps.TryAdd("%shard.id%", () => c.ShardId.ToString()); + return this; + } + + public ReplacementBuilder WithRngRegex() + { + var rng = new EllieRandom(); + _regex.TryAdd(_rngRegex, + match => + { + if (!int.TryParse(match.Groups["from"].ToString(), out var from)) + from = 0; + if (!int.TryParse(match.Groups["to"].ToString(), out var to)) + to = 0; + + if (from == 0 && to == 0) + return rng.Next(0, 11).ToString(); + + if (from >= to) + return string.Empty; + + return rng.Next(from, to + 1).ToString(); + }); + return this; + } + + public ReplacementBuilder WithOverride(string key, Func output) + { + _reps.AddOrUpdate(key, output, delegate { return output; }); + return this; + } + + public Replacer Build() + => new(_reps.Select(x => (x.Key, x.Value)).ToArray(), _regex.Select(x => (x.Key, x.Value)).ToArray()); + + public ReplacementBuilder WithProviders(IEnumerable phProviders) + { + foreach (var provider in phProviders) + foreach (var ovr in provider.GetPlaceholders()) + _reps.TryAdd(ovr.Name, ovr.Func); + + return this; + } +} diff --git a/src/Ellie.Bot.Common/Replacements/Replacer.cs b/src/Ellie.Bot.Common/Replacements/Replacer.cs new file mode 100644 index 0000000..aaee24f --- /dev/null +++ b/src/Ellie.Bot.Common/Replacements/Replacer.cs @@ -0,0 +1,93 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace Ellie.Common; + +public class Replacer +{ + private readonly IEnumerable<(Regex Regex, Func Replacement)> _regex; + private readonly IEnumerable<(string Key, Func Text)> _replacements; + + public Replacer(IEnumerable<(string, Func)> replacements, IEnumerable<(Regex, Func)> regex) + { + _replacements = replacements; + _regex = regex; + } + + public string Replace(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return input; + + foreach (var (key, text) in _replacements) + { + if (input.Contains(key)) + input = input.Replace(key, text(), StringComparison.InvariantCulture); + } + + foreach (var item in _regex) + input = item.Regex.Replace(input, m => item.Replacement(m)); + + return input; + } + + public SmartText Replace(SmartText data) + => data switch + { + SmartEmbedText embedData => Replace(embedData) with + { + PlainText = Replace(embedData.PlainText), + Color = embedData.Color + }, + SmartPlainText plain => Replace(plain), + SmartEmbedTextArray arr => Replace(arr), + _ => throw new ArgumentOutOfRangeException(nameof(data), "Unsupported argument type") + }; + + private SmartEmbedTextArray Replace(SmartEmbedTextArray embedArr) + => new() + { + Embeds = embedArr.Embeds.Map(e => Replace(e) with + { + Color = e.Color + }), + Content = Replace(embedArr.Content) + }; + + private SmartPlainText Replace(SmartPlainText plain) + => Replace(plain.Text); + + private T Replace(T embedData) where T : SmartEmbedTextBase, new() + { + var newEmbedData = new T + { + Description = Replace(embedData.Description), + Title = Replace(embedData.Title), + Thumbnail = Replace(embedData.Thumbnail), + Image = Replace(embedData.Image), + Url = Replace(embedData.Url), + Author = embedData.Author is null + ? null + : new() + { + Name = Replace(embedData.Author.Name), + IconUrl = Replace(embedData.Author.IconUrl) + }, + Fields = embedData.Fields?.Map(f => new SmartTextEmbedField + { + Name = Replace(f.Name), + Value = Replace(f.Value), + Inline = f.Inline + }), + Footer = embedData.Footer is null + ? null + : new() + { + Text = Replace(embedData.Footer.Text), + IconUrl = Replace(embedData.Footer.IconUrl) + } + }; + + return newEmbedData; + } +} diff --git a/src/Ellie.Bot.Common/Services/CommandHandler.cs b/src/Ellie.Bot.Common/Services/CommandHandler.cs new file mode 100644 index 0000000..43fb15b --- /dev/null +++ b/src/Ellie.Bot.Common/Services/CommandHandler.cs @@ -0,0 +1,427 @@ +#nullable disable +using Ellie.Common.Configs; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using ExecuteResult = Discord.Commands.ExecuteResult; +using PreconditionResult = Discord.Commands.PreconditionResult; + +namespace Ellie.Services; + +public class CommandHandler : IEService, IReadyExecutor, ICommandHandler +{ + private const int GLOBAL_COMMANDS_COOLDOWN = 750; + + private const float ONE_THOUSANDTH = 1.0f / 1000; + + public event Func CommandExecuted = delegate { return Task.CompletedTask; }; + public event Func CommandErrored = delegate { return Task.CompletedTask; }; + + //userid/msg count + public ConcurrentDictionary UserMessagesSent { get; } = new(); + + public ConcurrentHashSet UsersOnShortCooldown { get; } = new(); + + private readonly DiscordSocketClient _client; + private readonly CommandService _commandService; + private readonly BotConfigService _bss; + private readonly IBot _bot; + private readonly IBehaviorHandler _behaviorHandler; + private readonly IServiceProvider _services; + + private readonly ConcurrentDictionary _prefixes; + + private readonly DbService _db; + // private readonly InteractionService _interactions; + + public CommandHandler( + DiscordSocketClient client, + DbService db, + CommandService commandService, + BotConfigService bss, + IBot bot, + IBehaviorHandler behaviorHandler, + // InteractionService interactions, + IServiceProvider services) + { + _client = client; + _commandService = commandService; + _bss = bss; + _bot = bot; + _behaviorHandler = behaviorHandler; + _db = db; + _services = services; + // _interactions = interactions; + + _prefixes = bot.AllGuildConfigs.Where(x => x.Prefix is not null) + .ToDictionary(x => x.GuildId, x => x.Prefix) + .ToConcurrent(); + + } + + public async Task OnReadyAsync() + { + Log.Information("Command handler runnning on ready"); + // clear users on short cooldown every GLOBAL_COMMANDS_COOLDOWN miliseconds + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(GLOBAL_COMMANDS_COOLDOWN)); + while (await timer.WaitForNextTickAsync()) + UsersOnShortCooldown.Clear(); + } + + public string GetPrefix(IGuild guild) + => GetPrefix(guild?.Id); + + public string GetPrefix(ulong? id = null) + { + if (id is null || !_prefixes.TryGetValue(id.Value, out var prefix)) + return _bss.Data.Prefix; + + return prefix; + } + + public string SetDefaultPrefix(string prefix) + { + if (string.IsNullOrWhiteSpace(prefix)) + throw new ArgumentNullException(nameof(prefix)); + + _bss.ModifyConfig(bs => + { + bs.Prefix = prefix; + }); + + return prefix; + } + + public string SetPrefix(IGuild guild, string prefix) + { + if (string.IsNullOrWhiteSpace(prefix)) + throw new ArgumentNullException(nameof(prefix)); + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guild.Id, set => set); + gc.Prefix = prefix; + uow.SaveChanges(); + } + + _prefixes[guild.Id] = prefix; + + return prefix; + } + + public async Task ExecuteExternal(ulong? guildId, ulong channelId, string commandText) + { + if (guildId is not null) + { + var guild = _client.GetGuild(guildId.Value); + if (guild?.GetChannel(channelId) is not SocketTextChannel channel) + { + Log.Warning("Channel for external execution not found"); + return; + } + + try + { + IUserMessage msg = await channel.SendMessageAsync(commandText); + msg = (IUserMessage)await channel.GetMessageAsync(msg.Id); + await TryRunCommand(guild, channel, msg); + //msg.DeleteAfter(5); + } + catch { } + } + } + + public Task StartHandling() + { + _client.MessageReceived += MessageReceivedHandler; + // _client.SlashCommandExecuted += SlashCommandExecuted; + return Task.CompletedTask; + } + + // private async Task SlashCommandExecuted(SocketSlashCommand arg) + // { + // var ctx = new SocketInteractionContext(_client, arg); + // await _interactions.ExecuteCommandAsync(ctx, _services); + // } + + private Task LogSuccessfulExecution(IUserMessage usrMsg, ITextChannel channel, params int[] execPoints) + { + if (_bss.Data.ConsoleOutputType == ConsoleOutputType.Normal) + { + Log.Information(""" + Command Executed after {ExecTime}s + User: {User} + Server: {Server} + Channel: {Channel} + Message: {Message} + """, + string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))), + usrMsg.Author + " [" + usrMsg.Author.Id + "]", + channel is null ? "PRIVATE" : channel.Guild.Name + " [" + channel.Guild.Id + "]", + channel is null ? "PRIVATE" : channel.Name + " [" + channel.Id + "]", + usrMsg.Content); + } + else + { + Log.Information("Succ | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message}", + channel?.Guild.Id.ToString() ?? "-", + channel?.Id.ToString() ?? "-", + usrMsg.Author.Id, + usrMsg.Content.TrimTo(10)); + } + + return Task.CompletedTask; + } + + private void LogErroredExecution( + string errorMessage, + IUserMessage usrMsg, + ITextChannel channel, + params int[] execPoints) + { + if (_bss.Data.ConsoleOutputType == ConsoleOutputType.Normal) + { + Log.Warning(""" + Command Errored after {ExecTime}s + User: {User} + Server: {Guild} + Channel: {Channel} + Message: {Message} + Error: {ErrorMessage} + """, + string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))), + usrMsg.Author + " [" + usrMsg.Author.Id + "]", + channel is null ? "DM" : channel.Guild.Name + " [" + channel.Guild.Id + "]", + channel is null ? "DM" : channel.Name + " [" + channel.Id + "]", + usrMsg.Content, + errorMessage); + } + else + { + Log.Warning(""" + Err | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message} + Err: {ErrorMessage} + """, + channel?.Guild.Id.ToString() ?? "-", + channel?.Id.ToString() ?? "-", + usrMsg.Author.Id, + usrMsg.Content.TrimTo(10), + errorMessage); + } + } + + private Task MessageReceivedHandler(SocketMessage msg) + { + //no bots, wait until bot connected and initialized + if (msg.Author.IsBot || !_bot.IsReady) + return Task.CompletedTask; + + if (msg is not SocketUserMessage usrMsg) + return Task.CompletedTask; + + Task.Run(async () => + { + try + { +#if !GLOBAL_NADEKO + // track how many messages each user is sending + UserMessagesSent.AddOrUpdate(usrMsg.Author.Id, 1, (_, old) => ++old); +#endif + + var channel = msg.Channel; + var guild = (msg.Channel as SocketTextChannel)?.Guild; + + await TryRunCommand(guild, channel, usrMsg); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in CommandHandler"); + if (ex.InnerException is not null) + Log.Warning(ex.InnerException, "Inner Exception of the error in CommandHandler"); + } + }); + + return Task.CompletedTask; + } + + public async Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg) + { + var startTime = Environment.TickCount; + + var blocked = await _behaviorHandler.RunExecOnMessageAsync(guild, usrMsg); + if (blocked) + return; + + var blockTime = Environment.TickCount - startTime; + + var messageContent = await _behaviorHandler.RunInputTransformersAsync(guild, usrMsg); + + var prefix = GetPrefix(guild?.Id); + var isPrefixCommand = messageContent.StartsWith(".prefix", StringComparison.InvariantCultureIgnoreCase); + // execute the command and measure the time it took + if (isPrefixCommand || messageContent.StartsWith(prefix, StringComparison.InvariantCulture)) + { + var context = new CommandContext(_client, usrMsg); + var (success, error, info) = await ExecuteCommandAsync(context, + messageContent, + isPrefixCommand ? 1 : prefix.Length, + _services, + MultiMatchHandling.Best); + + startTime = Environment.TickCount - startTime; + + // if a command is found + if (info is not null) + { + // if it successfully executed + if (success) + { + await LogSuccessfulExecution(usrMsg, channel as ITextChannel, blockTime, startTime); + await CommandExecuted(usrMsg, info); + await _behaviorHandler.RunPostCommandAsync(context, info.Module.GetTopLevelModule().Name, info); + return; + } + + // if it errored + if (error is not null) + { + error = HumanizeError(error); + LogErroredExecution(error, usrMsg, channel as ITextChannel, blockTime, startTime); + + if (guild is not null) + await CommandErrored(info, channel as ITextChannel, error); + + return; + } + } + } + + await _behaviorHandler.RunOnNoCommandAsync(guild, usrMsg); + } + + private string HumanizeError(string error) + { + if (error.Contains("parse int", StringComparison.OrdinalIgnoreCase) + || error.Contains("parse float")) + return "Invalid number specified. Make sure you're specifying parameters in the correct order."; + + return error; + } + + public Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommandAsync( + ICommandContext context, + string input, + int argPos, + IServiceProvider serviceProvider, + MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + => ExecuteCommand(context, input[argPos..], serviceProvider, multiMatchHandling); + + + public async Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommand( + ICommandContext context, + string input, + IServiceProvider services, + MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + { + var searchResult = _commandService.Search(context, input); + if (!searchResult.IsSuccess) + return (false, null, null); + + var commands = searchResult.Commands; + var preconditionResults = new Dictionary(); + + foreach (var match in commands) + preconditionResults[match] = await match.Command.CheckPreconditionsAsync(context, services); + + var successfulPreconditions = preconditionResults.Where(x => x.Value.IsSuccess).ToArray(); + + if (successfulPreconditions.Length == 0) + { + //All preconditions failed, return the one from the highest priority command + var bestCandidate = preconditionResults.OrderByDescending(x => x.Key.Command.Priority) + .FirstOrDefault(x => !x.Value.IsSuccess); + return (false, bestCandidate.Value.ErrorReason, commands[0].Command); + } + + var parseResultsDict = new Dictionary(); + foreach (var pair in successfulPreconditions) + { + var parseResult = await pair.Key.ParseAsync(context, searchResult, pair.Value, services); + + if (parseResult.Error == CommandError.MultipleMatches) + { + IReadOnlyList argList, paramList; + switch (multiMatchHandling) + { + case MultiMatchHandling.Best: + argList = parseResult.ArgValues + .Map(x => x.Values.MaxBy(y => y.Score)); + paramList = parseResult.ParamValues + .Map(x => x.Values.MaxBy(y => y.Score)); + parseResult = ParseResult.FromSuccess(argList, paramList); + break; + } + } + + parseResultsDict[pair.Key] = parseResult; + } + + // Calculates the 'score' of a command given a parse result + float CalculateScore(CommandMatch match, ParseResult parseResult) + { + float argValuesScore = 0, paramValuesScore = 0; + + if (match.Command.Parameters.Count > 0) + { + var argValuesSum = + parseResult.ArgValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) + ?? 0; + var paramValuesSum = + parseResult.ParamValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) + ?? 0; + + argValuesScore = argValuesSum / match.Command.Parameters.Count; + paramValuesScore = paramValuesSum / match.Command.Parameters.Count; + } + + var totalArgsScore = (argValuesScore + paramValuesScore) / 2; + return match.Command.Priority + (totalArgsScore * 0.99f); + } + + //Order the parse results by their score so that we choose the most likely result to execute + var parseResults = parseResultsDict.OrderByDescending(x => CalculateScore(x.Key, x.Value)).ToList(); + + var successfulParses = parseResults.Where(x => x.Value.IsSuccess).ToArray(); + + if (successfulParses.Length == 0) + { + //All parses failed, return the one from the highest priority command, using score as a tie breaker + var bestMatch = parseResults.FirstOrDefault(x => !x.Value.IsSuccess); + return (false, bestMatch.Value.ErrorReason, commands[0].Command); + } + + var cmd = successfulParses[0].Key.Command; + + // Bot will ignore commands which are ran more often than what specified by + // GlobalCommandsCooldown constant (miliseconds) + if (!UsersOnShortCooldown.Add(context.Message.Author.Id)) + return (false, null, cmd); + //return SearchResult.FromError(CommandError.Exception, "You are on a global cooldown."); + + var blocked = await _behaviorHandler.RunPreCommandAsync(context, cmd); + if (blocked) + return (false, null, cmd); + + //If we get this far, at least one parse was successful. Execute the most likely overload. + var chosenOverload = successfulParses[0]; + var execResult = (ExecuteResult)await chosenOverload.Key.ExecuteAsync(context, chosenOverload.Value, services); + + if (execResult.Exception is not null + && (execResult.Exception is not HttpException he + || he.DiscordCode != DiscordErrorCode.InsufficientPermissions)) + Log.Warning(execResult.Exception, "Command Error"); + + return (true, null, cmd); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Currency/CurrencyService.cs b/src/Ellie.Bot.Common/Services/Currency/CurrencyService.cs new file mode 100644 index 0000000..873538e --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Currency/CurrencyService.cs @@ -0,0 +1,109 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Db.Models; +using Ellie.Services.Currency; + +namespace Ellie.Services; + +public sealed class CurrencyService : ICurrencyService, IEService +{ + private readonly DbService _db; + private readonly ITxTracker _txTracker; + + public CurrencyService(DbService db, ITxTracker txTracker) + { + _db = db; + _txTracker = txTracker; + } + + public Task GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + return Task.FromResult(new DefaultWallet(userId, _db)); + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task AddBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData txData, + CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + { + foreach (var userId in userIds) + { + var wallet = await GetWalletAsync(userId); + await wallet.Add(amount, txData); + } + + return; + } + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task RemoveBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData txData, + CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + { + await using var ctx = _db.GetDbContext(); + await ctx + .GetTable() + .Where(x => userIds.Contains(x.UserId)) + .UpdateAsync(du => new() + { + CurrencyAmount = du.CurrencyAmount >= amount + ? du.CurrencyAmount - amount + : 0 + }); + await ctx.SaveChangesAsync(); + return; + } + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task AddAsync( + ulong userId, + long amount, + TxData txData) + { + var wallet = await GetWalletAsync(userId); + await wallet.Add(amount, txData); + await _txTracker.TrackAdd(amount, txData); + } + + public async Task AddAsync( + IUser user, + long amount, + TxData txData) + => await AddAsync(user.Id, amount, txData); + + public async Task RemoveAsync( + ulong userId, + long amount, + TxData txData) + { + if (amount == 0) + return true; + + var wallet = await GetWalletAsync(userId); + var result = await wallet.Take(amount, txData); + if(result) + await _txTracker.TrackRemove(amount, txData); + return result; + } + + public async Task RemoveAsync( + IUser user, + long amount, + TxData txData) + => await RemoveAsync(user.Id, amount, txData); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Currency/CurrencyServiceExtensions.cs b/src/Ellie.Bot.Common/Services/Currency/CurrencyServiceExtensions.cs new file mode 100644 index 0000000..874430c --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Currency/CurrencyServiceExtensions.cs @@ -0,0 +1,39 @@ +using Ellie.Services.Currency; + +namespace Ellie.Services; + +public static class CurrencyServiceExtensions +{ + public static async Task GetBalanceAsync(this ICurrencyService cs, ulong userId) + { + var wallet = await cs.GetWalletAsync(userId); + return await wallet.GetBalance(); + } + + // FUTURE should be a transaction + public static async Task TransferAsync( + this ICurrencyService cs, + IEmbedBuilderService ebs, + IUser from, + IUser to, + long amount, + string? note, + string formattedAmount) + { + var fromWallet = await cs.GetWalletAsync(from.Id); + var toWallet = await cs.GetWalletAsync(to.Id); + + var extra = new TxData("gift", from.ToString()!, note, from.Id); + + if (await fromWallet.Transfer(amount, toWallet, extra)) + { + await to.SendConfirmAsync(ebs, + string.IsNullOrWhiteSpace(note) + ? $"Received {formattedAmount} from {from} " + : $"Received {formattedAmount} from {from}: {note}"); + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Currency/DefaultWallet.cs b/src/Ellie.Bot.Common/Services/Currency/DefaultWallet.cs new file mode 100644 index 0000000..f39ce2c --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Currency/DefaultWallet.cs @@ -0,0 +1,115 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Db.Models; +using Ellie.Services.Database.Models; + +namespace Ellie.Services.Currency; + +public class DefaultWallet : IWallet +{ + private readonly DbService _db; + public ulong UserId { get; } + + public DefaultWallet(ulong userId, DbService db) + { + UserId = userId; + _db = db; + } + + public async Task GetBalance() + { + await using var ctx = _db.GetDbContext(); + var userId = UserId; + return await ctx + .GetTable() + .Where(x => x.UserId == userId) + .Select(x => x.CurrencyAmount) + .FirstOrDefaultAsync(); + } + + public async Task Take(long amount, TxData? txData) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount to take must be non negative."); + + await using var ctx = _db.GetDbContext(); + + var userId = UserId; + var changed = await ctx + .GetTable() + .Where(x => x.UserId == userId && x.CurrencyAmount >= amount) + .UpdateAsync(x => new() + { + CurrencyAmount = x.CurrencyAmount - amount + }); + + if (changed == 0) + return false; + + if (txData is not null) + { + await ctx + .GetTable() + .InsertAsync(() => new() + { + Amount = -amount, + Note = txData.Note, + UserId = userId, + Type = txData.Type, + Extra = txData.Extra, + OtherId = txData.OtherId, + DateAdded = DateTime.UtcNow + }); + } + + return true; + } + + public async Task Add(long amount, TxData? txData) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0."); + + await using var ctx = _db.GetDbContext(); + var userId = UserId; + + await using (var tran = await ctx.Database.BeginTransactionAsync()) + { + var changed = await ctx + .GetTable() + .Where(x => x.UserId == userId) + .UpdateAsync(x => new() + { + CurrencyAmount = x.CurrencyAmount + amount + }); + + if (changed == 0) + { + await ctx + .GetTable() + .Value(x => x.UserId, userId) + .Value(x => x.Username, "Unknown") + .Value(x => x.Discriminator, "????") + .Value(x => x.CurrencyAmount, amount) + .InsertAsync(); + } + + await tran.CommitAsync(); + } + + if (txData is not null) + { + await ctx.GetTable() + .InsertAsync(() => new() + { + Amount = amount, + UserId = userId, + Note = txData.Note, + Type = txData.Type, + Extra = txData.Extra, + OtherId = txData.OtherId, + DateAdded = DateTime.UtcNow + }); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Currency/GamblingTxTracker.cs b/src/Ellie.Bot.Common/Services/Currency/GamblingTxTracker.cs new file mode 100644 index 0000000..9ad06f8 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Currency/GamblingTxTracker.cs @@ -0,0 +1,110 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Services.Currency; +using Ellie.Services.Database.Models; + +namespace Ellie.Services; + +public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor +{ + private static readonly IReadOnlySet _gamblingTypes = new HashSet(new[] + { + "lula", + "betroll", + "betflip", + "blackjack", + "betdraw", + "slot", + }); + + private ConcurrentDictionary _stats = new(); + + private readonly DbService _db; + + public GamblingTxTracker(DbService db) + { + _db = db; + } + + public async Task OnReadyAsync() + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + await using var ctx = _db.GetDbContext(); + await using var trans = await ctx.Database.BeginTransactionAsync(); + + try + { + var keys = _stats.Keys; + foreach (var key in keys) + { + if (_stats.TryRemove(key, out var stat)) + { + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + Feature = key, + Bet = stat.Bet, + PaidOut = stat.PaidOut, + DateAdded = DateTime.UtcNow + }, old => new() + { + Bet = old.Bet + stat.Bet, + PaidOut = old.PaidOut + stat.PaidOut, + }, () => new() + { + Feature = key + }); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "An error occurred in gambling tx tracker"); + } + finally + { + await trans.CommitAsync(); + } + } + } + + public Task TrackAdd(long amount, TxData? txData) + { + if (txData is null) + return Task.CompletedTask; + + if (_gamblingTypes.Contains(txData.Type)) + { + _stats.AddOrUpdate(txData.Type, + _ => (0, amount), + (_, old) => (old.Bet, old.PaidOut + amount)); + } + + return Task.CompletedTask; + } + + public Task TrackRemove(long amount, TxData? txData) + { + if (txData is null) + return Task.CompletedTask; + + if (_gamblingTypes.Contains(txData.Type)) + { + _stats.AddOrUpdate(txData.Type, + _ => (amount, 0), + (_, old) => (old.Bet + amount, old.PaidOut)); + } + + return Task.CompletedTask; + } + + public async Task> GetAllAsync() + { + await using var ctx = _db.GetDbContext(); + return await ctx.Set() + .ToListAsyncEF(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/IBehaviourExecutor.cs b/src/Ellie.Bot.Common/Services/IBehaviourExecutor.cs new file mode 100644 index 0000000..3788910 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/IBehaviourExecutor.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace Ellie.Services; + +public interface IBehaviorHandler +{ + Task AddAsync(ICustomBehavior behavior); + Task AddRangeAsync(IEnumerable behavior); + Task RemoveAsync(ICustomBehavior behavior); + Task RemoveRangeAsync(IEnumerable behs); + + Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg); + Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg); + Task RunPreCommandAsync(ICommandContext context, CommandInfo cmd); + ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd); + Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg); + void Initialize(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/ICommandHandler.cs b/src/Ellie.Bot.Common/Services/ICommandHandler.cs new file mode 100644 index 0000000..976a38f --- /dev/null +++ b/src/Ellie.Bot.Common/Services/ICommandHandler.cs @@ -0,0 +1,12 @@ +namespace Ellie.Services; + +public interface ICommandHandler +{ + string GetPrefix(IGuild ctxGuild); + string GetPrefix(ulong? id = null); + string SetDefaultPrefix(string toSet); + string SetPrefix(IGuild ctxGuild, string toSet); + ConcurrentDictionary UserMessagesSent { get; } + + Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/ICoordinator.cs b/src/Ellie.Bot.Common/Services/ICoordinator.cs new file mode 100644 index 0000000..62d8cd9 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/ICoordinator.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace Ellie.Services; + +public interface ICoordinator +{ + bool RestartBot(); + void Die(bool graceful); + bool RestartShard(int shardId); + IList GetAllShardStatuses(); + int GetGuildCount(); + Task Reload(); +} + +public class ShardStatus +{ + public ConnectionState ConnectionState { get; set; } + public DateTime LastUpdate { get; set; } + public int ShardId { get; set; } + public int GuildCount { get; set; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/ICustomBehavior.cs b/src/Ellie.Bot.Common/Services/ICustomBehavior.cs new file mode 100644 index 0000000..bf1a855 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/ICustomBehavior.cs @@ -0,0 +1,13 @@ +using Ellie.Common.ModuleBehaviors; + +namespace Ellie.Services; + +public interface ICustomBehavior + : IExecOnMessage, + IInputTransformer, + IExecPreCommand, + IExecNoCommand, + IExecPostCommand +{ + +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/IEService.cs b/src/Ellie.Bot.Common/Services/IEService.cs new file mode 100644 index 0000000..c915d94 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/IEService.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace Ellie.Services; + +/// +/// All services must implement this interface in order to be auto-discovered by the DI system +/// +public interface IEService +{ +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/IEmbedBuilderService.cs b/src/Ellie.Bot.Common/Services/IEmbedBuilderService.cs new file mode 100644 index 0000000..677660e --- /dev/null +++ b/src/Ellie.Bot.Common/Services/IEmbedBuilderService.cs @@ -0,0 +1,81 @@ +#nullable disable +using Ellie.Common.Configs; + +namespace Ellie.Services; + +public interface IEmbedBuilderService +{ + IEmbedBuilder Create(ICommandContext ctx = null); + IEmbedBuilder Create(EmbedBuilder eb); +} + +public class EmbedBuilderService : IEmbedBuilderService, IEService +{ + private readonly BotConfigService _botConfigService; + + public EmbedBuilderService(BotConfigService botConfigService) + => _botConfigService = botConfigService; + + public IEmbedBuilder Create(ICommandContext ctx = null) + => new DiscordEmbedBuilderWrapper(_botConfigService.Data); + + public IEmbedBuilder Create(EmbedBuilder embed) + => new DiscordEmbedBuilderWrapper(_botConfigService.Data, embed); +} + +public sealed class DiscordEmbedBuilderWrapper : IEmbedBuilder +{ + private readonly BotConfig _botConfig; + private EmbedBuilder embed; + + public DiscordEmbedBuilderWrapper(in BotConfig botConfig, EmbedBuilder embed = null) + { + _botConfig = botConfig; + this.embed = embed ?? new EmbedBuilder(); + } + + public IEmbedBuilder WithDescription(string desc) + => Wrap(embed.WithDescription(desc)); + + public IEmbedBuilder WithTitle(string title) + => Wrap(embed.WithTitle(title)); + + public IEmbedBuilder AddField(string title, object value, bool isInline = false) + => Wrap(embed.AddField(title, value, isInline)); + + public IEmbedBuilder WithFooter(string text, string iconUrl = null) + => Wrap(embed.WithFooter(text, iconUrl)); + + public IEmbedBuilder WithAuthor(string name, string iconUrl = null, string url = null) + => Wrap(embed.WithAuthor(name, iconUrl, url)); + + public IEmbedBuilder WithUrl(string url) + => Wrap(embed.WithUrl(url)); + + public IEmbedBuilder WithImageUrl(string url) + => Wrap(embed.WithImageUrl(url)); + + public IEmbedBuilder WithThumbnailUrl(string url) + => Wrap(embed.WithThumbnailUrl(url)); + + public IEmbedBuilder WithColor(EmbedColor color) + => color switch + { + EmbedColor.Ok => Wrap(embed.WithColor(_botConfig.Color.Ok.ToDiscordColor())), + EmbedColor.Pending => Wrap(embed.WithColor(_botConfig.Color.Pending.ToDiscordColor())), + EmbedColor.Error => Wrap(embed.WithColor(_botConfig.Color.Error.ToDiscordColor())), + _ => throw new ArgumentOutOfRangeException(nameof(color), "Unsupported EmbedColor type") + }; + + public IEmbedBuilder WithDiscordColor(Color color) + => Wrap(embed.WithColor(color)); + + public Embed Build() + => embed.Build(); + + private IEmbedBuilder Wrap(EmbedBuilder eb) + { + embed = eb; + return this; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/IGoogleApiService.cs b/src/Ellie.Bot.Common/Services/IGoogleApiService.cs new file mode 100644 index 0000000..36eec7c --- /dev/null +++ b/src/Ellie.Bot.Common/Services/IGoogleApiService.cs @@ -0,0 +1,19 @@ +#nullable disable + +namespace Ellie.Services; + +public interface IGoogleApiService +{ + IReadOnlyDictionary Languages { get; } + + Task> GetVideoLinksByKeywordAsync(string keywords, int count = 1); + Task> GetVideoInfosByKeywordAsync(string keywords, int count = 1); + Task> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1); + Task> GetRelatedVideosAsync(string id, int count = 1, string user = null); + Task> GetPlaylistTracksAsync(string playlistId, int count = 50); + Task> GetVideoDurationsAsync(IEnumerable videoIds); + Task Translate(string sourceText, string sourceLanguage, string targetLanguage); + + Task ShortenUrl(string url); + Task ShortenUrl(Uri url); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/ILocalDataCache.cs b/src/Ellie.Bot.Common/Services/ILocalDataCache.cs new file mode 100644 index 0000000..d975f04 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/ILocalDataCache.cs @@ -0,0 +1,13 @@ +#nullable disable +using Ellie.Common.Pokemon; +using Ellie.Modules.Games.Common.Trivia; + +namespace Ellie.Services; + +public interface ILocalDataCache +{ + Task> GetPokemonsAsync(); + Task> GetPokemonAbilitiesAsync(); + Task GetTriviaQuestionsAsync(); + Task> GetPokemonMapAsync(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/ILocalization.cs b/src/Ellie.Bot.Common/Services/ILocalization.cs new file mode 100644 index 0000000..94530fd --- /dev/null +++ b/src/Ellie.Bot.Common/Services/ILocalization.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Globalization; + +namespace Ellie.Services; + +public interface ILocalization +{ + CultureInfo DefaultCultureInfo { get; } + IDictionary GuildCultureInfos { get; } + + CultureInfo GetCultureInfo(IGuild guild); + CultureInfo GetCultureInfo(ulong? guildId); + void RemoveGuildCulture(IGuild guild); + void RemoveGuildCulture(ulong guildId); + void ResetDefaultCulture(); + void SetDefaultCulture(CultureInfo ci); + void SetGuildCulture(IGuild guild, CultureInfo ci); + void SetGuildCulture(ulong guildId, CultureInfo ci); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/IRemindService.cs b/src/Ellie.Bot.Common/Services/IRemindService.cs new file mode 100644 index 0000000..a4c3114 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/IRemindService.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace Ellie.Modules.Utility.Services; + +public interface IRemindService +{ + Task AddReminderAsync(ulong userId, + ulong targetId, + ulong? guildId, + bool isPrivate, + DateTime time, + string message); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/IStatsService.cs b/src/Ellie.Bot.Common/Services/IStatsService.cs new file mode 100644 index 0000000..a3b798a --- /dev/null +++ b/src/Ellie.Bot.Common/Services/IStatsService.cs @@ -0,0 +1,51 @@ +#nullable disable +namespace Ellie.Services; + +public interface IStatsService +{ + /// + /// The author of the bot. + /// + string Author { get; } + + /// + /// The total amount of commands ran since startup. + /// + long CommandsRan { get; } + + /// + /// The amount of messages seen by the bot since startup. + /// + long MessageCounter { get; } + + /// + /// The rate of messages the bot sees every second. + /// + double MessagesPerSecond { get; } + + /// + /// The total amount of text channels the bot can see. + /// + long TextChannels { get; } + + /// + /// The total amount of voice channels the bot can see. + /// + long VoiceChannels { get; } + + /// + /// Gets for how long the bot has been up since startup. + /// + TimeSpan GetUptime(); + + /// + /// Gets a formatted string of how long the bot has been up since startup. + /// + /// The formatting separator. + string GetUptimeString(string separator = ", "); + + /// + /// Gets total amount of private memory currently in use by the bot, in Megabytes. + /// + double GetPrivateMemoryMegabytes(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/ITimezoneService.cs b/src/Ellie.Bot.Common/Services/ITimezoneService.cs new file mode 100644 index 0000000..a587d6f --- /dev/null +++ b/src/Ellie.Bot.Common/Services/ITimezoneService.cs @@ -0,0 +1,6 @@ +namespace Ellie.Common; + +public interface ITimezoneService +{ + TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/BehaviorExecutor.cs b/src/Ellie.Bot.Common/Services/Impl/BehaviorExecutor.cs new file mode 100644 index 0000000..58b5df5 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/BehaviorExecutor.cs @@ -0,0 +1,302 @@ +#nullable disable +using Microsoft.Extensions.DependencyInjection; +using Ellie.Common.ModuleBehaviors; + +namespace Ellie.Services; + +// should be renamed to handler as it's not only executing +public sealed class BehaviorHandler : IBehaviorHandler +{ + private readonly IServiceProvider _services; + + private IReadOnlyCollection noCommandExecs; + private IReadOnlyCollection preCommandExecs; + private IReadOnlyCollection onMessageExecs; + private IReadOnlyCollection inputTransformers; + + private readonly SemaphoreSlim _customLock = new(1, 1); + private readonly List _customExecs = new(); + + public BehaviorHandler(IServiceProvider services) + { + _services = services; + } + + public void Initialize() + { + noCommandExecs = _services.GetServices().ToArray(); + preCommandExecs = _services.GetServices().OrderByDescending(x => x.Priority).ToArray(); + onMessageExecs = _services.GetServices().OrderByDescending(x => x.Priority).ToArray(); + inputTransformers = _services.GetServices().ToArray(); + } + + #region Add/Remove + + public async Task AddRangeAsync(IEnumerable execs) + { + await _customLock.WaitAsync(); + try + { + foreach (var exe in execs) + { + if (_customExecs.Contains(exe)) + continue; + + _customExecs.Add(exe); + } + } + finally + { + _customLock.Release(); + } + } + + public async Task AddAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + if (_customExecs.Contains(behavior)) + return false; + + _customExecs.Add(behavior); + return true; + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + return _customExecs.Remove(behavior); + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveRangeAsync(IEnumerable behs) + { + await _customLock.WaitAsync(); + try + { + foreach(var beh in behs) + _customExecs.Remove(beh); + } + finally + { + _customLock.Release(); + } + } + + #endregion + + #region Running + + public async Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs) + where T : IExecOnMessage + { + foreach (var exec in execs) + { + try + { + if (await exec.ExecOnMessageAsync(guild, usrMsg)) + { + Log.Information("{TypeName} blocked message g:{GuildId} u:{UserId} c:{ChannelId} msg:{Message}", + GetExecName(exec), + guild?.Id, + usrMsg.Author.Id, + usrMsg.Channel.Id, + usrMsg.Content?.TrimTo(10)); + + return true; + } + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} late blocker: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + + return false; + } + + if (await Exec(onMessageExecs)) + { + return true; + } + + await _customLock.WaitAsync(); + try + { + if (await Exec(_customExecs)) + return true; + } + finally + { + _customLock.Release(); + } + + return false; + } + + private string GetExecName(IBehavior exec) + => exec.Name; + + public async Task RunPreCommandAsync(ICommandContext ctx, CommandInfo cmd) + { + async Task Exec(IReadOnlyCollection execs) where T: IExecPreCommand + { + foreach (var exec in execs) + { + try + { + if (await exec.ExecPreCommandAsync(ctx, cmd.Module.GetTopLevelModule().Name, cmd)) + { + Log.Information("{TypeName} Pre-Command blocked [{User}] Command: [{Command}]", + GetExecName(exec), + ctx.User, + cmd.Aliases[0]); + return true; + } + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} PreCommand: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + + return false; + } + + if (await Exec(preCommandExecs)) + return true; + + await _customLock.WaitAsync(); + try + { + if (await Exec(_customExecs)) + return true; + } + finally + { + _customLock.Release(); + } + + return false; + } + + public async Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs) where T : IExecNoCommand + { + foreach (var exec in execs) + { + try + { + await exec.ExecOnNoCommandAsync(guild, usrMsg); + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} OnNoCommand: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + } + + await Exec(noCommandExecs); + + await _customLock.WaitAsync(); + try + { + await Exec(_customExecs); + } + finally + { + _customLock.Release(); + } + } + + public async Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs, string content) + where T : IInputTransformer + { + foreach (var exec in execs) + { + try + { + var newContent = await exec.TransformInput(guild, usrMsg.Channel, usrMsg.Author, content); + if (newContent is not null) + { + Log.Information("{ExecName} transformed content {OldContent} -> {NewContent}", + GetExecName(exec), + content, + newContent); + return newContent; + } + } + catch (Exception ex) + { + Log.Warning(ex, "An error occured during InputTransform handling: {ErrorMessage}", ex.Message); + } + } + + return null; + } + + var newContent = await Exec(inputTransformers, usrMsg.Content); + if (newContent is not null) + return newContent; + + await _customLock.WaitAsync(); + try + { + newContent = await Exec(_customExecs, usrMsg.Content); + if (newContent is not null) + return newContent; + } + finally + { + _customLock.Release(); + } + + return usrMsg.Content; + } + + public async ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd) + { + foreach (var exec in _customExecs) + { + try + { + await exec.ExecPostCommandAsync(ctx, moduleName, cmd.Name); + } + catch (Exception ex) + { + Log.Warning(ex, + "An error occured during PostCommand handling in {ExecName}: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + } + + #endregion +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/BlacklistService.cs b/src/Ellie.Bot.Common/Services/Impl/BlacklistService.cs new file mode 100644 index 0000000..6baa752 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/BlacklistService.cs @@ -0,0 +1,136 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Db.Models; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Permissions.Services; + +public sealed class BlacklistService : IExecOnMessage +{ + public int Priority + => int.MaxValue; + + private readonly DbService _db; + private readonly IPubSub _pubSub; + private readonly IBotCredentials _creds; + private IReadOnlyList blacklist; + + private readonly TypedKey _blPubKey = new("blacklist.reload"); + + public BlacklistService(DbService db, IPubSub pubSub, IBotCredentials creds) + { + _db = db; + _pubSub = pubSub; + _creds = creds; + + Reload(false); + _pubSub.Sub(_blPubKey, OnReload); + } + + private ValueTask OnReload(BlacklistEntry[] newBlacklist) + { + blacklist = newBlacklist; + return default; + } + + public Task ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg) + { + foreach (var bl in blacklist) + { + if (guild is not null && bl.Type == BlacklistType.Server && bl.ItemId == guild.Id) + { + Log.Information("Blocked input from blacklisted guild: {GuildName} [{GuildId}]", guild.Name, guild.Id); + + return Task.FromResult(true); + } + + if (bl.Type == BlacklistType.Channel && bl.ItemId == usrMsg.Channel.Id) + { + Log.Information("Blocked input from blacklisted channel: {ChannelName} [{ChannelId}]", + usrMsg.Channel.Name, + usrMsg.Channel.Id); + + return Task.FromResult(true); + } + + if (bl.Type == BlacklistType.User && bl.ItemId == usrMsg.Author.Id) + { + Log.Information("Blocked input from blacklisted user: {UserName} [{UserId}]", + usrMsg.Author.ToString(), + usrMsg.Author.Id); + + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + public IReadOnlyList GetBlacklist() + => blacklist; + + public void Reload(bool publish = true) + { + using var uow = _db.GetDbContext(); + var toPublish = uow.GetTable().ToArray(); + blacklist = toPublish; + if (publish) + _pubSub.Pub(_blPubKey, toPublish); + } + + public async Task Blacklist(BlacklistType type, ulong id) + { + if (_creds.OwnerIds.Contains(id)) + return; + + await using var uow = _db.GetDbContext(); + + await uow + .GetTable() + .InsertAsync(() => new() + { + ItemId = id, + Type = type, + }); + + Reload(); + } + + public async Task UnBlacklist(BlacklistType type, ulong id) + { + await using var uow = _db.GetDbContext(); + await uow.GetTable() + .Where(bi => bi.ItemId == id && bi.Type == type) + .DeleteAsync(); + + Reload(); + } + + public void BlacklistUsers(IReadOnlyCollection toBlacklist) + { + using (var uow = _db.GetDbContext()) + { + var bc = uow.Set(); + bc.AddRange(toBlacklist.Select(x => new BlacklistEntry + { + ItemId = x, + Type = BlacklistType.User + })); + + // todo check if blacklist works and removes currency + uow.GetTable() + .UpdateAsync(x => toBlacklist.Contains(x.UserId), + _ => new() + { + CurrencyAmount = 0 + }); + + uow.SaveChanges(); + } + + Reload(); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/CommandsUtilityService.cs b/src/Ellie.Bot.Common/Services/Impl/CommandsUtilityService.cs new file mode 100644 index 0000000..91f6880 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/CommandsUtilityService.cs @@ -0,0 +1,172 @@ +using CommandLine; + +namespace Ellie.Common; + +public sealed class CommandsUtilityService : ICommandsUtilityService, IEService +{ + private readonly CommandHandler _ch; + private readonly IBotStrings _strings; + private readonly DiscordPermOverrideService _dpos; + private readonly IEmbedBuilderService _eb; + private readonly ILocalization _loc; + private readonly Ellie.Marmalade.IMarmaladeLoaderSevice _marmalades; + + public CommandsUtilityService( + CommandHandler ch, + IBotStrings strings, + DiscordPermOverrideService dpos, + IEmbedBuilderService eb, + ILocalization loc, + Ellie.Marmalade.IMarmaladeLoaderSevice marmalades) + { + _ch = ch; + _strings = strings; + _dpos = dpos; + _eb = eb; + _loc = loc; + _marmalades = marmalades; + } + + public IEmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild) + { + var prefix = _ch.GetPrefix(guild); + + var str = $"**`{prefix + com.Aliases.First()}`**"; + var alias = com.Aliases.Skip(1).FirstOrDefault(); + if (alias is not null) + str += $" **/ `{prefix + alias}`**"; + + var culture = _loc.GetCultureInfo(guild); + + var em = _eb.Create() + .AddField(str, $"{com.RealSummary(_strings, _marmalades, culture, prefix)}", true); + + _dpos.TryGetOverrides(guild?.Id ?? 0, com.Name, out var overrides); + var reqs = GetCommandRequirements(com, (GuildPermission?)overrides); + if (reqs.Any()) + em.AddField(GetText(strs.requires, guild), string.Join("\n", reqs)); + + em.AddField(_strings.GetText(strs.usage), + string.Join("\n", com.RealRemarksArr(_strings, _marmalades, culture, prefix).Map(arg => Format.Code(arg)))) + .WithFooter(GetText(strs.module(com.Module.GetTopLevelModule().Name), guild)) + .WithOkColor(); + + var opt = GetNadekoOptionType(com.Attributes); + if (opt is not null) + { + var hs = GetCommandOptionHelp(opt); + if (!string.IsNullOrWhiteSpace(hs)) + em.AddField(GetText(strs.options, guild), hs); + } + + return em; + } + + public static string GetCommandOptionHelp(Type opt) + { + var strs = GetCommandOptionHelpList(opt); + + return string.Join("\n", strs); + } + + public static List GetCommandOptionHelpList(Type opt) + { + var strs = opt.GetProperties() + .Select(x => x.GetCustomAttributes(true).FirstOrDefault(a => a is OptionAttribute)) + .Where(x => x is not null) + .Cast() + .Select(x => + { + var toReturn = $"`--{x.LongName}`"; + + if (!string.IsNullOrWhiteSpace(x.ShortName)) + toReturn += $" (`-{x.ShortName}`)"; + + toReturn += $" {x.HelpText} "; + return toReturn; + }) + .ToList(); + + return strs; + } + + public static Type? GetNadekoOptionType(IEnumerable attributes) + => attributes + .Select(a => a.GetType()) + .Where(a => a.IsGenericType + && a.GetGenericTypeDefinition() == typeof(NadekoOptionsAttribute<>)) + .Select(a => a.GenericTypeArguments[0]) + .FirstOrDefault(); + + public static string[] GetCommandRequirements(CommandInfo cmd, GuildPerm? overrides = null) + { + var toReturn = new List(); + + if (cmd.Preconditions.Any(x => x is OwnerOnlyAttribute)) + toReturn.Add("Bot Owner Only"); + + if (cmd.Preconditions.Any(x => x is NoPublicBotAttribute) + || cmd.Module + .Preconditions + .Any(x => x is NoPublicBotAttribute) + || cmd.Module.GetTopLevelModule() + .Preconditions + .Any(x => x is NoPublicBotAttribute)) + toReturn.Add("No Public Bot"); + + if (cmd.Preconditions + .Any(x => x is OnlyPublicBotAttribute) + || cmd.Module + .Preconditions + .Any(x => x is OnlyPublicBotAttribute) + || cmd.Module.GetTopLevelModule() + .Preconditions + .Any(x => x is OnlyPublicBotAttribute)) + toReturn.Add("Only Public Bot"); + + var userPermString = cmd.Preconditions + .Where(ca => ca is UserPermAttribute) + .Cast() + .Select(userPerm => + { + if (userPerm.ChannelPermission is { } cPerm) + return GetPreconditionString(cPerm); + + if (userPerm.GuildPermission is { } gPerm) + return GetPreconditionString(gPerm); + + return string.Empty; + }) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Join('\n'); + + if (overrides is null) + { + if (!string.IsNullOrWhiteSpace(userPermString)) + toReturn.Add(userPermString); + } + else + { + if (!string.IsNullOrWhiteSpace(userPermString)) + toReturn.Add(Format.Strikethrough(userPermString)); + + toReturn.Add(GetPreconditionString(overrides.Value)); + } + + return toReturn.ToArray(); + } + + public static string GetPreconditionString(ChannelPerm perm) + => (perm + " Channel Permission").Replace("Guild", "Server"); + + public static string GetPreconditionString(GuildPerm perm) + => (perm + " Server Permission").Replace("Guild", "Server"); + + public string GetText(LocStr str, IGuild? guild) + => _strings.GetText(str, guild?.Id); +} + +public interface ICommandsUtilityService +{ + IEmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/DiscordPermOverrideService.cs b/src/Ellie.Bot.Common/Services/Impl/DiscordPermOverrideService.cs new file mode 100644 index 0000000..dd5817b --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/DiscordPermOverrideService.cs @@ -0,0 +1,136 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Services.Database.Models; + +namespace Ellie.Services; + +public class DiscordPermOverrideService : IEService, IExecPreCommand, IDiscordPermOverrideService +{ + public int Priority { get; } = int.MaxValue; + private readonly DbService _db; + private readonly IServiceProvider _services; + + private readonly ConcurrentDictionary<(ulong, string), DiscordPermOverride> _overrides; + + public DiscordPermOverrideService(DbService db, IServiceProvider services) + { + _db = db; + _services = services; + using var uow = _db.GetDbContext(); + _overrides = uow.Set() + .AsNoTracking() + .AsEnumerable() + .ToDictionary(o => (o.GuildId ?? 0, o.Command), o => o) + .ToConcurrent(); + } + + public bool TryGetOverrides(ulong guildId, string commandName, out Ellie.Bot.Db.GuildPerm? perm) + { + commandName = commandName.ToLowerInvariant(); + if (_overrides.TryGetValue((guildId, commandName), out var dpo)) + { + perm = dpo.Perm; + return true; + } + + perm = null; + return false; + } + + public Task ExecuteOverrides( + ICommandContext ctx, + CommandInfo command, + GuildPerm perms, + IServiceProvider services) + { + var rupa = new RequireUserPermissionAttribute(perms); + return rupa.CheckPermissionsAsync(ctx, command, services); + } + + public async Task AddOverride(ulong guildId, string commandName, GuildPerm perm) + { + commandName = commandName.ToLowerInvariant(); + await using var uow = _db.GetDbContext(); + var over = await uow.Set() + .AsQueryable() + .FirstOrDefaultAsync(x => x.GuildId == guildId && commandName == x.Command); + + if (over is null) + { + uow.Set() + .Add(over = new() + { + Command = commandName, + Perm = (Ellie.Bot.Db.GuildPerm)perm, + GuildId = guildId + }); + } + else + over.Perm = (Ellie.Bot.Db.GuildPerm)perm; + + _overrides[(guildId, commandName)] = over; + + await uow.SaveChangesAsync(); + } + + public async Task ClearAllOverrides(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var overrides = await uow.Set() + .AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + + uow.RemoveRange(overrides); + await uow.SaveChangesAsync(); + + foreach (var over in overrides) + _overrides.TryRemove((guildId, over.Command), out _); + } + + public async Task RemoveOverride(ulong guildId, string commandName) + { + commandName = commandName.ToLowerInvariant(); + + await using var uow = _db.GetDbContext(); + var over = await uow.Set() + .AsQueryable() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.GuildId == guildId && x.Command == commandName); + + if (over is null) + return; + + uow.Remove(over); + await uow.SaveChangesAsync(); + + _overrides.TryRemove((guildId, commandName), out _); + } + + public async Task> GetAllOverrides(ulong guildId) + { + await using var uow = _db.GetDbContext(); + return await uow.Set() + .AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + } + + public async Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + { + if (TryGetOverrides(context.Guild?.Id ?? 0, command.Name, out var perm) && perm is not null) + { + var result = + await new RequireUserPermissionAttribute((GuildPermission)perm).CheckPermissionsAsync(context, + command, + _services); + + return !result.IsSuccess; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/FontProvider.cs b/src/Ellie.Bot.Common/Services/Impl/FontProvider.cs new file mode 100644 index 0000000..0777e8c --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/FontProvider.cs @@ -0,0 +1,60 @@ +#nullable disable +using SixLabors.Fonts; + +namespace Ellie.Services; + +public class FontProvider : IEService +{ + public FontFamily DottyFont { get; } + + public FontFamily UniSans { get; } + + public FontFamily NotoSans { get; } + //public FontFamily Emojis { get; } + + /// + /// Font used for .rip command + /// + public Font RipFont { get; } + + public List FallBackFonts { get; } + private readonly FontCollection _fonts; + + public FontProvider() + { + _fonts = new(); + + NotoSans = _fonts.Add("data/fonts/NotoSans-Bold.ttf"); + UniSans = _fonts.Add("data/fonts/Uni Sans.ttf"); + + FallBackFonts = new(); + + //FallBackFonts.Add(_fonts.Install("data/fonts/OpenSansEmoji.ttf")); + + // try loading some emoji and jap fonts on windows as fallback fonts + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + try + { + var fontsfolder = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + FallBackFonts.Add(_fonts.Add(Path.Combine(fontsfolder, "seguiemj.ttf"))); + FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "msgothic.ttc"))); + FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "segoe.ttc"))); + } + catch { } + } + + // any fonts present in data/fonts should be added as fallback fonts + // this will allow support for special characters when drawing text + foreach (var font in Directory.GetFiles(@"data/fonts")) + { + if (font.EndsWith(".ttf")) + FallBackFonts.Add(_fonts.Add(font)); + else if (font.EndsWith(".ttc")) + FallBackFonts.AddRange(_fonts.AddCollection(font)); + } + + RipFont = NotoSans.CreateFont(20, FontStyle.Bold); + DottyFont = FallBackFonts.First(x => x.Name == "dotty"); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/IImageCache.cs b/src/Ellie.Bot.Common/Services/Impl/IImageCache.cs new file mode 100644 index 0000000..28ff952 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/IImageCache.cs @@ -0,0 +1,17 @@ +namespace Ellie.Services; + +public interface IImageCache +{ + Task GetHeadsImageAsync(); + Task GetTailsImageAsync(); + Task GetCurrencyImageAsync(); + Task GetXpBackgroundImageAsync(); + Task GetRategirlBgAsync(); + Task GetRategirlDotAsync(); + Task GetDiceAsync(int num); + Task GetSlotEmojiAsync(int number); + Task GetSlotBgAsync(); + Task GetRipBgAsync(); + Task GetRipOverlayAsync(); + Task GetImageDataAsync(Uri url); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/ImagesConfig.cs b/src/Ellie.Bot.Common/Services/Impl/ImagesConfig.cs new file mode 100644 index 0000000..8546c7c --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/ImagesConfig.cs @@ -0,0 +1,19 @@ +using Ellie.Common.Configs; + +namespace Ellie.Services; + +public sealed class ImagesConfig : ConfigServiceBase +{ + private const string PATH = "data/images.yml"; + + private static readonly TypedKey _changeKey = + new("config.images.updated"); + + public override string Name + => "images"; + + public ImagesConfig(IConfigSeria serializer, IPubSub pubSub) + : base(PATH, serializer, pubSub, _changeKey) + { + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/RedisImageExtensions.cs b/src/Ellie.Bot.Common/Services/Impl/RedisImageExtensions.cs new file mode 100644 index 0000000..721f6b7 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/RedisImageExtensions.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace Ellie.Services; + +public static class RedisImageExtensions +{ + private const string OLD_CDN_URL = "ellie.nyc3.digitaloceanspaces.com"; + private const string NEW_CDN_URL = "ellie.gcoms.xyz"; + + public static Uri ToNewCdn(this Uri uri) + => new(uri.ToString().Replace(OLD_CDN_URL, NEW_CDN_URL)); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/SingleProcessCoordinator.cs b/src/Ellie.Bot.Common/Services/Impl/SingleProcessCoordinator.cs new file mode 100644 index 0000000..49983cc --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/SingleProcessCoordinator.cs @@ -0,0 +1,58 @@ +#nullable disable +using System.Diagnostics; + +namespace Ellie.Services; + +public class SingleProcessCoordinator : ICoordinator +{ + private readonly IBotCredentials _creds; + private readonly DiscordSocketClient _client; + + public SingleProcessCoordinator(IBotCredentials creds, DiscordSocketClient client) + { + _creds = creds; + _client = client; + } + + public bool RestartBot() + { + if (string.IsNullOrWhiteSpace(_creds.RestartCommand?.Cmd) + || string.IsNullOrWhiteSpace(_creds.RestartCommand?.Args)) + { + Log.Error("You must set RestartCommand.Cmd and RestartCommand.Args in creds.yml"); + return false; + } + + Process.Start(_creds.RestartCommand.Cmd, _creds.RestartCommand.Args); + _ = Task.Run(async () => + { + await Task.Delay(2000); + Die(); + }); + return true; + } + + public void Die(bool graceful = false) + => Environment.Exit(5); + + public bool RestartShard(int shardId) + => RestartBot(); + + public IList GetAllShardStatuses() + => new[] + { + new ShardStatus + { + ConnectionState = _client.ConnectionState, + GuildCount = _client.Guilds.Count, + LastUpdate = DateTime.UtcNow, + ShardId = _client.ShardId + } + }; + + public int GetGuildCount() + => _client.Guilds.Count; + + public Task Reload() + => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/StartingGuildsListService.cs b/src/Ellie.Bot.Common/Services/Impl/StartingGuildsListService.cs new file mode 100644 index 0000000..fc6e541 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/StartingGuildsListService.cs @@ -0,0 +1,18 @@ +#nullable disable +using System.Collections; + +namespace Ellie.Services; + +public class StartingGuildsService : IEnumerable, IEService +{ + private readonly IReadOnlyList _guilds; + + public StartingGuildsService(DiscordSocketClient client) + => _guilds = client.Guilds.Select(x => x.Id).ToList(); + + public IEnumerator GetEnumerator() + => _guilds.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => _guilds.GetEnumerator(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/Impl/StatsService.cs b/src/Ellie.Bot.Common/Services/Impl/StatsService.cs new file mode 100644 index 0000000..aa1cc4c --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/StatsService.cs @@ -0,0 +1,188 @@ +#nullable disable +using Humanizer.Localisation; +using Ellie.Common.ModuleBehaviors; +using System.Diagnostics; + +namespace NadekoBot.Services; + +public sealed class StatsService : IStatsService, IReadyExecutor, IEService +{ + public const string BOT_VERSION = "5.0.0-alpha1"; + + public string Author + => "toastie_t0ast"; + + public double MessagesPerSecond + => MessageCounter / GetUptime().TotalSeconds; + + public long TextChannels + => Interlocked.Read(ref textChannels); + + public long VoiceChannels + => Interlocked.Read(ref voiceChannels); + + public long MessageCounter + => Interlocked.Read(ref messageCounter); + + public long CommandsRan + => Interlocked.Read(ref commandsRan); + + private readonly Process _currentProcess = Process.GetCurrentProcess(); + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + private readonly DateTime _started; + + private long textChannels; + private long voiceChannels; + private long messageCounter; + private long commandsRan; + + private readonly IHttpClientFactory _httpFactory; + + public StatsService( + DiscordSocketClient client, + CommandHandler cmdHandler, + IBotCredentials creds, + IHttpClientFactory factory) + { + _client = client; + _creds = creds; + _httpFactory = factory; + + _started = DateTime.UtcNow; + _client.MessageReceived += _ => Task.FromResult(Interlocked.Increment(ref messageCounter)); + cmdHandler.CommandExecuted += (_, _) => Task.FromResult(Interlocked.Increment(ref commandsRan)); + + _client.ChannelCreated += c => + { + _ = Task.Run(() => + { + if (c is ITextChannel) + Interlocked.Increment(ref textChannels); + else if (c is IVoiceChannel) + Interlocked.Increment(ref voiceChannels); + }); + + return Task.CompletedTask; + }; + + _client.ChannelDestroyed += c => + { + _ = Task.Run(() => + { + if (c is ITextChannel) + Interlocked.Decrement(ref textChannels); + else if (c is IVoiceChannel) + Interlocked.Decrement(ref voiceChannels); + }); + + return Task.CompletedTask; + }; + + _client.GuildAvailable += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, tc); + Interlocked.Add(ref voiceChannels, vc); + }); + return Task.CompletedTask; + }; + + _client.JoinedGuild += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, tc); + Interlocked.Add(ref voiceChannels, vc); + }); + return Task.CompletedTask; + }; + + _client.GuildUnavailable += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, -tc); + Interlocked.Add(ref voiceChannels, -vc); + }); + + return Task.CompletedTask; + }; + + _client.LeftGuild += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, -tc); + Interlocked.Add(ref voiceChannels, -vc); + }); + + return Task.CompletedTask; + }; + } + + private void InitializeChannelCount() + { + var guilds = _client.Guilds; + textChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is ITextChannel)); + voiceChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is IVoiceChannel)); + } + + public async Task OnReadyAsync() + { + InitializeChannelCount(); + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + do + { + if (string.IsNullOrWhiteSpace(_creds.BotListToken)) + continue; + + try + { + using var http = _httpFactory.CreateClient(); + using var content = new FormUrlEncodedContent(new Dictionary + { + { "shard_count", _creds.TotalShards.ToString() }, + { "shard_id", _client.ShardId.ToString() }, + { "server_count", _client.Guilds.Count().ToString() } + }); + content.Headers.Clear(); + content.Headers.Add("Content-Type", "application/x-www-form-urlencoded"); + http.DefaultRequestHeaders.Add("Authorization", _creds.BotListToken); + + using var res = await http.PostAsync( + new Uri($"https://discordbots.org/api/bots/{_client.CurrentUser.Id}/stats"), + content); + } + catch (Exception ex) + { + Log.Error(ex, "Error in botlist post"); + } + } while (await timer.WaitForNextTickAsync()); + } + + public TimeSpan GetUptime() + => DateTime.UtcNow - _started; + + public string GetUptimeString(string separator = ", ") + { + var time = GetUptime(); + return time.Humanize(3, maxUnit: TimeUnit.Day, minUnit: TimeUnit.Minute); + } + + public double GetPrivateMemoryMegabytes() + { + _currentProcess.Refresh(); + return _currentProcess.PrivateMemorySize64 / 1.Megabytes().Bytes; + } +} diff --git a/src/Ellie.Bot.Common/Services/Impl/YtdlOperation.cs b/src/Ellie.Bot.Common/Services/Impl/YtdlOperation.cs new file mode 100644 index 0000000..dfc3ab4 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/Impl/YtdlOperation.cs @@ -0,0 +1,77 @@ +#nullable disable +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace Ellie.Services; + +public class YtdlOperation +{ + private readonly string _baseArgString; + private readonly bool _isYtDlp; + + public YtdlOperation(string baseArgString, bool isYtDlp = false) + { + _baseArgString = baseArgString; + _isYtDlp = isYtDlp; + } + + private Process CreateProcess(string[] args) + { + var newArgs = args.Map(arg => (object)arg.Replace("\"", "")); + return new() + { + StartInfo = new() + { + FileName = _isYtDlp ? "yt-dlp" : "youtube-dl", + Arguments = string.Format(_baseArgString, newArgs), + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true + } + }; + } + + public async Task GetDataAsync(params string[] args) + { + try + { + using var process = CreateProcess(args); + + Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + process.Start(); + + var str = await process.StandardOutput.ReadToEndAsync(); + var err = await process.StandardError.ReadToEndAsync(); + if (!string.IsNullOrEmpty(err)) + Log.Warning("YTDL warning: {YtdlWarning}", err); + + return str; + } + catch (Win32Exception) + { + Log.Error("youtube-dl is likely not installed. " + "Please install it before running the command again"); + return default; + } + catch (Exception ex) + { + Log.Error(ex, "Exception running youtube-dl: {ErrorMessage}", ex.Message); + return default; + } + } + + public async IAsyncEnumerable EnumerateDataAsync(params string[] args) + { + using var process = CreateProcess(args); + + Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + process.Start(); + + string line; + while ((line = await process.StandardOutput.ReadLineAsync()) is not null) + yield return line; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/strings/impl/BotStrings.cs b/src/Ellie.Bot.Common/Services/strings/impl/BotStrings.cs new file mode 100644 index 0000000..4c50be9 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/strings/impl/BotStrings.cs @@ -0,0 +1,101 @@ +#nullable disable +using System.Globalization; + +namespace Ellie.Services; + +public class BotStrings : IBotStrings +{ + /// + /// Used as failsafe in case response key doesn't exist in the selected or default language. + /// + private readonly CultureInfo _usCultureInfo = new("en-US"); + + private readonly ILocalization _localization; + private readonly IBotStringsProvider _stringsProvider; + + public BotStrings(ILocalization loc, IBotStringsProvider stringsProvider) + { + _localization = loc; + _stringsProvider = stringsProvider; + } + + private string GetString(string key, CultureInfo cultureInfo) + => _stringsProvider.GetText(cultureInfo.Name, key); + + public string GetText(string key, ulong? guildId = null, params object[] data) + => GetText(key, _localization.GetCultureInfo(guildId), data); + + public string GetText(string key, CultureInfo cultureInfo) + { + var text = GetString(key, cultureInfo); + + if (string.IsNullOrWhiteSpace(text)) + { + Log.Warning("'{Key}' key is missing from '{LanguageName}' response strings. You may ignore this message", + key, + cultureInfo.Name); + text = GetString(key, _usCultureInfo) ?? $"Error: dkey {key} not found!"; + if (string.IsNullOrWhiteSpace(text)) + { + return + "I can't tell you if the command is executed, because there was an error printing out the response." + + $" Key '{key}' is missing from resources. You may ignore this message."; + } + } + + return text; + } + + public string GetText(string key, CultureInfo cultureInfo, params object[] data) + { + try + { + return string.Format(GetText(key, cultureInfo), data); + } + catch (FormatException) + { + Log.Warning( + " Key '{Key}' is not properly formatted in '{LanguageName}' response strings. Please report this", + key, + cultureInfo.Name); + if (cultureInfo.Name != _usCultureInfo.Name) + return GetText(key, _usCultureInfo, data); + return + "I can't tell you if the command is executed, because there was an error printing out the response.\n" + + $"Key '{key}' is not properly formatted. Please report this."; + } + } + + public CommandStrings GetCommandStrings(string commandName, ulong? guildId = null) + => GetCommandStrings(commandName, _localization.GetCultureInfo(guildId)); + + 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. Please report this", + commandName); + + return new CommandStrings() + { + Args = new[] { "" }, + Desc = "?" + }; + } + +// Log.Warning(@"'{CommandName}' command strings don't exist in '{LanguageName}' culture. +// This message is safe to ignore, however you can ask in Nadeko support server how you can contribute command translations", +// commandName, cultureInfo.Name); + + return GetCommandStrings(commandName, _usCultureInfo); + } + + return cmdStrings; + } + + public void Reload() + => _stringsProvider.Reload(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/strings/impl/LocalFileStringsSource.cs b/src/Ellie.Bot.Common/Services/strings/impl/LocalFileStringsSource.cs new file mode 100644 index 0000000..56e4f28 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/strings/impl/LocalFileStringsSource.cs @@ -0,0 +1,73 @@ +#nullable disable +using Newtonsoft.Json; +using YamlDotNet.Serialization; + +namespace Ellie.Services; + +/// +/// Loads strings from the local default filepath +/// +public class LocalFileStringsSource : IStringsSource +{ + private readonly string _responsesPath = "data/strings/responses"; + private readonly string _commandsPath = "data/strings/commands"; + + public LocalFileStringsSource( + string responsesPath = "data/strings/responses", + string commandsPath = "data/strings/commands") + { + _responsesPath = responsesPath; + _commandsPath = commandsPath; + } + + public Dictionary> GetResponseStrings() + { + var outputDict = new Dictionary>(); + foreach (var file in Directory.GetFiles(_responsesPath)) + { + try + { + var langDict = JsonConvert.DeserializeObject>(File.ReadAllText(file)); + var localeName = GetLocaleName(file); + outputDict[localeName] = langDict; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} response strings: {ErrorMessage}", file, ex.Message); + } + } + + return outputDict; + } + + public Dictionary> GetCommandStrings() + { + var deserializer = new DeserializerBuilder().Build(); + + var outputDict = new Dictionary>(); + foreach (var file in Directory.GetFiles(_commandsPath)) + { + try + { + var text = File.ReadAllText(file); + var langDict = deserializer.Deserialize>(text); + var localeName = GetLocaleName(file); + outputDict[localeName] = langDict; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} command strings: {ErrorMessage}", file, ex.Message); + } + } + + return outputDict; + } + + private static string GetLocaleName(string fileName) + { + fileName = Path.GetFileName(fileName); + var dotIndex = fileName.IndexOf('.') + 1; + var secondDotIndex = fileName.LastIndexOf('.'); + return fileName.Substring(dotIndex, secondDotIndex - dotIndex); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Services/strings/impl/MemoryBotStringsProvider.cs b/src/Ellie.Bot.Common/Services/strings/impl/MemoryBotStringsProvider.cs new file mode 100644 index 0000000..8fe9819 --- /dev/null +++ b/src/Ellie.Bot.Common/Services/strings/impl/MemoryBotStringsProvider.cs @@ -0,0 +1,38 @@ +#nullable disable +namespace Ellie.Services; + +public class MemoryBotStringsProvider : IBotStringsProvider +{ + private readonly IStringsSource _source; + private IReadOnlyDictionary> responseStrings; + private IReadOnlyDictionary> commandStrings; + + public MemoryBotStringsProvider(IStringsSource source) + { + _source = source; + Reload(); + } + + public string GetText(string localeName, string key) + { + if (responseStrings.TryGetValue(localeName, out var langStrings) && langStrings.TryGetValue(key, out var text)) + return text; + + return null; + } + + public void Reload() + { + responseStrings = _source.GetResponseStrings(); + commandStrings = _source.GetCommandStrings(); + } + + public CommandStrings GetCommandStrings(string localeName, string commandName) + { + if (commandStrings.TryGetValue(localeName, out var langStrings) + && langStrings.TryGetValue(commandName, out var strings)) + return strings; + + return null; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Settings/BotConfigService.cs b/src/Ellie.Bot.Common/Settings/BotConfigService.cs new file mode 100644 index 0000000..740ffe4 --- /dev/null +++ b/src/Ellie.Bot.Common/Settings/BotConfigService.cs @@ -0,0 +1,66 @@ +#nullable disable +using Ellie.Common.Configs; +using SixLabors.ImageSharp.PixelFormats; + +namespace Ellie.Services; + +/// +/// Settings service for bot-wide configuration. +/// +public sealed class BotConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/bot.yml"; + private static readonly TypedKey _changeKey = new("config.bot.updated"); + public override string Name { get; } = "bot"; + + public BotConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("color.ok", bs => bs.Color.Ok, Rgba32.TryParseHex, ConfigPrinters.Color); + AddParsedProp("color.error", bs => bs.Color.Error, Rgba32.TryParseHex, ConfigPrinters.Color); + AddParsedProp("color.pending", bs => bs.Color.Pending, Rgba32.TryParseHex, ConfigPrinters.Color); + AddParsedProp("help.text", bs => bs.HelpText, ConfigParsers.String, ConfigPrinters.ToString); + AddParsedProp("help.dmtext", bs => bs.DmHelpText, ConfigParsers.String, ConfigPrinters.ToString); + AddParsedProp("console.type", bs => bs.ConsoleOutputType, Enum.TryParse, ConfigPrinters.ToString); + AddParsedProp("locale", bs => bs.DefaultLocale, ConfigParsers.Culture, ConfigPrinters.Culture); + AddParsedProp("prefix", bs => bs.Prefix, ConfigParsers.String, ConfigPrinters.ToString); + AddParsedProp("checkforupdates", bs => bs.CheckForUpdates, bool.TryParse, ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 2) + ModifyConfig(c => c.Version = 2); + + if (data.Version < 3) + { + ModifyConfig(c => + { + c.Version = 3; + c.Blocked.Modules = c.Blocked.Modules?.Select(static x + => string.Equals(x, + "ActualCustomReactions", + StringComparison.InvariantCultureIgnoreCase) + ? "ACTUALEXPRESSIONS" + : x) + .Distinct() + .ToHashSet(); + }); + } + + if (data.Version < 4) + ModifyConfig(c => + { + c.Version = 4; + c.CheckForUpdates = true; + }); + + if(data.Version < 5) + ModifyConfig(c => + { + c.Version = 5; + }); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Settings/ConfigParsers.cs b/src/Ellie.Bot.Common/Settings/ConfigParsers.cs new file mode 100644 index 0000000..fe6a0ac --- /dev/null +++ b/src/Ellie.Bot.Common/Settings/ConfigParsers.cs @@ -0,0 +1,50 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; + +namespace Ellie.Services; + +/// +/// Custom setting value parsers for types which don't have them by default +/// +public static class ConfigParsers +{ + /// + /// Default string parser. Passes input to output and returns true. + /// + public static bool String(string input, out string output) + { + output = input; + return true; + } + + public static bool Culture(string input, out CultureInfo output) + { + try + { + output = new(input); + return true; + } + catch + { + output = null; + return false; + } + } + + public static bool InsensitiveEnum(string input, out T output) + where T : struct + => Enum.TryParse(input, true, out output); +} + +public static class ConfigPrinters +{ + public static string ToString(TAny input) + => input.ToString(); + + public static string Culture(CultureInfo culture) + => culture.Name; + + public static string Color(Rgba32 color) + => ((uint)((color.B << 0) | (color.G << 8) | (color.R << 16))).ToString("X6"); +} diff --git a/src/Ellie.Bot.Common/Settings/ConfigServiceBase.cs b/src/Ellie.Bot.Common/Settings/ConfigServiceBase.cs new file mode 100644 index 0000000..05cf80e --- /dev/null +++ b/src/Ellie.Bot.Common/Settings/ConfigServiceBase.cs @@ -0,0 +1,201 @@ +using Ellie.Common.Configs; +using Ellie.Common.Yml; +using System.Linq.Expressions; +using System.Reflection; + +namespace Ellie.Services; + +/// +/// Base service for all settings services +/// +/// Type of the settings +public abstract class ConfigServiceBase : IConfigService + where TSettings : ICloneable, new() +{ + // FUTURE config arrays are not copied - they're not protected from mutations + public TSettings Data + => data.Clone(); + + public abstract string Name { get; } + protected readonly string _filePath; + protected readonly IConfigSeria _serializer; + protected readonly IPubSub _pubSub; + private readonly TypedKey _changeKey; + + protected TSettings data; + + private readonly Dictionary> _propSetters = new(); + private readonly Dictionary> _propSelectors = new(); + private readonly Dictionary> _propPrinters = new(); + private readonly Dictionary _propComments = new(); + + /// + /// Initialized an instance of + /// + /// Path to the file where the settings are serialized/deserialized to and from + /// Serializer which will be used + /// Pubsub implementation for signaling when settings are updated + /// Key used to signal changed event + protected ConfigServiceBase( + string filePath, + IConfigSeria serializer, + IPubSub pubSub, + TypedKey changeKey) + { + _filePath = filePath; + _serializer = serializer; + _pubSub = pubSub; + _changeKey = changeKey; + + data = new(); + Load(); + _pubSub.Sub(_changeKey, OnChangePublished); + } + + private void PublishChange() + => _pubSub.Pub(_changeKey, data); + + private ValueTask OnChangePublished(TSettings newData) + { + data = newData; + OnStateUpdate(); + return default; + } + + /// + /// Loads data from disk. If file doesn't exist, it will be created with default values + /// + protected void Load() + { + // if file is deleted, regenerate it with default values + if (!File.Exists(_filePath)) + { + data = new(); + Save(); + } + + data = _serializer.Deserialize(File.ReadAllText(_filePath)); + } + + /// + /// Loads new data and publishes the new state + /// + public void Reload() + { + Load(); + _pubSub.Pub(_changeKey, data); + } + + /// + /// Doesn't do anything by default. This method will be executed after + /// is reloaded from or new data is recieved + /// from the publish event + /// + protected virtual void OnStateUpdate() + { + } + + private void Save() + { + var strData = _serializer.Serialize(data); + File.WriteAllText(_filePath, strData); + } + + protected void AddParsedProp( + string key, + Expression> selector, + SettingParser parser, + Func printer, + Func? checker = null) + { + checker ??= _ => true; + key = key.ToLowerInvariant(); + _propPrinters[key] = obj => printer((TProp)obj); + _propSelectors[key] = () => selector.Compile()(data)!; + _propSetters[key] = Magic(selector, parser, checker); + _propComments[key] = ((MemberExpression)selector.Body).Member.GetCustomAttribute()?.Comment; + } + + private Func Magic( + Expression> selector, + SettingParser parser, + Func checker) + => (target, input) => + { + if (!parser(input, out var value)) + return false; + + if (!checker(value)) + return false; + + object targetObject = target; + var expr = (MemberExpression)selector.Body; + var prop = (PropertyInfo)expr.Member; + + var expressions = new List(); + + while (true) + { + expr = expr.Expression as MemberExpression; + if (expr is null) + break; + + expressions.Add(expr); + } + + foreach (var memberExpression in expressions.AsEnumerable().Reverse()) + { + var localProp = (PropertyInfo)memberExpression.Member; + targetObject = localProp.GetValue(targetObject)!; + } + + prop.SetValue(targetObject, value, null); + return true; + }; + + public IReadOnlyList GetSettableProps() + => _propSetters.Keys.ToList(); + + public string? GetSetting(string prop) + { + prop = prop.ToLowerInvariant(); + if (!_propSelectors.TryGetValue(prop, out var selector) || !_propPrinters.TryGetValue(prop, out var printer)) + return null; + + return printer(selector()); + } + + public string? GetComment(string prop) + { + if (_propComments.TryGetValue(prop, out var comment)) + return comment; + + return null; + } + + private bool SetProperty(TSettings target, string key, string value) + => _propSetters.TryGetValue(key.ToLowerInvariant(), out var magic) && magic(target, value); + + public bool SetSetting(string prop, string newValue) + { + var success = true; + ModifyConfig(bs => + { + success = SetProperty(bs, prop, newValue); + }); + + if (success) + PublishChange(); + + return success; + } + + public void ModifyConfig(Action action) + { + var copy = Data; + action(copy); + data = copy; + Save(); + PublishChange(); + } +} diff --git a/src/Ellie.Bot.Common/Settings/IConfigMigrator.cs b/src/Ellie.Bot.Common/Settings/IConfigMigrator.cs new file mode 100644 index 0000000..ed2591d --- /dev/null +++ b/src/Ellie.Bot.Common/Settings/IConfigMigrator.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace Ellie.Services; + +public interface IConfigMigrator +{ + public void EnsureMigrated(); +} diff --git a/src/Ellie.Bot.Common/Settings/IConfigService.cs b/src/Ellie.Bot.Common/Settings/IConfigService.cs new file mode 100644 index 0000000..4f72e59 --- /dev/null +++ b/src/Ellie.Bot.Common/Settings/IConfigService.cs @@ -0,0 +1,46 @@ +#nullable disable +namespace Ellie.Services; + +/// +/// Interface that all services which deal with configs should implement +/// +public interface IConfigService +{ + /// + /// Name of the config + /// + public string Name { get; } + + /// + /// Loads new data and publishes the new state + /// + void Reload(); + + /// + /// Gets the list of props you can set + /// + /// List of props + IReadOnlyList GetSettableProps(); + + /// + /// Gets the value of the specified property + /// + /// Prop name + /// Value of the prop + string GetSetting(string prop); + + /// + /// Gets the value of the specified property + /// + /// Prop name + /// Value of the prop + string GetComment(string prop); + + /// + /// Sets the value of the specified property + /// + /// Property to set + /// Value to set the property to + /// Success + bool SetSetting(string prop, string newValue); +} diff --git a/src/Ellie.Bot.Common/Settings/SettingParser.cs b/src/Ellie.Bot.Common/Settings/SettingParser.cs new file mode 100644 index 0000000..c33671d --- /dev/null +++ b/src/Ellie.Bot.Common/Settings/SettingParser.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace Ellie.Services; + +/// +/// Delegate which describes a parser which can convert string input into given data type +/// +/// Data type to convert string to +public delegate bool SettingParser(string input, out TData output); diff --git a/src/Ellie.Bot.Common/SmartText/SmartEmbedText.cs b/src/Ellie.Bot.Common/SmartText/SmartEmbedText.cs new file mode 100644 index 0000000..af36e7a --- /dev/null +++ b/src/Ellie.Bot.Common/SmartText/SmartEmbedText.cs @@ -0,0 +1,184 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using System.Text.Json.Serialization; + +namespace Ellie; + +public sealed record SmartEmbedArrayElementText : SmartEmbedTextBase +{ + public string Color { get; init; } = string.Empty; + + public SmartEmbedArrayElementText() + { + + } + + public SmartEmbedArrayElementText(IEmbed eb) : base(eb) + { + Color = eb.Color is { } c ? new Rgba32(c.R, c.G, c.B).ToHex() : string.Empty; + } + + protected override EmbedBuilder GetEmbedInternal() + { + var embed = base.GetEmbedInternal(); + if (Rgba32.TryParseHex(Color, out var color)) + return embed.WithColor(color.ToDiscordColor()); + + return embed; + } +} + +public sealed record SmartEmbedText : SmartEmbedTextBase +{ + public string PlainText { get; init; } + + public uint Color { get; init; } = 7458112; + + public SmartEmbedText() + { + } + + private SmartEmbedText(IEmbed eb, string? plainText = null) + : base(eb) + => (PlainText, Color) = (plainText, eb.Color?.RawValue ?? 0); + + public static SmartEmbedText FromEmbed(IEmbed eb, string? plainText = null) + => new(eb, plainText); + + protected override EmbedBuilder GetEmbedInternal() + { + var embed = base.GetEmbedInternal(); + return embed.WithColor(Color); + } +} + +public abstract record SmartEmbedTextBase : SmartText +{ + public string Title { get; init; } + public string Description { get; init; } + public string Url { get; init; } + public string Thumbnail { get; init; } + public string Image { get; init; } + + public SmartTextEmbedAuthor Author { get; init; } + public SmartTextEmbedFooter Footer { get; init; } + public SmartTextEmbedField[] Fields { get; init; } + + [JsonIgnore] + public bool IsValid + => !string.IsNullOrWhiteSpace(Title) + || !string.IsNullOrWhiteSpace(Description) + || !string.IsNullOrWhiteSpace(Url) + || !string.IsNullOrWhiteSpace(Thumbnail) + || !string.IsNullOrWhiteSpace(Image) + || (Footer is not null + && (!string.IsNullOrWhiteSpace(Footer.Text) || !string.IsNullOrWhiteSpace(Footer.IconUrl))) + || Fields is { Length: > 0 }; + + protected SmartEmbedTextBase() + { + + } + + protected SmartEmbedTextBase(IEmbed eb) + { + Title = eb.Title; + Description = eb.Description; + Url = eb.Url; + Thumbnail = eb.Thumbnail?.Url; + Image = eb.Image?.Url; + Author = eb.Author is { } ea + ? new() + { + Name = ea.Name, + Url = ea.Url, + IconUrl = ea.IconUrl + } + : null; + Footer = eb.Footer is { } ef + ? new() + { + Text = ef.Text, + IconUrl = ef.IconUrl + } + : null; + + if (eb.Fields.Length > 0) + { + Fields = eb.Fields.Select(field + => new SmartTextEmbedField + { + Inline = field.Inline, + Name = field.Name, + Value = field.Value + }) + .ToArray(); + } + } + + public EmbedBuilder GetEmbed() + => GetEmbedInternal(); + + protected virtual EmbedBuilder GetEmbedInternal() + { + var embed = new EmbedBuilder(); + + if (!string.IsNullOrWhiteSpace(Title)) + embed.WithTitle(Title); + + if (!string.IsNullOrWhiteSpace(Description)) + embed.WithDescription(Description); + + if (Url is not null && Uri.IsWellFormedUriString(Url, UriKind.Absolute)) + embed.WithUrl(Url); + + if (Footer is not null) + { + embed.WithFooter(efb => + { + efb.WithText(Footer.Text); + if (Uri.IsWellFormedUriString(Footer.IconUrl, UriKind.Absolute)) + efb.WithIconUrl(Footer.IconUrl); + }); + } + + if (Thumbnail is not null && Uri.IsWellFormedUriString(Thumbnail, UriKind.Absolute)) + embed.WithThumbnailUrl(Thumbnail); + + if (Image is not null && Uri.IsWellFormedUriString(Image, UriKind.Absolute)) + embed.WithImageUrl(Image); + + if (Author is not null && !string.IsNullOrWhiteSpace(Author.Name)) + { + if (!Uri.IsWellFormedUriString(Author.IconUrl, UriKind.Absolute)) + Author.IconUrl = null; + if (!Uri.IsWellFormedUriString(Author.Url, UriKind.Absolute)) + Author.Url = null; + + embed.WithAuthor(Author.Name, Author.IconUrl, Author.Url); + } + + if (Fields is not null) + { + foreach (var f in Fields) + { + if (!string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value)) + embed.AddField(f.Name, f.Value, f.Inline); + } + } + + return embed; + } + + public void NormalizeFields() + { + if (Fields is { Length: > 0 }) + { + foreach (var f in Fields) + { + f.Name = f.Name.TrimTo(256); + f.Value = f.Value.TrimTo(1024); + } + } + } +} diff --git a/src/Ellie.Bot.Common/SmartText/SmartEmbedTypeArray.cs b/src/Ellie.Bot.Common/SmartText/SmartEmbedTypeArray.cs new file mode 100644 index 0000000..ae979b0 --- /dev/null +++ b/src/Ellie.Bot.Common/SmartText/SmartEmbedTypeArray.cs @@ -0,0 +1,34 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace Ellie; + +public sealed record SmartEmbedTextArray : SmartText +{ + public string Content { get; set; } + public SmartEmbedArrayElementText[] Embeds { get; set; } + + [JsonIgnore] + public bool IsValid + => Embeds?.All(x => x.IsValid) ?? false; + + public EmbedBuilder[] GetEmbedBuilders() + { + if (Embeds is null) + return Array.Empty(); + + return Embeds + .Where(x => x.IsValid) + .Select(em => em.GetEmbed()) + .ToArray(); + } + + public void NormalizeFields() + { + if (Embeds is null) + return; + + foreach (var eb in Embeds) + eb.NormalizeFields(); + } +} diff --git a/src/Ellie.Bot.Common/SmartText/SmartPlainText.cs b/src/Ellie.Bot.Common/SmartText/SmartPlainText.cs new file mode 100644 index 0000000..6dcaf50 --- /dev/null +++ b/src/Ellie.Bot.Common/SmartText/SmartPlainText.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace Ellie; + +public sealed record SmartPlainText : SmartText +{ + public string Text { get; init; } + + public SmartPlainText(string text) + => Text = text; + + public static implicit operator SmartPlainText(string input) + => new(input); + + public static implicit operator string(SmartPlainText input) + => input.Text; + + public override string ToString() + => Text; +} diff --git a/src/Ellie.Bot.Common/SmartText/SmartText.cs b/src/Ellie.Bot.Common/SmartText/SmartText.cs new file mode 100644 index 0000000..3481b44 --- /dev/null +++ b/src/Ellie.Bot.Common/SmartText/SmartText.cs @@ -0,0 +1,89 @@ +#nullable disable +using Newtonsoft.Json.Linq; +using System.Text.Json.Serialization; + +namespace Ellie; + +public abstract record SmartText +{ + [JsonIgnore] + public bool IsEmbed + => this is SmartEmbedText; + + [JsonIgnore] + public bool IsPlainText + => this is SmartPlainText; + + [JsonIgnore] + public bool IsEmbedArray + => this is SmartEmbedTextArray; + + public static SmartText operator +(SmartText text, string input) + => text switch + { + SmartEmbedText set => set with + { + PlainText = set.PlainText + input + }, + SmartPlainText spt => new SmartPlainText(spt.Text + input), + SmartEmbedTextArray arr => arr with + { + Content = arr.Content + input + }, + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static SmartText operator +(string input, SmartText text) + => text switch + { + SmartEmbedText set => set with + { + PlainText = input + set.PlainText + }, + SmartPlainText spt => new SmartPlainText(input + spt.Text), + SmartEmbedTextArray arr => arr with + { + Content = input + arr.Content + }, + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static SmartText CreateFrom(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return new SmartPlainText(input); + + try + { + var doc = JObject.Parse(input); + var root = doc.Root; + if (root.Type == JTokenType.Object) + { + if (((JObject)root).TryGetValue("embeds", out _)) + { + var arr = root.ToObject(); + + if (arr is null) + return new SmartPlainText(input); + + arr!.NormalizeFields(); + return arr; + } + + var obj = root.ToObject(); + + if (obj is null || !(obj.IsValid || !string.IsNullOrWhiteSpace(obj.PlainText))) + return new SmartPlainText(input); + + obj.NormalizeFields(); + return obj; + } + + return new SmartPlainText(input); + } + catch + { + return new SmartPlainText(input); + } + } +} diff --git a/src/Ellie.Bot.Common/SmartText/SmartTextEmbedAuthor.cs b/src/Ellie.Bot.Common/SmartText/SmartTextEmbedAuthor.cs new file mode 100644 index 0000000..f89a437 --- /dev/null +++ b/src/Ellie.Bot.Common/SmartText/SmartTextEmbedAuthor.cs @@ -0,0 +1,14 @@ +#nullable disable +using Newtonsoft.Json; + +namespace Ellie; + +public class SmartTextEmbedAuthor +{ + public string Name { get; set; } + + [JsonProperty("icon_url")] + public string IconUrl { get; set; } + + public string Url { get; set; } +} diff --git a/src/Ellie.Bot.Common/SmartText/SmartTextEmbedField.cs b/src/Ellie.Bot.Common/SmartText/SmartTextEmbedField.cs new file mode 100644 index 0000000..338c084 --- /dev/null +++ b/src/Ellie.Bot.Common/SmartText/SmartTextEmbedField.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace Ellie; + +public class SmartTextEmbedField +{ + public string Name { get; set; } + public string Value { get; set; } + public bool Inline { get; set; } +} diff --git a/src/Ellie.Bot.Common/SmartText/SmartTextEmbedFooter.cs b/src/Ellie.Bot.Common/SmartText/SmartTextEmbedFooter.cs new file mode 100644 index 0000000..6e3e6c6 --- /dev/null +++ b/src/Ellie.Bot.Common/SmartText/SmartTextEmbedFooter.cs @@ -0,0 +1,12 @@ +#nullable disable +using Newtonsoft.Json; + +namespace Ellie; + +public class SmartTextEmbedFooter +{ + public string Text { get; set; } + + [JsonProperty("icon_url")] + public string IconUrl { get; set; } +} diff --git a/src/Ellie.Bot.Common/TypeReaderResult.cs b/src/Ellie.Bot.Common/TypeReaderResult.cs new file mode 100644 index 0000000..f92f098 --- /dev/null +++ b/src/Ellie.Bot.Common/TypeReaderResult.cs @@ -0,0 +1,30 @@ +namespace Ellie.Common.TypeReaders; + +public readonly struct TypeReaderResult +{ + public bool IsSuccess + => _result.IsSuccess; + + public IReadOnlyCollection Values + => _result.Values; + + private readonly Discord.Commands.TypeReaderResult _result; + + private TypeReaderResult(in Discord.Commands.TypeReaderResult result) + => _result = result; + + public static implicit operator TypeReaderResult(in Discord.Commands.TypeReaderResult result) + => new(result); + + public static implicit operator Discord.Commands.TypeReaderResult(in TypeReaderResult wrapper) + => wrapper._result; +} + +public static class TypeReaderResult +{ + public static TypeReaderResult FromError(CommandError error, string reason) + => Discord.Commands.TypeReaderResult.FromError(error, reason); + + public static TypeReaderResult FromSuccess(in T value) + => Discord.Commands.TypeReaderResult.FromSuccess(value); +} diff --git a/src/Ellie.Bot.Common/TypeReaders/CommandOrExprInfo.cs b/src/Ellie.Bot.Common/TypeReaders/CommandOrExprInfo.cs new file mode 100644 index 0000000..13d57cd --- /dev/null +++ b/src/Ellie.Bot.Common/TypeReaders/CommandOrExprInfo.cs @@ -0,0 +1,23 @@ +#nullable disable +namespace Ellie.Common.TypeReaders; + +public class CommandOrExprInfo +{ + public enum Type + { + Normal, + Custom + } + + public string Name { get; set; } + public Type CmdType { get; set; } + + public bool IsCustom + => CmdType == Type.Custom; + + public CommandOrExprInfo(string input, Type type) + { + Name = input; + CmdType = type; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/TypeReaders/GuildDateTimeTypeReader.cs b/src/Ellie.Bot.Common/TypeReaders/GuildDateTimeTypeReader.cs new file mode 100644 index 0000000..9296043 --- /dev/null +++ b/src/Ellie.Bot.Common/TypeReaders/GuildDateTimeTypeReader.cs @@ -0,0 +1,49 @@ +#nullable disable +namespace Ellie.Common.TypeReaders; + +public sealed class GuildDateTimeTypeReader : EllieTypeReader +{ + private readonly ITimezoneService _gts; + + public GuildDateTimeTypeReader(ITimezoneService gts) + => _gts = gts; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + var gdt = Parse(context.Guild.Id, input); + if (gdt is null) + { + return new(TypeReaderResult.FromError(CommandError.ParseFailed, + "Input string is in an incorrect format.")); + } + + return new(TypeReaderResult.FromSuccess(gdt)); + } + + private GuildDateTime Parse(ulong guildId, string input) + { + if (!DateTime.TryParse(input, out var dt)) + return null; + + var tz = _gts.GetTimeZoneOrUtc(guildId); + + return new(tz, dt); + } +} + +public class GuildDateTime +{ + public TimeZoneInfo Timezone { get; } + public DateTime CurrentGuildTime { get; } + public DateTime InputTime { get; } + public DateTime InputTimeUtc { get; } + + public GuildDateTime(TimeZoneInfo guildTimezone, DateTime inputTime) + { + var now = DateTime.UtcNow; + Timezone = guildTimezone; + CurrentGuildTime = TimeZoneInfo.ConvertTime(now, TimeZoneInfo.Utc, Timezone); + InputTime = inputTime; + InputTimeUtc = TimeZoneInfo.ConvertTime(inputTime, Timezone, TimeZoneInfo.Utc); + } +} diff --git a/src/Ellie.Bot.Common/Yml/CommentAttribute.cs b/src/Ellie.Bot.Common/Yml/CommentAttribute.cs new file mode 100644 index 0000000..478c02f --- /dev/null +++ b/src/Ellie.Bot.Common/Yml/CommentAttribute.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace Ellie.Common.Yml; + +[AttributeUsage(AttributeTargets.Property)] +public class CommentAttribute : Attribute +{ + public string Comment { get; } + + public CommentAttribute(string comment) + => Comment = comment; +} diff --git a/src/Ellie.Bot.Common/Yml/CommentGatheringTypeInspector.cs b/src/Ellie.Bot.Common/Yml/CommentGatheringTypeInspector.cs new file mode 100644 index 0000000..8420b98 --- /dev/null +++ b/src/Ellie.Bot.Common/Yml/CommentGatheringTypeInspector.cs @@ -0,0 +1,65 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.TypeInspectors; + +namespace Ellie.Common.Yml; + +public class CommentGatheringTypeInspector : TypeInspectorSkeleton +{ + public readonly ITypeInspector _innerTypeDescriptor; + + public CommentGatheringTypeInspector(ITypeInspector innerTypeDescriptor) + => _innerTypeDescriptor = innerTypeDescriptor ?? throw new ArgumentNullException(nameof(innerTypeDescriptor)); + + public override IEnumerable GetProperties(Type type, object container) + => _innerTypeDescriptor.GetProperties(type, container).Select(d => new CommentsPropertyDescriptor(d)); + + private sealed class CommentsPropertyDescriptor : IPropertyDescriptor + { + public string Name { get; } + + public Type Type + => _baseDescriptor.Type; + + public Type TypeOverride + { + get => _baseDescriptor.TypeOverride; + set => _baseDescriptor.TypeOverride = value; + } + + public int Order { get; set; } + + public ScalarStyle ScalarStyle + { + get => _baseDescriptor.ScalarStyle; + set => _baseDescriptor.ScalarStyle = value; + } + + public bool CanWrite + => _baseDescriptor.CanWrite; + + private readonly IPropertyDescriptor _baseDescriptor; + + public CommentsPropertyDescriptor(IPropertyDescriptor baseDescriptor) + { + _baseDescriptor = baseDescriptor; + Name = baseDescriptor.Name; + } + + public void Write(object target, object value) + => _baseDescriptor.Write(target, value); + + public T GetCustomAttribute() + where T : Attribute + => _baseDescriptor.GetCustomAttribute(); + + public IObjectDescriptor Read(object target) + { + var comment = _baseDescriptor.GetCustomAttribute(); + return comment is not null + ? new CommentsObjectDescriptor(_baseDescriptor.Read(target), comment.Comment) + : _baseDescriptor.Read(target); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Yml/CommentsObjectDescriptor.cs b/src/Ellie.Bot.Common/Yml/CommentsObjectDescriptor.cs new file mode 100644 index 0000000..08cddb7 --- /dev/null +++ b/src/Ellie.Bot.Common/Yml/CommentsObjectDescriptor.cs @@ -0,0 +1,30 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace Ellie.Common.Yml; + +public sealed class CommentsObjectDescriptor : IObjectDescriptor +{ + public string Comment { get; } + + public object Value + => _innerDescriptor.Value; + + public Type Type + => _innerDescriptor.Type; + + public Type StaticType + => _innerDescriptor.StaticType; + + public ScalarStyle ScalarStyle + => _innerDescriptor.ScalarStyle; + + private readonly IObjectDescriptor _innerDescriptor; + + public CommentsObjectDescriptor(IObjectDescriptor innerDescriptor, string comment) + { + _innerDescriptor = innerDescriptor; + Comment = comment; + } +} diff --git a/src/Ellie.Bot.Common/Yml/CommentsObjectGraphVisitor.cs b/src/Ellie.Bot.Common/Yml/CommentsObjectGraphVisitor.cs new file mode 100644 index 0000000..c485549 --- /dev/null +++ b/src/Ellie.Bot.Common/Yml/CommentsObjectGraphVisitor.cs @@ -0,0 +1,29 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.ObjectGraphVisitors; + +namespace Ellie.Common.Yml; + +public class CommentsObjectGraphVisitor : ChainedObjectGraphVisitor +{ + public CommentsObjectGraphVisitor(IObjectGraphVisitor nextVisitor) + : base(nextVisitor) + { + } + + public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context) + { + if (value is CommentsObjectDescriptor commentsDescriptor + && !string.IsNullOrWhiteSpace(commentsDescriptor.Comment)) + { + var parts = commentsDescriptor.Comment.Split('\n'); + + foreach (var part in parts) + context.Emit(new Comment(part.Trim(), false)); + } + + return base.EnterMapping(key, value, context); + } +} diff --git a/src/Ellie.Bot.Common/Yml/MultilineScalarFlowStyleEmitter.cs b/src/Ellie.Bot.Common/Yml/MultilineScalarFlowStyleEmitter.cs new file mode 100644 index 0000000..096ea2d --- /dev/null +++ b/src/Ellie.Bot.Common/Yml/MultilineScalarFlowStyleEmitter.cs @@ -0,0 +1,35 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.EventEmitters; + +namespace Ellie.Common.Yml; + +public class MultilineScalarFlowStyleEmitter : ChainedEventEmitter +{ + public MultilineScalarFlowStyleEmitter(IEventEmitter nextEmitter) + : base(nextEmitter) + { + } + + public override void Emit(ScalarEventInfo eventInfo, IEmitter emitter) + { + if (typeof(string).IsAssignableFrom(eventInfo.Source.Type)) + { + var value = eventInfo.Source.Value as string; + if (!string.IsNullOrEmpty(value)) + { + var isMultiline = value.IndexOfAny(new[] { '\r', '\n', '\x85', '\x2028', '\x2029' }) >= 0; + if (isMultiline) + { + eventInfo = new(eventInfo.Source) + { + Style = ScalarStyle.Literal + }; + } + } + } + + nextEmitter.Emit(eventInfo, emitter); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/Yml/Rgba32Converter.cs b/src/Ellie.Bot.Common/Yml/Rgba32Converter.cs new file mode 100644 index 0000000..477e8ec --- /dev/null +++ b/src/Ellie.Bot.Common/Yml/Rgba32Converter.cs @@ -0,0 +1,47 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Ellie.Common.Yml; + +public class Rgba32Converter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(Rgba32); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = Rgba32.ParseHex(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var color = (Rgba32)value; + var val = (uint)((color.B << 0) | (color.G << 8) | (color.R << 16)); + emitter.Emit(new Scalar(val.ToString("X6").ToLower())); + } +} + +public class CultureInfoConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(CultureInfo); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = new CultureInfo(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var ci = (CultureInfo)value; + emitter.Emit(new Scalar(ci.Name)); + } +} diff --git a/src/Ellie.Bot.Common/Yml/UrlConverter.cs b/src/Ellie.Bot.Common/Yml/UrlConverter.cs new file mode 100644 index 0000000..3054e65 --- /dev/null +++ b/src/Ellie.Bot.Common/Yml/UrlConverter.cs @@ -0,0 +1,25 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Ellie.Common.Yml; + +public class UriConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(Uri); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = new Uri(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var uri = (Uri)value; + emitter.Emit(new Scalar(uri.ToString())); + } +} diff --git a/src/Ellie.Bot.Common/Yml/Yaml.cs b/src/Ellie.Bot.Common/Yml/Yaml.cs new file mode 100644 index 0000000..ef7e114 --- /dev/null +++ b/src/Ellie.Bot.Common/Yml/Yaml.cs @@ -0,0 +1,28 @@ +#nullable disable +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Ellie.Common.Yml; + +public class Yaml +{ + public static ISerializer Serializer + => new SerializerBuilder().WithTypeInspector(inner => new CommentGatheringTypeInspector(inner)) + .WithEmissionPhaseObjectGraphVisitor(args + => new CommentsObjectGraphVisitor(args.InnerVisitor)) + .WithEventEmitter(args => new MultilineScalarFlowStyleEmitter(args)) + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithIndentedSequences() + .WithTypeConverter(new Rgba32Converter()) + .WithTypeConverter(new CultureInfoConverter()) + .WithTypeConverter(new UriConverter()) + .Build(); + + public static IDeserializer Deserializer + => new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new Rgba32Converter()) + .WithTypeConverter(new CultureInfoConverter()) + .WithTypeConverter(new UriConverter()) + .IgnoreUnmatchedProperties() + .Build(); +} diff --git a/src/Ellie.Bot.Common/_Extensions/BotCredentialsExtensions.cs b/src/Ellie.Bot.Common/_Extensions/BotCredentialsExtensions.cs new file mode 100644 index 0000000..3e3ad90 --- /dev/null +++ b/src/Ellie.Bot.Common/_Extensions/BotCredentialsExtensions.cs @@ -0,0 +1,10 @@ +namespace Ellie.Extensions; + +public static class BotCredentialsExtensions +{ + public static bool IsOwner(this IBotCredentials creds, IUser user) + => creds.IsOwner(user.Id); + + public static bool IsOwner(this IBotCredentials creds, ulong userId) + => creds.OwnerIds.Contains(userId); +} diff --git a/src/Ellie.Bot.Common/_Extensions/Extensions.cs b/src/Ellie.Bot.Common/_Extensions/Extensions.cs new file mode 100644 index 0000000..1452e4d --- /dev/null +++ b/src/Ellie.Bot.Common/_Extensions/Extensions.cs @@ -0,0 +1,207 @@ +using Humanizer.Localisation; +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using Ellie.Marmalade; + +namespace Ellie.Extensions; + +public static class Extensions +{ + public static DateOnly ToDateOnly(this DateTime dateTime) + => DateOnly.FromDateTime(dateTime); + + public static bool IsBeforeToday(this DateTime date) + => date < DateTime.UtcNow.Date; + + private static readonly Regex _urlRegex = + new(@"^(https?|ftp)://(?[^\s/$.?#].[^\s]*)$", RegexOptions.Compiled); + + public static IEmbedBuilder WithAuthor(this IEmbedBuilder eb, IUser author) + => eb.WithAuthor(author.ToString()!, author.RealAvatarUrl().ToString()); + + public static Task EditAsync(this IUserMessage msg, SmartText text) + => text switch + { + SmartEmbedText set => msg.ModifyAsync(x => + { + x.Embed = set.IsValid ? set.GetEmbed().Build() : null; + x.Content = set.PlainText?.SanitizeMentions() ?? ""; + }), + SmartEmbedTextArray set => msg.ModifyAsync(x => + { + x.Embeds = set.GetEmbedBuilders().Map(eb => eb.Build()); + x.Content = set.Content?.SanitizeMentions() ?? ""; + }), + SmartPlainText spt => msg.ModifyAsync(x => + { + x.Content = spt.Text.SanitizeMentions(); + x.Embed = null; + }), + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static ulong[] GetGuildIds(this DiscordSocketClient client) + => client.Guilds + .Map(x => x.Id); + + /// + /// Generates a string in the format HHH:mm if timespan is >= 2m. + /// Generates a string in the format 00:mm:ss if timespan is less than 2m. + /// + /// Timespan to convert to string + /// Formatted duration string + public static string ToPrettyStringHm(this TimeSpan span) + => span.Humanize(2, minUnit: TimeUnit.Second); + + public static bool TryGetUrlPath(this string input, out string path) + { + var match = _urlRegex.Match(input); + if (match.Success) + { + path = match.Groups["path"].Value; + return true; + } + + path = string.Empty; + return false; + } + + public static IEmote ToIEmote(this string emojiStr) + => Emote.TryParse(emojiStr, out var maybeEmote) ? maybeEmote : new Emoji(emojiStr); + + + /// + /// First 10 characters of teh bot token. + /// + public static string RedisKey(this IBotCredentials bc) + => bc.Token[..10]; + + public static bool IsAuthor(this IMessage msg, IDiscordClient client) + => msg.Author?.Id == client.CurrentUser.Id; + + public static string RealSummary( + this CommandInfo cmd, + IBotStrings strings, + IMarmaladeLoaderSevice marmalades, + CultureInfo culture, + string prefix) + { + string description; + if (cmd.Remarks?.StartsWith("marmalade///") ?? false) + { + // command method name is kept in Summary + // marmalade/// is kept in remarks + // this way I can find the name of the marmalade, and then name of the command for which + // the description should be loaded + var marmaladeName = cmd.Remarks.Split("///")[1]; + description = marmalades.GetCommandDescription(marmaladeName, cmd.Summary, culture); + } + else + { + description = strings.GetCommandStrings(cmd.Summary, culture).Desc; + } + + return string.Format(description, prefix); + } + + public static string[] RealRemarksArr( + this CommandInfo cmd, + IBotStrings strings, + IMarmaladeLoaderSevice marmalades, + CultureInfo culture, + string prefix) + { + string[] args; + if (cmd.Remarks?.StartsWith("marmalade///") ?? false) + { + // command method name is kept in Summary + // marmalade/// is kept in remarks + // this way I can find the name of the marmalade, + // and command for which data should be loaded + var marmaladeName = cmd.Remarks.Split("///")[1]; + args = marmalades.GetCommandExampleArgs(marmaladeName, cmd.Summary, culture); + } + else + { + args = strings.GetCommandStrings(cmd.Summary, culture).Args; + } + + return args.Map(arg => GetFullUsage(cmd.Aliases.First(), arg, prefix)); + } + + private static string GetFullUsage(string commandName, string args, string prefix) + => $"{prefix}{commandName} {string.Format(args, prefix)}".TrimEnd(); + + public static IEmbedBuilder AddPaginatedFooter(this IEmbedBuilder embed, int curPage, int? lastPage) + { + if (lastPage is not null) + return embed.WithFooter($"{curPage + 1} / {lastPage + 1}"); + return embed.WithFooter(curPage.ToString()); + } + + 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); + + public static IMessage DeleteAfter(this IUserMessage msg, float seconds, ILogCommandService? logService = null) + { + Task.Run(async () => + { + await Task.Delay((int)(seconds * 1000)); + if (logService is not null) + logService.AddDeleteIgnore(msg.Id); + + try + { + await msg.DeleteAsync(); + } + catch + { + } + }); + return msg; + } + + public static ModuleInfo GetTopLevelModule(this ModuleInfo module) + { + while (module.Parent is not null) + module = module.Parent; + + return module; + } + + public static string GetGroupName(this ModuleInfo module) + => module.Name.Replace("Commands", "", StringComparison.InvariantCulture); + + public static async Task> GetMembersAsync(this IRole role) + { + var users = await role.Guild.GetUsersAsync(CacheMode.CacheOnly); + return users.Where(u => u.RoleIds.Contains(role.Id)); + } + + public static string ToJson(this T any, JsonSerializerOptions? options = null) + => JsonSerializer.Serialize(any, options); + + public static Stream ToStream(this IEnumerable bytes, bool canWrite = false) + { + var ms = new MemoryStream(bytes as byte[] ?? bytes.ToArray(), canWrite); + ms.Seek(0, SeekOrigin.Begin); + return ms; + } + + public static IEnumerable GetRoles(this IGuildUser user) + => user.RoleIds.Select(r => user.Guild.GetRole(r)).Where(r => r is not null); + + public static void Lap(this Stopwatch sw, string checkpoint) + { + Log.Information("Checkpoint {CheckPoint}: {Time}ms", checkpoint, sw.Elapsed.TotalMilliseconds); + sw.Restart(); + } +} diff --git a/src/Ellie.Bot.Common/_Extensions/IMessageChannelExtensions.cs b/src/Ellie.Bot.Common/_Extensions/IMessageChannelExtensions.cs new file mode 100644 index 0000000..0598638 --- /dev/null +++ b/src/Ellie.Bot.Common/_Extensions/IMessageChannelExtensions.cs @@ -0,0 +1,328 @@ +namespace Ellie.Extensions; + +public static class MessageChannelExtensions +{ + // main overload that all other send methods reduce to + public static Task SendAsync( + this IMessageChannel channel, + string? plainText, + Embed? embed = null, + IReadOnlyCollection? embeds = null, + bool sanitizeAll = false, + MessageComponent? components = null) + { + plainText = sanitizeAll + ? plainText?.SanitizeAllMentions() ?? "" + : plainText?.SanitizeMentions() ?? ""; + + return channel.SendMessageAsync(plainText, + embed: embed, + embeds: embeds is null + ? null + : embeds as Embed[] ?? embeds.ToArray(), + components: components); + } + + public static async Task SendAsync( + this IMessageChannel channel, + string? plainText, + EllieInteraction? inter, + Embed? embed = null, + IReadOnlyCollection? embeds = null, + bool sanitizeAll = false) + { + var msg = await channel.SendAsync(plainText, + embed, + embeds, + sanitizeAll, + inter?.CreateComponent()); + + if (inter is not null) + await inter.RunAsync(msg); + + return msg; + } + + public static Task SendAsync( + this IMessageChannel channel, + SmartText text, + bool sanitizeAll = false) + => text switch + { + SmartEmbedText set => channel.SendAsync(set.PlainText, + set.IsValid ? set.GetEmbed().Build() : null, + sanitizeAll: sanitizeAll), + SmartPlainText st => channel.SendAsync(st.Text, + default(Embed), + sanitizeAll: sanitizeAll), + SmartEmbedTextArray arr => channel.SendAsync(arr.Content, + embeds: arr.GetEmbedBuilders().Map(e => e.Build())), + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static Task EmbedAsync( + this IMessageChannel ch, + IEmbedBuilder? embed, + string plainText = "", + IReadOnlyCollection? embeds = null, + EllieInteraction? inter = null) + => ch.SendAsync(plainText, + inter, + embed: embed?.Build(), + embeds: embeds?.Map(x => x.Build())); + + public static Task SendAsync( + this IMessageChannel ch, + IEmbedBuilderService eb, + string text, + MsgType type, + EllieInteraction? inter = null) + { + var builder = eb.Create().WithDescription(text); + + builder = (type switch + { + MsgType.Error => builder.WithErrorColor(), + MsgType.Ok => builder.WithOkColor(), + MsgType.Pending => builder.WithPendingColor(), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }); + + return ch.EmbedAsync(builder, inter: inter); + } + + // regular send overloads + public static Task SendErrorAsync(this IMessageChannel ch, IEmbedBuilderService eb, string text) + => ch.SendAsync(eb, text, MsgType.Error); + + public static Task SendConfirmAsync(this IMessageChannel ch, IEmbedBuilderService eb, string text) + => ch.SendAsync(eb, text, MsgType.Ok); + + public static Task SendAsync( + this IMessageChannel ch, + IEmbedBuilderService eb, + MsgType type, + string? title, + string text, + string? url = null, + string? footer = null) + { + var embed = eb.Create() + .WithDescription(text) + .WithTitle(title); + + if (url is not null && Uri.IsWellFormedUriString(url, UriKind.Absolute)) + embed.WithUrl(url); + + if (!string.IsNullOrWhiteSpace(footer)) + embed.WithFooter(footer); + + embed = type switch + { + MsgType.Error => embed.WithErrorColor(), + MsgType.Ok => embed.WithOkColor(), + MsgType.Pending => embed.WithPendingColor(), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + return ch.EmbedAsync(embed); + } + + // embed title and optional footer overloads + + public static Task SendConfirmAsync( + this IMessageChannel ch, + IEmbedBuilderService eb, + string? title, + string text, + string? url = null, + string? footer = null) + => ch.SendAsync(eb, MsgType.Ok, title, text, url, footer); + + public static Task SendErrorAsync( + this IMessageChannel ch, + IEmbedBuilderService eb, + string title, + string text, + string? url = null, + string? footer = null) + => ch.SendAsync(eb, MsgType.Error, title, text, url, footer); + + public static Task SendPaginatedConfirmAsync( + this ICommandContext ctx, + int currentPage, + Func pageFunc, + int totalElements, + int itemsPerPage, + bool addPaginatedFooter = true) + => ctx.SendPaginatedConfirmAsync(currentPage, + x => Task.FromResult(pageFunc(x)), + totalElements, + itemsPerPage, + addPaginatedFooter); + + private const string BUTTON_LEFT = "BUTTON_LEFT"; + private const string BUTTON_RIGHT = "BUTTON_RIGHT"; + + private static readonly IEmote _arrowLeft = Emote.Parse("<:x:969658061805465651>"); + private static readonly IEmote _arrowRight = Emote.Parse("<:x:969658062220701746>"); + + public static Task SendPaginatedConfirmAsync( + this ICommandContext ctx, + int currentPage, + Func> pageFunc, + int totalElements, + int itemsPerPage, + bool addPaginatedFooter = true) + => ctx.SendPaginatedConfirmAsync(currentPage, + pageFunc, + default(Func?>>), + totalElements, + itemsPerPage, + addPaginatedFooter); + + public static async Task SendPaginatedConfirmAsync( + this ICommandContext ctx, + int currentPage, + Func> pageFunc, + Func?>>? interFactory, + int totalElements, + int itemsPerPage, + bool addPaginatedFooter = true) + { + var lastPage = (totalElements - 1) / itemsPerPage; + + var embed = await pageFunc(currentPage); + + if (addPaginatedFooter) + embed.AddPaginatedFooter(currentPage, lastPage); + + SimpleInteraction? maybeInter = null; + async Task GetComponentBuilder() + { + var cb = new ComponentBuilder(); + + cb.WithButton(new ButtonBuilder() + .WithStyle(ButtonStyle.Primary) + .WithCustomId(BUTTON_LEFT) + .WithDisabled(lastPage == 0) + .WithEmote(_arrowLeft) + .WithDisabled(currentPage <= 0)); + + if (interFactory is not null) + { + maybeInter = await interFactory(currentPage); + + if (maybeInter is not null) + cb.WithButton(maybeInter.Button); + } + + cb.WithButton(new ButtonBuilder() + .WithStyle(ButtonStyle.Primary) + .WithCustomId(BUTTON_RIGHT) + .WithDisabled(lastPage == 0 || currentPage >= lastPage) + .WithEmote(_arrowRight)); + + return cb; + } + + async Task UpdatePageAsync(SocketMessageComponent smc) + { + var toSend = await pageFunc(currentPage); + if (addPaginatedFooter) + toSend.AddPaginatedFooter(currentPage, lastPage); + + var component = (await GetComponentBuilder()).Build(); + + await smc.ModifyOriginalResponseAsync(x => + { + x.Embed = toSend.Build(); + x.Components = component; + }); + } + + var component = (await GetComponentBuilder()).Build(); + var msg = await ctx.Channel.SendAsync(null, embed: embed.Build(), components: component); + + async Task OnInteractionAsync(SocketInteraction si) + { + try + { + if (si is not SocketMessageComponent smc) + return; + + if (smc.Message.Id != msg.Id) + return; + + await si.DeferAsync(); + if (smc.User.Id != ctx.User.Id) + return; + + if (smc.Data.CustomId == BUTTON_LEFT) + { + if (currentPage == 0) + return; + + --currentPage; + _ = UpdatePageAsync(smc); + } + else if (smc.Data.CustomId == BUTTON_RIGHT) + { + if (currentPage >= lastPage) + return; + + ++currentPage; + _ = UpdatePageAsync(smc); + } + else if (maybeInter is { } inter && inter.Button.CustomId == smc.Data.CustomId) + { + await inter.TriggerAsync(smc); + _ = UpdatePageAsync(smc); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error in pagination: {ErrorMessage}", ex.Message); + } + } + + if (lastPage == 0 && interFactory is null) + return; + + var client = (DiscordSocketClient)ctx.Client; + + client.InteractionCreated += OnInteractionAsync; + + await Task.Delay(30_000); + + client.InteractionCreated -= OnInteractionAsync; + + await msg.ModifyAsync(mp => mp.Components = new ComponentBuilder().Build()); + } + + private static readonly Emoji _okEmoji = new Emoji("✅"); + private static readonly Emoji _warnEmoji = new Emoji("⚠️"); + private static readonly Emoji _errorEmoji = new Emoji("❌"); + + public static Task ReactAsync(this ICommandContext ctx, MsgType type) + { + var emoji = type switch + { + MsgType.Error => _errorEmoji, + MsgType.Pending => _warnEmoji, + MsgType.Ok => _okEmoji, + _ => throw new ArgumentOutOfRangeException(nameof(type)), + }; + + return ctx.Message.AddReactionAsync(emoji); + } + + public static Task OkAsync(this ICommandContext ctx) + => ctx.ReactAsync(MsgType.Ok); + + public static Task ErrorAsync(this ICommandContext ctx) + => ctx.ReactAsync(MsgType.Error); + + public static Task WarningAsync(this ICommandContext ctx) + => ctx.ReactAsync(MsgType.Pending); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/_Extensions/LinkedListExtensions.cs b/src/Ellie.Bot.Common/_Extensions/LinkedListExtensions.cs new file mode 100644 index 0000000..a2cc826 --- /dev/null +++ b/src/Ellie.Bot.Common/_Extensions/LinkedListExtensions.cs @@ -0,0 +1,18 @@ +namespace Ellie.Extensions; + +public static class LinkedListExtensions +{ + public static LinkedListNode? FindNode(this LinkedList list, Func predicate) + { + var node = list.First; + while (node is not null) + { + if (predicate(node.Value)) + return node; + + node = node.Next; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/_Extensions/NumberExtensions.cs b/src/Ellie.Bot.Common/_Extensions/NumberExtensions.cs new file mode 100644 index 0000000..a65fc5f --- /dev/null +++ b/src/Ellie.Bot.Common/_Extensions/NumberExtensions.cs @@ -0,0 +1,7 @@ +namespace Ellie.Extensions; + +public static class NumberExtensions +{ + public static DateTimeOffset ToUnixTimestamp(this double number) + => new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(number); +} diff --git a/src/Ellie.Bot.Common/_Extensions/ReflectionExtensions.cs b/src/Ellie.Bot.Common/_Extensions/ReflectionExtensions.cs new file mode 100644 index 0000000..c66df67 --- /dev/null +++ b/src/Ellie.Bot.Common/_Extensions/ReflectionExtensions.cs @@ -0,0 +1,23 @@ +namespace Ellie.Extensions; + +public static class ReflectionExtensions +{ + public static bool IsAssignableToGenericType(this Type givenType, Type genericType) + { + var interfaceTypes = givenType.GetInterfaces(); + + foreach (var it in interfaceTypes) + { + if (it.IsGenericType && it.GetGenericTypeDefinition() == genericType) + return true; + } + + if (givenType.IsGenericType && givenType.GetGenericTypeDefinition() == genericType) + return true; + + var baseType = givenType.BaseType; + if (baseType == null) return false; + + return IsAssignableToGenericType(baseType, genericType); + } +} diff --git a/src/Ellie.Bot.Common/_Extensions/Rgba32Extensions.cs b/src/Ellie.Bot.Common/_Extensions/Rgba32Extensions.cs new file mode 100644 index 0000000..d521221 --- /dev/null +++ b/src/Ellie.Bot.Common/_Extensions/Rgba32Extensions.cs @@ -0,0 +1,57 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace Ellie.Extensions; + +public static class Rgba32Extensions +{ + public static Image Merge(this IEnumerable> images) + => images.Merge(out _); + + public static Image Merge(this IEnumerable> images, out IImageFormat format) + { + format = PngFormat.Instance; + + void DrawFrame(IList> imgArray, Image imgFrame, int frameNumber) + { + var xOffset = 0; + for (var i = 0; i < imgArray.Count; i++) + { + using var frame = imgArray[i].Frames.CloneFrame(frameNumber % imgArray[i].Frames.Count); + var offset = xOffset; + imgFrame.Mutate(x => x.DrawImage(frame, new(offset, 0), new GraphicsOptions())); + xOffset += imgArray[i].Bounds().Width; + } + } + + var imgs = images.ToList(); + var frames = imgs.Max(x => x.Frames.Count); + + var width = imgs.Sum(img => img.Width); + var height = imgs.Max(img => img.Height); + var canvas = new Image(width, height); + if (frames == 1) + { + DrawFrame(imgs, canvas, 0); + return canvas; + } + + format = GifFormat.Instance; + for (var j = 0; j < frames; j++) + { + using var imgFrame = new Image(width, height); + DrawFrame(imgs, imgFrame, j); + + var frameToAdd = imgFrame.Frames[0]; + frameToAdd.Metadata.GetGifMetadata().DisposalMethod = GifDisposalMethod.RestoreToBackground; + canvas.Frames.AddFrame(frameToAdd); + } + + canvas.Frames.RemoveFrame(0); + return canvas; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Common/_Extensions/SocketMessageComponentExtensions.cs b/src/Ellie.Bot.Common/_Extensions/SocketMessageComponentExtensions.cs new file mode 100644 index 0000000..87b53ff --- /dev/null +++ b/src/Ellie.Bot.Common/_Extensions/SocketMessageComponentExtensions.cs @@ -0,0 +1,96 @@ +namespace Ellie.Extensions; + +public static class SocketMessageComponentExtensions +{ + public static Task RespondAsync( + this SocketMessageComponent smc, + string? plainText, + Embed? embed = null, + IReadOnlyCollection? embeds = null, + bool sanitizeAll = false, + MessageComponent? components = null, + bool ephemeral = true) + { + plainText = sanitizeAll + ? plainText?.SanitizeAllMentions() ?? "" + : plainText?.SanitizeMentions() ?? ""; + + return smc.RespondAsync(plainText, + embed: embed, + embeds: embeds is null + ? null + : embeds as Embed[] ?? embeds.ToArray(), + components: components, + ephemeral: ephemeral); + } + + public static Task RespondAsync( + this SocketMessageComponent smc, + SmartText text, + bool sanitizeAll = false, + bool ephemeral = true) + => text switch + { + SmartEmbedText set => smc.RespondAsync(set.PlainText, + set.IsValid ? set.GetEmbed().Build() : null, + sanitizeAll: sanitizeAll, + ephemeral: ephemeral), + SmartPlainText st => smc.RespondAsync(st.Text, + default(Embed), + sanitizeAll: sanitizeAll, + ephemeral: ephemeral), + SmartEmbedTextArray arr => smc.RespondAsync(arr.Content, + embeds: arr.GetEmbedBuilders().Map(e => e.Build()), + ephemeral: ephemeral), + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static Task EmbedAsync( + this SocketMessageComponent smc, + IEmbedBuilder? embed, + string plainText = "", + IReadOnlyCollection? embeds = null, + EllieInteraction? inter = null, + bool ephemeral = false) + => smc.RespondAsync(plainText, + embed: embed?.Build(), + embeds: embeds?.Map(x => x.Build()), + ephemeral: ephemeral); + + public static Task RespondAsync( + this SocketMessageComponent ch, + IEmbedBuilderService eb, + string text, + MsgType type, + bool ephemeral = false, + EllieInteraction? inter = null) + { + var builder = eb.Create().WithDescription(text); + + builder = (type switch + { + MsgType.Error => builder.WithErrorColor(), + MsgType.Ok => builder.WithOkColor(), + MsgType.Pending => builder.WithPendingColor(), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }); + + return ch.EmbedAsync(builder, inter: inter, ephemeral: ephemeral); + } + + // embed title and optional footer overloads + + public static Task RespondErrorAsync( + this SocketMessageComponent smc, + IEmbedBuilderService eb, + string text, + bool ephemeral = false) + => smc.RespondAsync(eb, text, MsgType.Error, ephemeral); + + public static Task RespondConfirmAsync( + this SocketMessageComponent smc, + IEmbedBuilderService eb, + string text, + bool ephemeral = false) + => smc.RespondAsync(eb, text, MsgType.Ok, ephemeral); +} diff --git a/src/Ellie.Bot.Common/_Extensions/UserExtensions.cs b/src/Ellie.Bot.Common/_Extensions/UserExtensions.cs new file mode 100644 index 0000000..01c6e7e --- /dev/null +++ b/src/Ellie.Bot.Common/_Extensions/UserExtensions.cs @@ -0,0 +1,39 @@ +using Ellie.Db.Models; + +namespace Ellie.Extensions; + +public static class UserExtensions +{ + public static async Task EmbedAsync(this IUser user, IEmbedBuilder embed, string msg = "") + { + var ch = await user.CreateDMChannelAsync(); + return await ch.EmbedAsync(embed, msg); + } + + public static async Task SendAsync(this IUser user, SmartText text, bool sanitizeAll = false) + { + var ch = await user.CreateDMChannelAsync(); + return await ch.SendAsync(text, sanitizeAll); + } + + public static async Task SendConfirmAsync(this IUser user, IEmbedBuilderService eb, string text) + => await user.SendMessageAsync("", embed: eb.Create().WithOkColor().WithDescription(text).Build()); + + public static async Task SendErrorAsync(this IUser user, IEmbedBuilderService eb, string error) + => await user.SendMessageAsync("", embed: eb.Create().WithErrorColor().WithDescription(error).Build()); + + public static async Task SendPendingAsync(this IUser user, IEmbedBuilderService eb, string message) + => await user.SendMessageAsync("", embed: eb.Create().WithPendingColor().WithDescription(message).Build()); + + // This method is used by everything that fetches the avatar from a user + public static Uri RealAvatarUrl(this IUser usr, ushort size = 256) + => usr.AvatarId is null ? new(usr.GetDefaultAvatarUrl()) : new Uri(usr.GetAvatarUrl(ImageFormat.Auto, size)); + + // This method is only used for the xp card + public static Uri RealAvatarUrl(this DiscordUser usr) + => usr.AvatarId is null + ? new(CDN.GetDefaultUserAvatarUrl(ushort.Parse(usr.Discriminator))) + : new Uri(usr.AvatarId.StartsWith("a_", StringComparison.InvariantCulture) + ? $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.gif" + : $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.png"); +}