From 6d5004bf54bfe4b08d910757dabc2d81fbf38a4f Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 12 Feb 2020 15:16:19 +0100 Subject: [PATCH] Large refactor and project restructuring --- .../Controllers/AccountController.cs | 5 +- PluralKit.API/Controllers/MemberController.cs | 12 +- .../Controllers/MessageController.cs | 6 +- PluralKit.API/Controllers/SystemController.cs | 9 +- .../PluralKit.API.csproj.DotSettings | 4 + PluralKit.API/Program.cs | 3 +- .../{ => Services}/TokenAuthService.cs | 2 + PluralKit.API/Utils/JsonModelExt.cs | 107 +++ .../{ => Utils}/RequiresSystemAttribute.cs | 0 PluralKit.Bot/Bot.cs | 14 +- PluralKit.Bot/BotMetrics.cs | 1 - PluralKit.Bot/CommandSystem/Command.cs | 2 +- PluralKit.Bot/CommandSystem/CommandGroup.cs | 2 +- PluralKit.Bot/CommandSystem/Context.cs | 5 +- PluralKit.Bot/CommandSystem/Parameters.cs | 2 +- PluralKit.Bot/Commands/Autoproxy.cs | 4 +- PluralKit.Bot/Commands/CommandTree.cs | 4 +- PluralKit.Bot/Commands/Fun.cs | 4 +- PluralKit.Bot/Commands/Help.cs | 5 +- PluralKit.Bot/Commands/ImportExport.cs | 6 +- PluralKit.Bot/Commands/Member.cs | 3 +- PluralKit.Bot/Commands/MemberAvatar.cs | 8 +- PluralKit.Bot/Commands/MemberEdit.cs | 5 +- PluralKit.Bot/Commands/MemberProxy.cs | 7 +- PluralKit.Bot/Commands/Misc.cs | 6 +- PluralKit.Bot/Commands/ServerConfig.cs | 5 +- PluralKit.Bot/Commands/Switch.cs | 19 +- PluralKit.Bot/Commands/System.cs | 10 +- PluralKit.Bot/Commands/SystemEdit.cs | 9 +- PluralKit.Bot/Commands/SystemFront.cs | 10 +- PluralKit.Bot/Commands/SystemLink.cs | 4 +- PluralKit.Bot/Commands/SystemList.cs | 4 +- PluralKit.Bot/Commands/Token.cs | 7 +- PluralKit.Bot/Errors.cs | 23 +- PluralKit.Bot/Modules.cs | 4 +- PluralKit.Bot/PKPerformanceEventListener.cs | 20 - .../PluralKit.Bot.csproj.DotSettings | 5 + PluralKit.Bot/Services/EmbedService.cs | 14 +- PluralKit.Bot/Services/LogChannelService.cs | 5 +- .../Services/PeriodicStatCollector.cs | 2 +- PluralKit.Bot/Services/ProxyService.cs | 2 +- .../Services/WebhookExecutorService.cs | 1 - PluralKit.Bot/Utils.cs | 165 ----- PluralKit.Bot/Utils/AvatarUtils.cs | 55 ++ PluralKit.Bot/{ => Utils}/ContextUtils.cs | 2 +- PluralKit.Bot/Utils/DiscordUtils.cs | 32 + PluralKit.Bot/Utils/MiscUtils.cs | 33 + PluralKit.Bot/Utils/StringUtils.cs | 52 ++ PluralKit.Core/CoreConfig.cs | 2 +- PluralKit.Core/Models.cs | 227 ------ PluralKit.Core/Models/PKMember.cs | 79 +++ PluralKit.Core/Models/PKSwitch.cs | 17 + PluralKit.Core/Models/PKSystem.cs | 27 + PluralKit.Core/Models/Privacy.cs | 21 + PluralKit.Core/Modules.cs | 1 - .../PluralKit.Core.csproj.DotSettings | 4 + .../DataFileService.cs} | 16 +- PluralKit.Core/Services/IDataStore.cs | 422 ++++++++++++ .../PostgresDataStore.cs} | 440 +----------- .../ProxyCacheService.cs} | 0 .../{ => Services}/SchemaService.cs | 3 +- PluralKit.Core/Utils.cs | 645 ------------------ PluralKit.Core/Utils/ConnectionUtils.cs | 28 + PluralKit.Core/Utils/DatabaseUtils.cs | 305 +++++++++ PluralKit.Core/Utils/DateTimeFormats.cs | 24 + PluralKit.Core/Utils/DateUtils.cs | 174 +++++ PluralKit.Core/Utils/Emojis.cs | 11 + PluralKit.Core/Utils/InitUtils.cs | 57 ++ PluralKit.Core/{ => Utils}/Limits.cs | 0 PluralKit.Core/Utils/StringUtils.cs | 57 ++ PluralKit.Core/{ => Utils}/TaskUtils.cs | 2 +- 71 files changed, 1664 insertions(+), 1607 deletions(-) create mode 100644 PluralKit.API/PluralKit.API.csproj.DotSettings rename PluralKit.API/{ => Services}/TokenAuthService.cs (97%) create mode 100644 PluralKit.API/Utils/JsonModelExt.cs rename PluralKit.API/{ => Utils}/RequiresSystemAttribute.cs (100%) delete mode 100644 PluralKit.Bot/PKPerformanceEventListener.cs create mode 100644 PluralKit.Bot/PluralKit.Bot.csproj.DotSettings delete mode 100644 PluralKit.Bot/Utils.cs create mode 100644 PluralKit.Bot/Utils/AvatarUtils.cs rename PluralKit.Bot/{ => Utils}/ContextUtils.cs (99%) create mode 100644 PluralKit.Bot/Utils/DiscordUtils.cs create mode 100644 PluralKit.Bot/Utils/MiscUtils.cs create mode 100644 PluralKit.Bot/Utils/StringUtils.cs delete mode 100644 PluralKit.Core/Models.cs create mode 100644 PluralKit.Core/Models/PKMember.cs create mode 100644 PluralKit.Core/Models/PKSwitch.cs create mode 100644 PluralKit.Core/Models/PKSystem.cs create mode 100644 PluralKit.Core/Models/Privacy.cs create mode 100644 PluralKit.Core/PluralKit.Core.csproj.DotSettings rename PluralKit.Core/{DataFiles.cs => Services/DataFileService.cs} (96%) create mode 100644 PluralKit.Core/Services/IDataStore.cs rename PluralKit.Core/{Stores.cs => Services/PostgresDataStore.cs} (64%) rename PluralKit.Core/{ProxyCache.cs => Services/ProxyCacheService.cs} (100%) rename PluralKit.Core/{ => Services}/SchemaService.cs (99%) delete mode 100644 PluralKit.Core/Utils.cs create mode 100644 PluralKit.Core/Utils/ConnectionUtils.cs create mode 100644 PluralKit.Core/Utils/DatabaseUtils.cs create mode 100644 PluralKit.Core/Utils/DateTimeFormats.cs create mode 100644 PluralKit.Core/Utils/DateUtils.cs create mode 100644 PluralKit.Core/Utils/Emojis.cs create mode 100644 PluralKit.Core/Utils/InitUtils.cs rename PluralKit.Core/{ => Utils}/Limits.cs (100%) create mode 100644 PluralKit.Core/Utils/StringUtils.cs rename PluralKit.Core/{ => Utils}/TaskUtils.cs (97%) diff --git a/PluralKit.API/Controllers/AccountController.cs b/PluralKit.API/Controllers/AccountController.cs index 6e32b431..e8757c1b 100644 --- a/PluralKit.API/Controllers/AccountController.cs +++ b/PluralKit.API/Controllers/AccountController.cs @@ -1,9 +1,12 @@ using System.Threading.Tasks; + using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; -namespace PluralKit.API.Controllers +using PluralKit.Core; + +namespace PluralKit.API { [ApiController] [Route("a")] diff --git a/PluralKit.API/Controllers/MemberController.cs b/PluralKit.API/Controllers/MemberController.cs index ef632aa4..19f21842 100644 --- a/PluralKit.API/Controllers/MemberController.cs +++ b/PluralKit.API/Controllers/MemberController.cs @@ -1,12 +1,12 @@ -using System.Linq; using System.Threading.Tasks; + using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using PluralKit.Core; -namespace PluralKit.API.Controllers +namespace PluralKit.API { [ApiController] [Route("m")] @@ -48,9 +48,9 @@ namespace PluralKit.API.Controllers var member = await _data.CreateMember(system, properties.Value("name")); try { - member.Apply(properties); + member.ApplyJson(properties); } - catch (PKParseError e) + catch (JsonModelParseError e) { return BadRequest(e.Message); } @@ -70,9 +70,9 @@ namespace PluralKit.API.Controllers try { - member.Apply(changes); + member.ApplyJson(changes); } - catch (PKParseError e) + catch (JsonModelParseError e) { return BadRequest(e.Message); } diff --git a/PluralKit.API/Controllers/MessageController.cs b/PluralKit.API/Controllers/MessageController.cs index 419e541f..49190297 100644 --- a/PluralKit.API/Controllers/MessageController.cs +++ b/PluralKit.API/Controllers/MessageController.cs @@ -1,11 +1,15 @@ using System.Threading.Tasks; + using Microsoft.AspNetCore.Mvc; + using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NodaTime; -namespace PluralKit.API.Controllers +using PluralKit.Core; + +namespace PluralKit.API { public struct MessageReturn { diff --git a/PluralKit.API/Controllers/SystemController.cs b/PluralKit.API/Controllers/SystemController.cs index bd6956f4..c3896e7f 100644 --- a/PluralKit.API/Controllers/SystemController.cs +++ b/PluralKit.API/Controllers/SystemController.cs @@ -1,17 +1,20 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using Dapper; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; + using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NodaTime; + using PluralKit.Core; -namespace PluralKit.API.Controllers +namespace PluralKit.API { public struct SwitchesReturn { @@ -130,9 +133,9 @@ namespace PluralKit.API.Controllers try { - system.Apply(changes); + system.ApplyJson(changes); } - catch (PKParseError e) + catch (JsonModelParseError e) { return BadRequest(e.Message); } diff --git a/PluralKit.API/PluralKit.API.csproj.DotSettings b/PluralKit.API/PluralKit.API.csproj.DotSettings new file mode 100644 index 00000000..3bceb3b3 --- /dev/null +++ b/PluralKit.API/PluralKit.API.csproj.DotSettings @@ -0,0 +1,4 @@ + + True + True + True \ No newline at end of file diff --git a/PluralKit.API/Program.cs b/PluralKit.API/Program.cs index 674724bc..c8c4a6cf 100644 --- a/PluralKit.API/Program.cs +++ b/PluralKit.API/Program.cs @@ -1,9 +1,10 @@ using Autofac.Extensions.DependencyInjection; -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; +using PluralKit.Core; + namespace PluralKit.API { public class Program diff --git a/PluralKit.API/TokenAuthService.cs b/PluralKit.API/Services/TokenAuthService.cs similarity index 97% rename from PluralKit.API/TokenAuthService.cs rename to PluralKit.API/Services/TokenAuthService.cs index c70cc89e..0b041013 100644 --- a/PluralKit.API/TokenAuthService.cs +++ b/PluralKit.API/Services/TokenAuthService.cs @@ -2,6 +2,8 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using PluralKit.Core; + namespace PluralKit.API { public class TokenAuthService: IMiddleware diff --git a/PluralKit.API/Utils/JsonModelExt.cs b/PluralKit.API/Utils/JsonModelExt.cs new file mode 100644 index 00000000..97a36214 --- /dev/null +++ b/PluralKit.API/Utils/JsonModelExt.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; + +using Newtonsoft.Json.Linq; + +using PluralKit.Core; + +namespace PluralKit.API +{ + public static class JsonModelExt + { + public static JObject ToJson(this PKSystem system, LookupContext ctx) + { + var o = new JObject(); + o.Add("id", system.Hid); + o.Add("name", system.Name); + o.Add("description", system.DescriptionPrivacy.CanAccess(ctx) ? system.Description : null); + o.Add("tag", system.Tag); + o.Add("avatar_url", system.AvatarUrl); + o.Add("created", DateTimeFormats.TimestampExportFormat.Format(system.Created)); + o.Add("tz", system.UiTz); + return o; + } + + public static void ApplyJson(this PKSystem system, JObject o) + { + if (o.ContainsKey("name")) system.Name = o.Value("name").NullIfEmpty().BoundsCheckField(Limits.MaxSystemNameLength, "System name"); + if (o.ContainsKey("description")) system.Description = o.Value("description").NullIfEmpty().BoundsCheckField(Limits.MaxDescriptionLength, "System description"); + if (o.ContainsKey("tag")) system.Tag = o.Value("tag").NullIfEmpty().BoundsCheckField(Limits.MaxSystemTagLength, "System tag"); + if (o.ContainsKey("avatar_url")) system.AvatarUrl = o.Value("avatar_url").NullIfEmpty(); + if (o.ContainsKey("tz")) system.UiTz = o.Value("tz") ?? "UTC"; + } + + public static JObject ToJson(this PKMember member, LookupContext ctx) + { + var o = new JObject(); + o.Add("id", member.Hid); + o.Add("name", member.Name); + o.Add("color", member.MemberPrivacy.CanAccess(ctx) ? member.Color : null); + o.Add("display_name", member.DisplayName); + o.Add("birthday", member.MemberPrivacy.CanAccess(ctx) && member.Birthday.HasValue ? DateTimeFormats.DateExportFormat.Format(member.Birthday.Value) : null); + o.Add("pronouns", member.MemberPrivacy.CanAccess(ctx) ? member.Pronouns : null); + o.Add("avatar_url", member.AvatarUrl); + o.Add("description", member.MemberPrivacy.CanAccess(ctx) ? member.Description : null); + + var tagArray = new JArray(); + foreach (var tag in member.ProxyTags) + tagArray.Add(new JObject {{"prefix", tag.Prefix}, {"suffix", tag.Suffix}}); + o.Add("proxy_tags", tagArray); + + o.Add("keep_proxy", member.KeepProxy); + o.Add("created", DateTimeFormats.TimestampExportFormat.Format(member.Created)); + + if (member.ProxyTags.Count > 0) + { + // Legacy compatibility only, TODO: remove at some point + o.Add("prefix", member.ProxyTags?.FirstOrDefault().Prefix); + o.Add("suffix", member.ProxyTags?.FirstOrDefault().Suffix); + } + + return o; + } + + public static void ApplyJson(this PKMember member, JObject o) + { + if (o.ContainsKey("name") && o["name"].Type == JTokenType.Null) + throw new JsonModelParseError("Member name can not be set to null."); + + if (o.ContainsKey("name")) member.Name = o.Value("name").BoundsCheckField(Limits.MaxMemberNameLength, "Member name"); + if (o.ContainsKey("color")) member.Color = o.Value("color").NullIfEmpty(); + if (o.ContainsKey("display_name")) member.DisplayName = o.Value("display_name").NullIfEmpty().BoundsCheckField(Limits.MaxMemberNameLength, "Member display name"); + if (o.ContainsKey("birthday")) + { + var str = o.Value("birthday").NullIfEmpty(); + var res = DateTimeFormats.DateExportFormat.Parse(str); + if (res.Success) member.Birthday = res.Value; + else if (str == null) member.Birthday = null; + else throw new JsonModelParseError("Could not parse member birthday."); + } + + if (o.ContainsKey("pronouns")) member.Pronouns = o.Value("pronouns").NullIfEmpty().BoundsCheckField(Limits.MaxPronounsLength, "Member pronouns"); + if (o.ContainsKey("description")) member.Description = o.Value("description").NullIfEmpty().BoundsCheckField(Limits.MaxDescriptionLength, "Member descriptoin"); + if (o.ContainsKey("keep_proxy")) member.KeepProxy = o.Value("keep_proxy"); + + if (o.ContainsKey("prefix") || o.ContainsKey("suffix") && !o.ContainsKey("proxy_tags")) + member.ProxyTags = new[] {new ProxyTag(o.Value("prefix"), o.Value("suffix"))}; + else if (o.ContainsKey("proxy_tags")) + { + member.ProxyTags = o.Value("proxy_tags") + .OfType().Select(o => new ProxyTag(o.Value("prefix"), o.Value("suffix"))) + .ToList(); + } + } + + private static string BoundsCheckField(this string input, int maxLength, string nameInError) + { + if (input != null && input.Length > maxLength) + throw new JsonModelParseError($"{nameInError} too long ({input.Length} > {maxLength})."); + return input; + } + } + + public class JsonModelParseError: Exception + { + public JsonModelParseError(string message): base(message) { } + } +} \ No newline at end of file diff --git a/PluralKit.API/RequiresSystemAttribute.cs b/PluralKit.API/Utils/RequiresSystemAttribute.cs similarity index 100% rename from PluralKit.API/RequiresSystemAttribute.cs rename to PluralKit.API/Utils/RequiresSystemAttribute.cs diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 9bfc3350..64bc7029 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,33 +1,23 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using App.Metrics; using Autofac; -using Autofac.Core; -using Dapper; using Discord; using Discord.WebSocket; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using PluralKit.Bot.Commands; -using PluralKit.Bot.CommandSystem; using PluralKit.Core; using Sentry; -using Sentry.Infrastructure; using Serilog; using Serilog.Events; -using SystemClock = NodaTime.SystemClock; - namespace PluralKit.Bot { class Initialize @@ -109,11 +99,9 @@ namespace PluralKit.Bot private IMetrics _metrics; private PeriodicStatCollector _collector; private ILogger _logger; - private PKPerformanceEventListener _pl; public Bot(ILifetimeScope services, IDiscordClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger) { - _pl = new PKPerformanceEventListener(); _services = services; _client = client as DiscordShardedClient; _metrics = metrics; @@ -306,7 +294,7 @@ namespace PluralKit.Bot // Check if message starts with the command prefix if (msg.Content.StartsWith("pk;", StringComparison.InvariantCultureIgnoreCase)) argPos = 3; else if (msg.Content.StartsWith("pk!", StringComparison.InvariantCultureIgnoreCase)) argPos = 3; - else if (msg.Content != null && Utils.HasMentionPrefix(msg.Content, ref argPos, out var id)) // Set argPos to the proper value + else if (msg.Content != null && StringUtils.HasMentionPrefix(msg.Content, ref argPos, out var id)) // Set argPos to the proper value if (id != _client.CurrentUser.Id) // But undo it if it's someone else's ping argPos = -1; diff --git a/PluralKit.Bot/BotMetrics.cs b/PluralKit.Bot/BotMetrics.cs index 4f0080df..04f21082 100644 --- a/PluralKit.Bot/BotMetrics.cs +++ b/PluralKit.Bot/BotMetrics.cs @@ -1,6 +1,5 @@ using App.Metrics; using App.Metrics.Gauge; -using App.Metrics.Histogram; using App.Metrics.Meter; using App.Metrics.Timer; diff --git a/PluralKit.Bot/CommandSystem/Command.cs b/PluralKit.Bot/CommandSystem/Command.cs index 42a5a2cd..30e6596d 100644 --- a/PluralKit.Bot/CommandSystem/Command.cs +++ b/PluralKit.Bot/CommandSystem/Command.cs @@ -1,4 +1,4 @@ -namespace PluralKit.Bot.CommandSystem +namespace PluralKit.Bot { public class Command { diff --git a/PluralKit.Bot/CommandSystem/CommandGroup.cs b/PluralKit.Bot/CommandSystem/CommandGroup.cs index a54ea2ad..62f3a060 100644 --- a/PluralKit.Bot/CommandSystem/CommandGroup.cs +++ b/PluralKit.Bot/CommandSystem/CommandGroup.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace PluralKit.Bot.CommandSystem +namespace PluralKit.Bot { public class CommandGroup { diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index f42b1163..671c7acb 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -4,14 +4,13 @@ using System.Threading.Tasks; using App.Metrics; using Autofac; -using Autofac.Core; using Discord; using Discord.WebSocket; -using Microsoft.Extensions.DependencyInjection; +using PluralKit.Core; -namespace PluralKit.Bot.CommandSystem +namespace PluralKit.Bot { public class Context { diff --git a/PluralKit.Bot/CommandSystem/Parameters.cs b/PluralKit.Bot/CommandSystem/Parameters.cs index d43bcc6a..328bb747 100644 --- a/PluralKit.Bot/CommandSystem/Parameters.cs +++ b/PluralKit.Bot/CommandSystem/Parameters.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace PluralKit.Bot.CommandSystem +namespace PluralKit.Bot { public class Parameters { diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs index 7de40c6a..a84d9a7c 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -4,9 +4,9 @@ using System.Threading.Tasks; using Discord; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class Autoproxy { diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 31689afe..bbd2a7b2 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -3,9 +3,9 @@ using System.Threading.Tasks; using Discord.WebSocket; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class CommandTree { diff --git a/PluralKit.Bot/Commands/Fun.cs b/PluralKit.Bot/Commands/Fun.cs index 92288d7a..6ec22bb2 100644 --- a/PluralKit.Bot/Commands/Fun.cs +++ b/PluralKit.Bot/Commands/Fun.cs @@ -1,8 +1,6 @@ using System.Threading.Tasks; -using PluralKit.Bot.CommandSystem; - -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class Fun { diff --git a/PluralKit.Bot/Commands/Help.cs b/PluralKit.Bot/Commands/Help.cs index fc45f9d5..07ea6b8a 100644 --- a/PluralKit.Bot/Commands/Help.cs +++ b/PluralKit.Bot/Commands/Help.cs @@ -1,9 +1,10 @@ using System.Threading.Tasks; + using Discord; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class Help { diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index 1cc62a3d..2a9a2d43 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -4,13 +4,15 @@ using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; + using Discord; using Discord.Net; + using Newtonsoft.Json; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class ImportExport { diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 136c8378..c90cd3c2 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -1,10 +1,9 @@ using System.Linq; using System.Threading.Tasks; -using PluralKit.Bot.CommandSystem; using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class Member { diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index c137fbda..2498cbcc 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -3,9 +3,9 @@ using System.Threading.Tasks; using Discord; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class MemberAvatar { @@ -62,7 +62,7 @@ namespace PluralKit.Bot.Commands } else if (ctx.RemainderOrNull() is string url) { - await Utils.VerifyAvatarOrThrow(url); + await AvatarUtils.VerifyAvatarOrThrow(url); target.AvatarUrl = url; await _data.SaveMember(target); @@ -71,7 +71,7 @@ namespace PluralKit.Bot.Commands } else if (ctx.Message.Attachments.FirstOrDefault() is Attachment attachment) { - await Utils.VerifyAvatarOrThrow(attachment.Url); + await AvatarUtils.VerifyAvatarOrThrow(attachment.Url); target.AvatarUrl = attachment.Url; await _data.SaveMember(target); diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 20584afd..7295f815 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -3,10 +3,9 @@ using System.Threading.Tasks; using NodaTime; -using PluralKit.Bot.CommandSystem; using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class MemberEdit { @@ -103,7 +102,7 @@ namespace PluralKit.Bot.Commands var birthday = ctx.RemainderOrNull(); if (birthday != null) { - date = PluralKit.Utils.ParseDate(birthday, true); + date = DateUtils.ParseDate(birthday, true); if (date == null) throw Errors.BirthdayParseError(birthday); } diff --git a/PluralKit.Bot/Commands/MemberProxy.cs b/PluralKit.Bot/Commands/MemberProxy.cs index 1ce8a2e3..ccebd4bf 100644 --- a/PluralKit.Bot/Commands/MemberProxy.cs +++ b/PluralKit.Bot/Commands/MemberProxy.cs @@ -1,10 +1,9 @@ -using System; -using System.Linq; +using System.Linq; using System.Threading.Tasks; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class MemberProxy { diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index 4ab5fcbe..97d77542 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; + using App.Metrics; using Discord; @@ -11,10 +12,9 @@ using Humanizer; using NodaTime; -using PluralKit.Bot.CommandSystem; using PluralKit.Core; -namespace PluralKit.Bot.Commands { +namespace PluralKit.Bot { public class Misc { private BotConfig _botConfig; @@ -79,7 +79,7 @@ namespace PluralKit.Bot.Commands { .AddField("Messages proxied", $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", true) .AddField("Commands executed", $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", true) .AddField("Current shard", $"Shard #{shardId} (of {shardTotal} total, {shardUpTotal} are up)", true) - .AddField("Shard uptime", $"{Formats.DurationFormat.Format(shardUptime)} ({shardInfo.DisconnectionCount} disconnections)", true) + .AddField("Shard uptime", $"{DateTimeFormats.DurationFormat.Format(shardUptime)} ({shardInfo.DisconnectionCount} disconnections)", true) .AddField("CPU usage", $"{_cpu.LastCpuMeasure:P1}", true) .AddField("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true) .AddField("Latency", $"API: {(msg.Timestamp - ctx.Message.Timestamp).TotalMilliseconds:F0} ms, shard: {shardInfo.ShardLatency} ms", true) diff --git a/PluralKit.Bot/Commands/ServerConfig.cs b/PluralKit.Bot/Commands/ServerConfig.cs index aee16033..63ad8393 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -1,11 +1,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using Discord; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class ServerConfig { diff --git a/PluralKit.Bot/Commands/Switch.cs b/PluralKit.Bot/Commands/Switch.cs index 384abc05..be5a6675 100644 --- a/PluralKit.Bot/Commands/Switch.cs +++ b/PluralKit.Bot/Commands/Switch.cs @@ -1,14 +1,15 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using Discord; using NodaTime; using NodaTime.TimeZones; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class Switch { @@ -79,7 +80,7 @@ namespace PluralKit.Bot.Commands var timeToMove = ctx.RemainderOrNull() ?? throw new PKSyntaxError("Must pass a date or time to move the switch to."); var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.System.UiTz ?? "UTC"); - var result = PluralKit.Utils.ParseDateTime(timeToMove, true, tz); + var result = DateUtils.ParseDateTime(timeToMove, true, tz); if (result == null) throw Errors.InvalidDateTime(timeToMove); var time = result.Value; @@ -102,10 +103,10 @@ namespace PluralKit.Bot.Commands // But, we do a prompt to confirm. var lastSwitchMembers = _data.GetSwitchMembers(lastTwoSwitches[0]); var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.Name).ToListAsync()); - var lastSwitchTimeStr = Formats.ZonedDateTimeFormat.Format(lastTwoSwitches[0].Timestamp.InZone(ctx.System.Zone)); - var lastSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp); - var newSwitchTimeStr = Formats.ZonedDateTimeFormat.Format(time); - var newSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - time.ToInstant()); + var lastSwitchTimeStr = DateTimeFormats.ZonedDateTimeFormat.Format(lastTwoSwitches[0].Timestamp.InZone(ctx.System.Zone)); + var lastSwitchDeltaStr = DateTimeFormats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp); + var newSwitchTimeStr = DateTimeFormats.ZonedDateTimeFormat.Format(time); + var newSwitchDeltaStr = DateTimeFormats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - time.ToInstant()); // yeet var msg = await ctx.Reply($"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr.SanitizeMentions()}) from {lastSwitchTimeStr} ({lastSwitchDeltaStr} ago) to {newSwitchTimeStr} ({newSwitchDeltaStr} ago). Is this OK?"); @@ -137,7 +138,7 @@ namespace PluralKit.Bot.Commands var lastSwitchMembers = _data.GetSwitchMembers(lastTwoSwitches[0]); var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.Name).ToListAsync()); - var lastSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp); + var lastSwitchDeltaStr = DateTimeFormats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp); IUserMessage msg; if (lastTwoSwitches.Count == 1) @@ -149,7 +150,7 @@ namespace PluralKit.Bot.Commands { var secondSwitchMembers = _data.GetSwitchMembers(lastTwoSwitches[1]); var secondSwitchMemberStr = string.Join(", ", await secondSwitchMembers.Select(m => m.Name).ToListAsync()); - var secondSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp); + var secondSwitchDeltaStr = DateTimeFormats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp); msg = await ctx.Reply( $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr.SanitizeMentions()}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr.SanitizeMentions()} ({secondSwitchDeltaStr} ago). Is this okay?"); } diff --git a/PluralKit.Bot/Commands/System.cs b/PluralKit.Bot/Commands/System.cs index 6d478300..3094c3b6 100644 --- a/PluralKit.Bot/Commands/System.cs +++ b/PluralKit.Bot/Commands/System.cs @@ -1,16 +1,8 @@ -using System; -using System.Linq; using System.Threading.Tasks; -using Discord; -using Humanizer; -using NodaTime; -using NodaTime.Text; -using NodaTime.TimeZones; -using PluralKit.Bot.CommandSystem; using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class System { diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index 1a5d2a38..6c1cdabe 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -8,10 +8,9 @@ using NodaTime; using NodaTime.Text; using NodaTime.TimeZones; -using PluralKit.Bot.CommandSystem; using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class SystemEdit { @@ -103,7 +102,7 @@ namespace PluralKit.Bot.Commands { // They can't both be null - otherwise we would've hit the conditional at the very top string url = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.ProxyUrl; - await ctx.BusyIndicator(() => Utils.VerifyAvatarOrThrow(url)); + await ctx.BusyIndicator(() => AvatarUtils.VerifyAvatarOrThrow(url)); ctx.System.AvatarUrl = url; await _data.SaveSystem(ctx.System); @@ -162,7 +161,7 @@ namespace PluralKit.Bot.Commands var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone); var msg = await ctx.Reply( - $"This will change the system time zone to {zone.Id}. The current time is {Formats.ZonedDateTimeFormat.Format(currentTime)}. Is this correct?"); + $"This will change the system time zone to {zone.Id}. The current time is {DateTimeFormats.ZonedDateTimeFormat.Format(currentTime)}. Is this correct?"); if (!await ctx.PromptYesNo(msg)) throw Errors.TimezoneChangeCancelled; ctx.System.UiTz = zone.Id; await _data.SaveSystem(ctx.System); @@ -246,7 +245,7 @@ namespace PluralKit.Bot.Commands public async Task FindTimeZone(Context ctx, string zoneStr) { // First, if we're given a flag emoji, we extract the flag emoji code from it. - zoneStr = PluralKit.Utils.ExtractCountryFlag(zoneStr) ?? zoneStr; + zoneStr = Core.StringUtils.ExtractCountryFlag(zoneStr) ?? zoneStr; // Then, we find all *locations* matching either the given country code or the country name. var locations = TzdbDateTimeZoneSource.Default.Zone1970Locations; diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index 06969543..726987d9 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -5,9 +5,9 @@ using Discord; using NodaTime; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class SystemFront { @@ -81,12 +81,12 @@ namespace PluralKit.Bot.Commands // Calculate the time between the last switch (that we iterated - ie. the next one on the timeline) and the current one var switchDuration = lastSw.Value - sw.Timestamp; stringToAdd = - $"**{membersStr}** ({Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(system.Zone))}, {Formats.DurationFormat.Format(switchSince)} ago, for {Formats.DurationFormat.Format(switchDuration)})\n"; + $"**{membersStr}** ({DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(system.Zone))}, {DateTimeFormats.DurationFormat.Format(switchSince)} ago, for {DateTimeFormats.DurationFormat.Format(switchDuration)})\n"; } else { stringToAdd = - $"**{membersStr}** ({Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(system.Zone))}, {Formats.DurationFormat.Format(switchSince)} ago)\n"; + $"**{membersStr}** ({DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(system.Zone))}, {DateTimeFormats.DurationFormat.Format(switchSince)} ago)\n"; } if (outputStr.Length + stringToAdd.Length > EmbedBuilder.MaxDescriptionLength) break; @@ -107,7 +107,7 @@ namespace PluralKit.Bot.Commands var now = SystemClock.Instance.GetCurrentInstant(); - var rangeStart = PluralKit.Utils.ParseDateTime(durationStr, true, system.Zone); + var rangeStart = DateUtils.ParseDateTime(durationStr, true, system.Zone); if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; diff --git a/PluralKit.Bot/Commands/SystemLink.cs b/PluralKit.Bot/Commands/SystemLink.cs index d1fdc693..72be74ca 100644 --- a/PluralKit.Bot/Commands/SystemLink.cs +++ b/PluralKit.Bot/Commands/SystemLink.cs @@ -1,9 +1,9 @@ using System.Linq; using System.Threading.Tasks; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class SystemLink { diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs index fe746a8d..60c6422f 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -4,9 +4,9 @@ using System.Threading.Tasks; using Humanizer; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class SystemList { diff --git a/PluralKit.Bot/Commands/Token.cs b/PluralKit.Bot/Commands/Token.cs index e8c6fb1c..dbdfa057 100644 --- a/PluralKit.Bot/Commands/Token.cs +++ b/PluralKit.Bot/Commands/Token.cs @@ -1,9 +1,10 @@ using System.Threading.Tasks; + using Discord; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; -namespace PluralKit.Bot.Commands +namespace PluralKit.Bot { public class Token { @@ -33,7 +34,7 @@ namespace PluralKit.Bot.Commands private async Task MakeAndSetNewToken(PKSystem system) { - system.Token = PluralKit.Utils.GenerateToken(); + system.Token = Core.StringUtils.GenerateToken(); await _data.SaveSystem(system); return system.Token; } diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 40ea85a4..7c91929b 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -7,6 +7,27 @@ using NodaTime; using PluralKit.Core; namespace PluralKit.Bot { + /// + /// An exception class representing user-facing errors caused when parsing and executing commands. + /// + public class PKError : Exception + { + public PKError(string message) : base(message) + { + } + } + + /// + /// A subclass of that represent command syntax errors, meaning they'll have their command + /// usages printed in the message. + /// + public class PKSyntaxError : PKError + { + public PKSyntaxError(string message) : base(message) + { + } + } + public static class Errors { // TODO: is returning constructed errors and throwing them at call site a good idea, or should these be methods that insta-throw instead? // or should we just like... go back to inlining them? at least for the one-time-use commands @@ -60,7 +81,7 @@ namespace PluralKit.Bot { public static PKError SwitchTimeInFuture => new PKError("Can't move switch to a time in the future."); public static PKError NoRegisteredSwitches => new PKError("There are no registered switches for this system."); - public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({Formats.ZonedDateTimeFormat.Format(time)}), as it would cause conflicts."); + public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({DateTimeFormats.ZonedDateTimeFormat.Format(time)}), as it would cause conflicts."); public static PKError SwitchMoveCancelled => new PKError("Switch move cancelled."); public static PKError SwitchDeleteCancelled => new PKError("Switch deletion cancelled."); public static PKError TimezoneParseError(string timezone) => new PKError($"Could not parse timezone offset {timezone.SanitizeMentions()}. Offset must be a value like 'UTC+5' or 'GMT-4:30'."); diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 171aa814..2ded51b0 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -7,7 +7,7 @@ using Discord; using Discord.Rest; using Discord.WebSocket; -using PluralKit.Bot.Commands; +using PluralKit.Core; using Sentry; @@ -44,7 +44,7 @@ namespace PluralKit.Bot builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); - builder.RegisterType().AsSelf(); + builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); diff --git a/PluralKit.Bot/PKPerformanceEventListener.cs b/PluralKit.Bot/PKPerformanceEventListener.cs deleted file mode 100644 index 430c10e4..00000000 --- a/PluralKit.Bot/PKPerformanceEventListener.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Diagnostics.Tracing; -using System.Linq; - -namespace PluralKit.Bot { - class PKPerformanceEventListener: EventListener - { - public PKPerformanceEventListener() - { - foreach (var s in EventSource.GetSources()) - EnableEvents(s, EventLevel.Informational); - } - - protected override void OnEventWritten(EventWrittenEventArgs eventData) - { - base.OnEventWritten(eventData); - // Console.WriteLine($"{eventData.EventSource.Name}/{eventData.EventName}: {string.Join(", ", eventData.PayloadNames.Zip(eventData.Payload).Select(v => $"{v.First}={v.Second}" ))}"); - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/PluralKit.Bot.csproj.DotSettings b/PluralKit.Bot/PluralKit.Bot.csproj.DotSettings new file mode 100644 index 00000000..c58bbdfe --- /dev/null +++ b/PluralKit.Bot/PluralKit.Bot.csproj.DotSettings @@ -0,0 +1,5 @@ + + True + True + True + True \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 519be0f9..383b6823 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -8,6 +8,8 @@ using Discord.WebSocket; using Humanizer; using NodaTime; +using PluralKit.Core; + namespace PluralKit.Bot { public class EmbedService { @@ -31,7 +33,7 @@ namespace PluralKit.Bot { .WithColor(Color.Blue) .WithTitle(system.Name ?? null) .WithThumbnailUrl(system.AvatarUrl ?? null) - .WithFooter($"System ID: {system.Hid} | Created on {Formats.ZonedDateTimeFormat.Format(system.Created.InZone(system.Zone))}"); + .WithFooter($"System ID: {system.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(system.Created.InZone(system.Zone))}"); var latestSwitch = await _data.GetLatestSwitch(system); if (latestSwitch != null && system.FrontPrivacy.CanAccess(ctx)) @@ -100,7 +102,7 @@ namespace PluralKit.Bot { // TODO: add URL of website when that's up .WithAuthor(name, member.AvatarUrl) .WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : Color.Default) - .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Created on {Formats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}"); + .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}"); if (member.MemberPrivacy == PrivacyLevel.Private) eb.WithDescription("*(this member is private)*"); @@ -125,7 +127,7 @@ namespace PluralKit.Bot { return new EmbedBuilder() .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? Color.Blue) .AddField($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*") - .AddField("Since", $"{Formats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({Formats.DurationFormat.Format(timeSinceSwitch)} ago)") + .AddField("Since", $"{DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({DateTimeFormats.DurationFormat.Format(timeSinceSwitch)} ago)") .Build(); } @@ -179,7 +181,7 @@ namespace PluralKit.Bot { var actualPeriod = breakdown.RangeEnd - breakdown.RangeStart; var eb = new EmbedBuilder() .WithColor(Color.Blue) - .WithFooter($"Since {Formats.ZonedDateTimeFormat.Format(breakdown.RangeStart.InZone(tz))} ({Formats.DurationFormat.Format(actualPeriod)} ago)"); + .WithFooter($"Since {DateTimeFormats.ZonedDateTimeFormat.Format(breakdown.RangeStart.InZone(tz))} ({DateTimeFormats.DurationFormat.Format(actualPeriod)} ago)"); var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others" @@ -193,13 +195,13 @@ namespace PluralKit.Bot { foreach (var pair in membersOrdered) { var frac = pair.Value / actualPeriod; - eb.AddField(pair.Key?.Name ?? "*(no fronter)*", $"{frac*100:F0}% ({Formats.DurationFormat.Format(pair.Value)})"); + eb.AddField(pair.Key?.Name ?? "*(no fronter)*", $"{frac*100:F0}% ({DateTimeFormats.DurationFormat.Format(pair.Value)})"); } if (membersOrdered.Count > maxEntriesToDisplay) { eb.AddField("(others)", - Formats.DurationFormat.Format(membersOrdered.Skip(maxEntriesToDisplay) + DateTimeFormats.DurationFormat.Format(membersOrdered.Skip(maxEntriesToDisplay) .Aggregate(Duration.Zero, (prod, next) => prod + next.Value)), true); } diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index ded26440..3e2168ab 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -1,6 +1,9 @@ using System.Threading.Tasks; -using Dapper; + using Discord; + +using PluralKit.Core; + using Serilog; namespace PluralKit.Bot { diff --git a/PluralKit.Bot/Services/PeriodicStatCollector.cs b/PluralKit.Bot/Services/PeriodicStatCollector.cs index a34e4494..ba373be9 100644 --- a/PluralKit.Bot/Services/PeriodicStatCollector.cs +++ b/PluralKit.Bot/Services/PeriodicStatCollector.cs @@ -1,13 +1,13 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Threading; using System.Threading.Tasks; using App.Metrics; using Discord; using Discord.WebSocket; using NodaTime.Extensions; using PluralKit.Core; + using Serilog; namespace PluralKit.Bot diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index 73684578..4e73772e 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -48,7 +48,7 @@ namespace PluralKit.Bot // eg. @Ske [text] => [@Ske text] int matchStartPosition = 0; string leadingMention = null; - if (Utils.HasMentionPrefix(message, ref matchStartPosition, out _)) + if (StringUtils.HasMentionPrefix(message, ref matchStartPosition, out _)) { leadingMention = message.Substring(0, matchStartPosition); message = message.Substring(matchStartPosition); diff --git a/PluralKit.Bot/Services/WebhookExecutorService.cs b/PluralKit.Bot/Services/WebhookExecutorService.cs index fc838c2d..21f0eaaf 100644 --- a/PluralKit.Bot/Services/WebhookExecutorService.cs +++ b/PluralKit.Bot/Services/WebhookExecutorService.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.IO; using System.Linq; diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs deleted file mode 100644 index 64659ff5..00000000 --- a/PluralKit.Bot/Utils.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using System.Net.Http; -using System.Net.Sockets; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Discord; -using Discord.Net; - -using PluralKit.Core; -using Image = SixLabors.ImageSharp.Image; - -namespace PluralKit.Bot -{ - public static class Utils { - public static string NameAndMention(this IUser user) { - return $"{user.Username}#{user.Discriminator} ({user.Mention})"; - } - - public static Color? ToDiscordColor(this string color) - { - if (uint.TryParse(color, NumberStyles.HexNumber, null, out var colorInt)) - return new Color(colorInt); - throw new ArgumentException($"Invalid color string '{color}'."); - } - - public static async Task VerifyAvatarOrThrow(string url) - { - // List of MIME types we consider acceptable - var acceptableMimeTypes = new[] - { - "image/jpeg", - "image/gif", - "image/png" - // TODO: add image/webp once ImageSharp supports this - }; - - using (var client = new HttpClient()) - { - Uri uri; - try - { - uri = new Uri(url); - if (!uri.IsAbsoluteUri) throw Errors.InvalidUrl(url); - } - catch (UriFormatException) - { - throw Errors.InvalidUrl(url); - } - - var response = await client.GetAsync(uri); - if (!response.IsSuccessStatusCode) // Check status code - throw Errors.AvatarServerError(response.StatusCode); - if (response.Content.Headers.ContentLength == null) // Check presence of content length - throw Errors.AvatarNotAnImage(null); - if (response.Content.Headers.ContentLength > Limits.AvatarFileSizeLimit) // Check content length - throw Errors.AvatarFileSizeLimit(response.Content.Headers.ContentLength.Value); - if (!acceptableMimeTypes.Contains(response.Content.Headers.ContentType.MediaType)) // Check MIME type - throw Errors.AvatarNotAnImage(response.Content.Headers.ContentType.MediaType); - - // Parse the image header in a worker - var stream = await response.Content.ReadAsStreamAsync(); - var image = await Task.Run(() => Image.Identify(stream)); - if (image == null) throw Errors.AvatarInvalid; - if (image.Width > Limits.AvatarDimensionLimit || image.Height > Limits.AvatarDimensionLimit) // Check image size - throw Errors.AvatarDimensionsTooLarge(image.Width, image.Height); - } - } - - public static bool HasMentionPrefix(string content, ref int argPos, out ulong mentionId) - { - mentionId = 0; - - // Roughly ported from Discord.Commands.MessageExtensions.HasMentionPrefix - if (string.IsNullOrEmpty(content) || content.Length <= 3 || (content[0] != '<' || content[1] != '@')) - return false; - int num = content.IndexOf('>'); - if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' || !MentionUtils.TryParseUser(content.Substring(0, num + 1), out mentionId)) - return false; - argPos = num + 2; - return true; - } - - public static bool TryParseMention(this string potentialMention, out ulong id) - { - if (ulong.TryParse(potentialMention, out id)) return true; - if (MentionUtils.TryParseUser(potentialMention, out id)) return true; - return false; - } - - public static string SanitizeMentions(this string input) => - Regex.Replace(Regex.Replace(input, "<@[!&]?(\\d{17,19})>", "<\u200B@$1>"), "@(everyone|here)", "@\u200B$1"); - - public static string SanitizeEveryone(this string input) => - Regex.Replace(input, "@(everyone|here)", "@\u200B$1"); - - public static string EscapeMarkdown(this string input) - { - Regex pattern = new Regex(@"[*_~>`(||)\\]", RegexOptions.Multiline); - if (input != null) return pattern.Replace(input, @"\$&"); - else return input; - } - - public static string ProxyTagsString(this PKMember member) => string.Join(", ", member.ProxyTags.Select(t => $"`{t.ProxyString.EscapeMarkdown()}`")); - - public static async Task PermissionsIn(this IChannel channel) - { - switch (channel) - { - case IDMChannel _: - return ChannelPermissions.DM; - case IGroupChannel _: - return ChannelPermissions.Group; - case IGuildChannel gc: - var currentUser = await gc.Guild.GetCurrentUserAsync(); - return currentUser.GetPermissions(gc); - default: - return ChannelPermissions.None; - } - } - - public static async Task HasPermission(this IChannel channel, ChannelPermission permission) => - (await PermissionsIn(channel)).Has(permission); - - public static bool IsOurProblem(this Exception e) - { - // This function filters out sporadic errors out of our control from being reported to Sentry - // otherwise we'd blow out our error reporting budget as soon as Discord takes a dump, or something. - - // Discord server errors are *not our problem* - if (e is HttpException he && ((int) he.HttpCode) >= 500) return false; - - // Socket errors are *not our problem* - if (e is SocketException) return false; - - // Tasks being cancelled for whatver reason are, you guessed it, also not our problem. - if (e is TaskCanceledException) return false; - - // This may expanded at some point. - return true; - } - } - - /// - /// An exception class representing user-facing errors caused when parsing and executing commands. - /// - public class PKError : Exception - { - public PKError(string message) : base(message) - { - } - } - - /// - /// A subclass of that represent command syntax errors, meaning they'll have their command - /// usages printed in the message. - /// - public class PKSyntaxError : PKError - { - public PKSyntaxError(string message) : base(message) - { - } - } -} \ No newline at end of file diff --git a/PluralKit.Bot/Utils/AvatarUtils.cs b/PluralKit.Bot/Utils/AvatarUtils.cs new file mode 100644 index 00000000..12569c02 --- /dev/null +++ b/PluralKit.Bot/Utils/AvatarUtils.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +using PluralKit.Core; + +using SixLabors.ImageSharp; + +namespace PluralKit.Bot { + public static class AvatarUtils { + public static async Task VerifyAvatarOrThrow(string url) + { + // List of MIME types we consider acceptable + var acceptableMimeTypes = new[] + { + "image/jpeg", + "image/gif", + "image/png" + // TODO: add image/webp once ImageSharp supports this + }; + + using (var client = new HttpClient()) + { + Uri uri; + try + { + uri = new Uri(url); + if (!uri.IsAbsoluteUri) throw Errors.InvalidUrl(url); + } + catch (UriFormatException) + { + throw Errors.InvalidUrl(url); + } + + var response = await client.GetAsync(uri); + if (!response.IsSuccessStatusCode) // Check status code + throw Errors.AvatarServerError(response.StatusCode); + if (response.Content.Headers.ContentLength == null) // Check presence of content length + throw Errors.AvatarNotAnImage(null); + if (response.Content.Headers.ContentLength > Limits.AvatarFileSizeLimit) // Check content length + throw Errors.AvatarFileSizeLimit(response.Content.Headers.ContentLength.Value); + if (!acceptableMimeTypes.Contains(response.Content.Headers.ContentType.MediaType)) // Check MIME type + throw Errors.AvatarNotAnImage(response.Content.Headers.ContentType.MediaType); + + // Parse the image header in a worker + var stream = await response.Content.ReadAsStreamAsync(); + var image = await Task.Run(() => Image.Identify(stream)); + if (image == null) throw Errors.AvatarInvalid; + if (image.Width > Limits.AvatarDimensionLimit || image.Height > Limits.AvatarDimensionLimit) // Check image size + throw Errors.AvatarDimensionsTooLarge(image.Width, image.Height); + } + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/ContextUtils.cs b/PluralKit.Bot/Utils/ContextUtils.cs similarity index 99% rename from PluralKit.Bot/ContextUtils.cs rename to PluralKit.Bot/Utils/ContextUtils.cs index 72ce3931..f104a645 100644 --- a/PluralKit.Bot/ContextUtils.cs +++ b/PluralKit.Bot/Utils/ContextUtils.cs @@ -7,7 +7,7 @@ using Discord; using Discord.Net; using Discord.WebSocket; -using PluralKit.Bot.CommandSystem; +using PluralKit.Core; namespace PluralKit.Bot { public static class ContextUtils { diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs new file mode 100644 index 00000000..8884ccc9 --- /dev/null +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; + +using Discord; + +namespace PluralKit.Bot +{ + public static class DiscordUtils + { + public static string NameAndMention(this IUser user) { + return $"{user.Username}#{user.Discriminator} ({user.Mention})"; + } + + public static async Task PermissionsIn(this IChannel channel) + { + switch (channel) + { + case IDMChannel _: + return ChannelPermissions.DM; + case IGroupChannel _: + return ChannelPermissions.Group; + case IGuildChannel gc: + var currentUser = await gc.Guild.GetCurrentUserAsync(); + return currentUser.GetPermissions(gc); + default: + return ChannelPermissions.None; + } + } + + public static async Task HasPermission(this IChannel channel, ChannelPermission permission) => + (await PermissionsIn(channel)).Has(permission); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Utils/MiscUtils.cs b/PluralKit.Bot/Utils/MiscUtils.cs new file mode 100644 index 00000000..7bc4c61e --- /dev/null +++ b/PluralKit.Bot/Utils/MiscUtils.cs @@ -0,0 +1,33 @@ +using System; +using System.Linq; +using System.Net.Sockets; +using System.Threading.Tasks; + +using Discord.Net; + +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public static class MiscUtils { + public static string ProxyTagsString(this PKMember member) => string.Join(", ", member.ProxyTags.Select(t => $"`{t.ProxyString.EscapeMarkdown()}`")); + + public static bool IsOurProblem(this Exception e) + { + // This function filters out sporadic errors out of our control from being reported to Sentry + // otherwise we'd blow out our error reporting budget as soon as Discord takes a dump, or something. + + // Discord server errors are *not our problem* + if (e is HttpException he && ((int) he.HttpCode) >= 500) return false; + + // Socket errors are *not our problem* + if (e is SocketException) return false; + + // Tasks being cancelled for whatver reason are, you guessed it, also not our problem. + if (e is TaskCanceledException) return false; + + // This may expanded at some point. + return true; + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Utils/StringUtils.cs b/PluralKit.Bot/Utils/StringUtils.cs new file mode 100644 index 00000000..f0ede963 --- /dev/null +++ b/PluralKit.Bot/Utils/StringUtils.cs @@ -0,0 +1,52 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +using Discord; + +namespace PluralKit.Bot +{ + public static class StringUtils + { + public static Color? ToDiscordColor(this string color) + { + if (uint.TryParse(color, NumberStyles.HexNumber, null, out var colorInt)) + return new Color(colorInt); + throw new ArgumentException($"Invalid color string '{color}'."); + } + + public static bool HasMentionPrefix(string content, ref int argPos, out ulong mentionId) + { + mentionId = 0; + + // Roughly ported from Discord.Commands.MessageExtensions.HasMentionPrefix + if (string.IsNullOrEmpty(content) || content.Length <= 3 || (content[0] != '<' || content[1] != '@')) + return false; + int num = content.IndexOf('>'); + if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' || !MentionUtils.TryParseUser(content.Substring(0, num + 1), out mentionId)) + return false; + argPos = num + 2; + return true; + } + + public static bool TryParseMention(this string potentialMention, out ulong id) + { + if (ulong.TryParse(potentialMention, out id)) return true; + if (MentionUtils.TryParseUser(potentialMention, out id)) return true; + return false; + } + + public static string SanitizeMentions(this string input) => + Regex.Replace(Regex.Replace(input, "<@[!&]?(\\d{17,19})>", "<\u200B@$1>"), "@(everyone|here)", "@\u200B$1"); + + public static string SanitizeEveryone(this string input) => + Regex.Replace(input, "@(everyone|here)", "@\u200B$1"); + + public static string EscapeMarkdown(this string input) + { + Regex pattern = new Regex(@"[*_~>`(||)\\]", RegexOptions.Multiline); + if (input != null) return pattern.Replace(input, @"\$&"); + else return input; + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/CoreConfig.cs b/PluralKit.Core/CoreConfig.cs index 1ffd3205..c33d1500 100644 --- a/PluralKit.Core/CoreConfig.cs +++ b/PluralKit.Core/CoreConfig.cs @@ -1,4 +1,4 @@ -namespace PluralKit +namespace PluralKit.Core { public class CoreConfig { diff --git a/PluralKit.Core/Models.cs b/PluralKit.Core/Models.cs deleted file mode 100644 index 14cfd121..00000000 --- a/PluralKit.Core/Models.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -using Dapper.Contrib.Extensions; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -using NodaTime; -using NodaTime.Text; - -using PluralKit.Core; - -namespace PluralKit -{ - public class PKParseError: Exception - { - public PKParseError(string message): base(message) { } - } - - public enum PrivacyLevel - { - Public = 1, - Private = 2 - } - - public static class PrivacyExt - { - public static bool CanAccess(this PrivacyLevel level, LookupContext ctx) => - level == PrivacyLevel.Public || ctx == LookupContext.ByOwner; - } - - public enum LookupContext - { - ByOwner, - ByNonOwner, - API - } - - public struct ProxyTag - { - public ProxyTag(string prefix, string suffix) - { - // Normalize empty strings to null for DB - Prefix = prefix?.Length == 0 ? null : prefix; - Suffix = suffix?.Length == 0 ? null : suffix; - } - - [JsonProperty("prefix")] public string Prefix { get; set; } - [JsonProperty("suffix")] public string Suffix { get; set; } - - [JsonIgnore] public string ProxyString => $"{Prefix ?? ""}text{Suffix ?? ""}"; - - public bool IsEmpty => Prefix == null && Suffix == null; - - public bool Equals(ProxyTag other) => Prefix == other.Prefix && Suffix == other.Suffix; - - public override bool Equals(object obj) => obj is ProxyTag other && Equals(other); - - public override int GetHashCode() - { - unchecked - { - return ((Prefix != null ? Prefix.GetHashCode() : 0) * 397) ^ - (Suffix != null ? Suffix.GetHashCode() : 0); - } - } - } - - public class PKSystem - { - // Additions here should be mirrored in SystemStore::Save - [Key] [JsonIgnore] public int Id { get; set; } - [JsonProperty("id")] public string Hid { get; set; } - [JsonProperty("name")] public string Name { get; set; } - [JsonProperty("description")] public string Description { get; set; } - [JsonProperty("tag")] public string Tag { get; set; } - [JsonProperty("avatar_url")] public string AvatarUrl { get; set; } - [JsonIgnore] public string Token { get; set; } - [JsonProperty("created")] public Instant Created { get; set; } - [JsonProperty("tz")] public string UiTz { get; set; } - public PrivacyLevel DescriptionPrivacy { get; set; } - public PrivacyLevel MemberListPrivacy { get; set; } - public PrivacyLevel FrontPrivacy { get; set; } - public PrivacyLevel FrontHistoryPrivacy { get; set; } - - [JsonIgnore] public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz); - - public JObject ToJson(LookupContext ctx) - { - var o = new JObject(); - o.Add("id", Hid); - o.Add("name", Name); - o.Add("description", DescriptionPrivacy.CanAccess(ctx) ? Description : null); - o.Add("tag", Tag); - o.Add("avatar_url", AvatarUrl); - o.Add("created", Formats.TimestampExportFormat.Format(Created)); - o.Add("tz", UiTz); - return o; - } - - public void Apply(JObject o) - { - if (o.ContainsKey("name")) Name = o.Value("name").NullIfEmpty().BoundsCheck(Limits.MaxSystemNameLength, "System name"); - if (o.ContainsKey("description")) Description = o.Value("description").NullIfEmpty().BoundsCheck(Limits.MaxDescriptionLength, "System description"); - if (o.ContainsKey("tag")) Tag = o.Value("tag").NullIfEmpty().BoundsCheck(Limits.MaxSystemTagLength, "System tag"); - if (o.ContainsKey("avatar_url")) AvatarUrl = o.Value("avatar_url").NullIfEmpty(); - if (o.ContainsKey("tz")) UiTz = o.Value("tz") ?? "UTC"; - } - } - - public class PKMember - { - // Additions here should be mirrored in MemberStore::Save - [JsonIgnore] public int Id { get; set; } - [JsonProperty("id")] public string Hid { get; set; } - [JsonIgnore] public int System { get; set; } - [JsonProperty("color")] public string Color { get; set; } - [JsonProperty("avatar_url")] public string AvatarUrl { get; set; } - [JsonProperty("name")] public string Name { get; set; } - [JsonProperty("display_name")] public string DisplayName { get; set; } - [JsonProperty("birthday")] public LocalDate? Birthday { get; set; } - [JsonProperty("pronouns")] public string Pronouns { get; set; } - [JsonProperty("description")] public string Description { get; set; } - [JsonProperty("proxy_tags")] public ICollection ProxyTags { get; set; } - [JsonProperty("keep_proxy")] public bool KeepProxy { get; set; } - [JsonProperty("created")] public Instant Created { get; set; } - - public PrivacyLevel MemberPrivacy { get; set; } - - /// Returns a formatted string representing the member's birthday, taking into account that a year of "0001" or "0004" is hidden - /// Before Feb 10 2020, the sentinel year was 0001, now it is 0004. - [JsonIgnore] public string BirthdayString - { - get - { - if (Birthday == null) return null; - - var format = LocalDatePattern.CreateWithInvariantCulture("MMM dd, yyyy"); - if (Birthday?.Year == 1 || Birthday?.Year == 4) format = LocalDatePattern.CreateWithInvariantCulture("MMM dd"); - return format.Format(Birthday.Value); - } - } - - [JsonIgnore] public bool HasProxyTags => ProxyTags.Count > 0; - public string ProxyName(string systemTag, string guildDisplayName) - { - if (systemTag == null) return guildDisplayName ?? DisplayName ?? Name; - return $"{guildDisplayName ?? DisplayName ?? Name} {systemTag}"; - } - - public JObject ToJson(LookupContext ctx) - { - var o = new JObject(); - o.Add("id", Hid); - o.Add("name", Name); - o.Add("color", MemberPrivacy.CanAccess(ctx) ? Color : null); - o.Add("display_name", DisplayName); - o.Add("birthday", MemberPrivacy.CanAccess(ctx) && Birthday.HasValue ? Formats.DateExportFormat.Format(Birthday.Value) : null); - o.Add("pronouns", MemberPrivacy.CanAccess(ctx) ? Pronouns : null); - o.Add("avatar_url", AvatarUrl); - o.Add("description", MemberPrivacy.CanAccess(ctx) ? Description : null); - - var tagArray = new JArray(); - foreach (var tag in ProxyTags) - tagArray.Add(new JObject {{"prefix", tag.Prefix}, {"suffix", tag.Suffix}}); - o.Add("proxy_tags", tagArray); - - o.Add("keep_proxy", KeepProxy); - o.Add("created", Formats.TimestampExportFormat.Format(Created)); - - if (ProxyTags.Count > 0) - { - // Legacy compatibility only, TODO: remove at some point - o.Add("prefix", ProxyTags?.FirstOrDefault().Prefix); - o.Add("suffix", ProxyTags?.FirstOrDefault().Suffix); - } - - return o; - } - - public void Apply(JObject o) - { - if (o.ContainsKey("name") && o["name"].Type == JTokenType.Null) - throw new PKParseError("Member name can not be set to null."); - - if (o.ContainsKey("name")) Name = o.Value("name").BoundsCheck(Limits.MaxMemberNameLength, "Member name"); - if (o.ContainsKey("color")) Color = o.Value("color").NullIfEmpty(); - if (o.ContainsKey("display_name")) DisplayName = o.Value("display_name").NullIfEmpty().BoundsCheck(Limits.MaxMemberNameLength, "Member display name"); - if (o.ContainsKey("birthday")) - { - var str = o.Value("birthday").NullIfEmpty(); - var res = Formats.DateExportFormat.Parse(str); - if (res.Success) Birthday = res.Value; - else if (str == null) Birthday = null; - else throw new PKParseError("Could not parse member birthday."); - } - - if (o.ContainsKey("pronouns")) Pronouns = o.Value("pronouns").NullIfEmpty().BoundsCheck(Limits.MaxPronounsLength, "Member pronouns"); - if (o.ContainsKey("description")) Description = o.Value("description").NullIfEmpty().BoundsCheck(Limits.MaxDescriptionLength, "Member descriptoin"); - if (o.ContainsKey("keep_proxy")) KeepProxy = o.Value("keep_proxy"); - - if (o.ContainsKey("prefix") || o.ContainsKey("suffix") && !o.ContainsKey("proxy_tags")) - ProxyTags = new[] {new ProxyTag(o.Value("prefix"), o.Value("suffix"))}; - else if (o.ContainsKey("proxy_tags")) - { - ProxyTags = o.Value("proxy_tags") - .OfType().Select(o => new ProxyTag(o.Value("prefix"), o.Value("suffix"))) - .ToList(); - } - } - } - - public class PKSwitch - { - public int Id { get; set; } - public int System { get; set; } - public Instant Timestamp { get; set; } - } - - public class PKSwitchMember - { - public int Id { get; set; } - public int Switch { get; set; } - public int Member { get; set; } - } -} \ No newline at end of file diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs new file mode 100644 index 00000000..2107db5a --- /dev/null +++ b/PluralKit.Core/Models/PKMember.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; + +using Newtonsoft.Json; + +using NodaTime; +using NodaTime.Text; + +namespace PluralKit.Core { + public class PKMember + { + // Additions here should be mirrored in MemberStore::Save + [JsonIgnore] public int Id { get; set; } + [JsonProperty("id")] public string Hid { get; set; } + [JsonIgnore] public int System { get; set; } + [JsonProperty("color")] public string Color { get; set; } + [JsonProperty("avatar_url")] public string AvatarUrl { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("display_name")] public string DisplayName { get; set; } + [JsonProperty("birthday")] public LocalDate? Birthday { get; set; } + [JsonProperty("pronouns")] public string Pronouns { get; set; } + [JsonProperty("description")] public string Description { get; set; } + [JsonProperty("proxy_tags")] public ICollection ProxyTags { get; set; } + [JsonProperty("keep_proxy")] public bool KeepProxy { get; set; } + [JsonProperty("created")] public Instant Created { get; set; } + + public PrivacyLevel MemberPrivacy { get; set; } + + /// Returns a formatted string representing the member's birthday, taking into account that a year of "0001" or "0004" is hidden + /// Before Feb 10 2020, the sentinel year was 0001, now it is 0004. + [JsonIgnore] public string BirthdayString + { + get + { + if (Birthday == null) return null; + + var format = LocalDatePattern.CreateWithInvariantCulture("MMM dd, yyyy"); + if (Birthday?.Year == 1 || Birthday?.Year == 4) format = LocalDatePattern.CreateWithInvariantCulture("MMM dd"); + return format.Format(Birthday.Value); + } + } + + [JsonIgnore] public bool HasProxyTags => ProxyTags.Count > 0; + public string ProxyName(string systemTag, string guildDisplayName) + { + if (systemTag == null) return guildDisplayName ?? DisplayName ?? Name; + return $"{guildDisplayName ?? DisplayName ?? Name} {systemTag}"; + } + } + + public struct ProxyTag + { + public ProxyTag(string prefix, string suffix) + { + // Normalize empty strings to null for DB + Prefix = prefix?.Length == 0 ? null : prefix; + Suffix = suffix?.Length == 0 ? null : suffix; + } + + [JsonProperty("prefix")] public string Prefix { get; set; } + [JsonProperty("suffix")] public string Suffix { get; set; } + + [JsonIgnore] public string ProxyString => $"{Prefix ?? ""}text{Suffix ?? ""}"; + + public bool IsEmpty => Prefix == null && Suffix == null; + + public bool Equals(ProxyTag other) => Prefix == other.Prefix && Suffix == other.Suffix; + + public override bool Equals(object obj) => obj is ProxyTag other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return ((Prefix != null ? Prefix.GetHashCode() : 0) * 397) ^ + (Suffix != null ? Suffix.GetHashCode() : 0); + } + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/PKSwitch.cs b/PluralKit.Core/Models/PKSwitch.cs new file mode 100644 index 00000000..b27c2f34 --- /dev/null +++ b/PluralKit.Core/Models/PKSwitch.cs @@ -0,0 +1,17 @@ +using NodaTime; + +namespace PluralKit.Core { + public class PKSwitch + { + public int Id { get; set; } + public int System { get; set; } + public Instant Timestamp { get; set; } + } + + public class PKSwitchMember + { + public int Id { get; set; } + public int Switch { get; set; } + public int Member { get; set; } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/PKSystem.cs b/PluralKit.Core/Models/PKSystem.cs new file mode 100644 index 00000000..9cc09fd6 --- /dev/null +++ b/PluralKit.Core/Models/PKSystem.cs @@ -0,0 +1,27 @@ +using Dapper.Contrib.Extensions; + +using Newtonsoft.Json; + +using NodaTime; + +namespace PluralKit.Core { + public class PKSystem + { + // Additions here should be mirrored in SystemStore::Save + [Key] [JsonIgnore] public int Id { get; set; } + [JsonProperty("id")] public string Hid { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("description")] public string Description { get; set; } + [JsonProperty("tag")] public string Tag { get; set; } + [JsonProperty("avatar_url")] public string AvatarUrl { get; set; } + [JsonIgnore] public string Token { get; set; } + [JsonProperty("created")] public Instant Created { get; set; } + [JsonProperty("tz")] public string UiTz { get; set; } + public PrivacyLevel DescriptionPrivacy { get; set; } + public PrivacyLevel MemberListPrivacy { get; set; } + public PrivacyLevel FrontPrivacy { get; set; } + public PrivacyLevel FrontHistoryPrivacy { get; set; } + + [JsonIgnore] public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/Privacy.cs b/PluralKit.Core/Models/Privacy.cs new file mode 100644 index 00000000..e153e84d --- /dev/null +++ b/PluralKit.Core/Models/Privacy.cs @@ -0,0 +1,21 @@ +namespace PluralKit.Core +{ + public enum PrivacyLevel + { + Public = 1, + Private = 2 + } + + public static class PrivacyExt + { + public static bool CanAccess(this PrivacyLevel level, LookupContext ctx) => + level == PrivacyLevel.Public || ctx == LookupContext.ByOwner; + } + + public enum LookupContext + { + ByOwner, + ByNonOwner, + API + } +} \ No newline at end of file diff --git a/PluralKit.Core/Modules.cs b/PluralKit.Core/Modules.cs index 5624cf24..597f5b71 100644 --- a/PluralKit.Core/Modules.cs +++ b/PluralKit.Core/Modules.cs @@ -5,7 +5,6 @@ using App.Metrics; using Autofac; using Autofac.Extensions.DependencyInjection; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/PluralKit.Core/PluralKit.Core.csproj.DotSettings b/PluralKit.Core/PluralKit.Core.csproj.DotSettings new file mode 100644 index 00000000..9259ebbb --- /dev/null +++ b/PluralKit.Core/PluralKit.Core.csproj.DotSettings @@ -0,0 +1,4 @@ + + True + True + True \ No newline at end of file diff --git a/PluralKit.Core/DataFiles.cs b/PluralKit.Core/Services/DataFileService.cs similarity index 96% rename from PluralKit.Core/DataFiles.cs rename to PluralKit.Core/Services/DataFileService.cs index 0dda348b..05f64020 100644 --- a/PluralKit.Core/DataFiles.cs +++ b/PluralKit.Core/Services/DataFileService.cs @@ -2,13 +2,15 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using Newtonsoft.Json; + using NodaTime; using NodaTime.Text; -using PluralKit.Core; + using Serilog; -namespace PluralKit.Bot +namespace PluralKit.Core { public class DataFileService { @@ -34,13 +36,13 @@ namespace PluralKit.Bot Name = m.Name, DisplayName = m.DisplayName, Description = m.Description, - Birthday = m.Birthday != null ? Formats.DateExportFormat.Format(m.Birthday.Value) : null, + Birthday = m.Birthday != null ? DateTimeFormats.DateExportFormat.Format(m.Birthday.Value) : null, Pronouns = m.Pronouns, Color = m.Color, AvatarUrl = m.AvatarUrl, ProxyTags = m.ProxyTags, KeepProxy = m.KeepProxy, - Created = Formats.TimestampExportFormat.Format(m.Created), + Created = DateTimeFormats.TimestampExportFormat.Format(m.Created), MessageCount = messageCounts.Where(x => x.Member == m.Id).Select(x => x.MessageCount).FirstOrDefault() })) members.Add(member); @@ -49,7 +51,7 @@ namespace PluralKit.Bot var switchList = await _data.GetPeriodFronters(system, Instant.FromDateTimeUtc(DateTime.MinValue.ToUniversalTime()), SystemClock.Instance.GetCurrentInstant()); switches.AddRange(switchList.Select(x => new DataFileSwitch { - Timestamp = Formats.TimestampExportFormat.Format(x.TimespanStart), + Timestamp = DateTimeFormats.TimestampExportFormat.Format(x.TimespanStart), Members = x.Members.Select(m => m.Hid).ToList() // Look up member's HID using the member export from above })); @@ -63,7 +65,7 @@ namespace PluralKit.Bot TimeZone = system.UiTz, Members = members, Switches = switches, - Created = Formats.TimestampExportFormat.Format(system.Created), + Created = DateTimeFormats.TimestampExportFormat.Format(system.Created), LinkedAccounts = (await _data.GetSystemAccounts(system)).ToList() }; } @@ -170,7 +172,7 @@ namespace PluralKit.Bot if (dataMember.Birthday != null) { - var birthdayParse = Formats.DateExportFormat.Parse(dataMember.Birthday); + var birthdayParse = DateTimeFormats.DateExportFormat.Parse(dataMember.Birthday); member.Birthday = birthdayParse.Success ? (LocalDate?)birthdayParse.Value : null; } diff --git a/PluralKit.Core/Services/IDataStore.cs b/PluralKit.Core/Services/IDataStore.cs new file mode 100644 index 00000000..9529a282 --- /dev/null +++ b/PluralKit.Core/Services/IDataStore.cs @@ -0,0 +1,422 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +using NodaTime; + +namespace PluralKit.Core { + public enum AutoproxyMode + { + Off = 1, + Front = 2, + Latch = 3, + Member = 4 + } + + public class FullMessage + { + public PKMessage Message; + public PKMember Member; + public PKSystem System; + } + + public struct PKMessage + { + public ulong Mid; + public ulong? Guild; // null value means "no data" (ie. from before this field being added) + public ulong Channel; + public ulong Sender; + public ulong? OriginalMid; + } + + public struct ImportedSwitch + { + public Instant Timestamp; + public IReadOnlyCollection Members; + } + + public struct SwitchListEntry + { + public ICollection Members; + public Instant TimespanStart; + public Instant TimespanEnd; + } + + public struct MemberMessageCount + { + public int Member; + public int MessageCount; + } + + public struct FrontBreakdown + { + public Dictionary MemberSwitchDurations; + public Duration NoFronterDuration; + public Instant RangeStart; + public Instant RangeEnd; + } + + public struct SwitchMembersListEntry + { + public int Member; + public Instant Timestamp; + } + + public struct GuildConfig + { + public ulong Id { get; set; } + public ulong? LogChannel { get; set; } + public ISet LogBlacklist { get; set; } + public ISet Blacklist { get; set; } + } + + public class SystemGuildSettings + { + public ulong Guild { get; set; } + public bool ProxyEnabled { get; set; } = true; + + public AutoproxyMode AutoproxyMode { get; set; } = AutoproxyMode.Off; + public int? AutoproxyMember { get; set; } + } + + public class MemberGuildSettings + { + public int Member { get; set; } + public ulong Guild { get; set; } + public string DisplayName { get; set; } + } + + public class AuxillaryProxyInformation + { + public GuildConfig Guild { get; set; } + public SystemGuildSettings SystemGuild { get; set; } + public MemberGuildSettings MemberGuild { get; set; } + } + + public interface IDataStore + { + /// + /// Gets a system by its internal system ID. + /// + /// The with the given internal ID, or null if no system was found. + Task GetSystemById(int systemId); + + /// + /// Gets a system by its user-facing human ID. + /// + /// The with the given human ID, or null if no system was found. + Task GetSystemByHid(string systemHid); + + /// + /// Gets a system by one of its linked Discord account IDs. Multiple IDs can return the same system. + /// + /// The with the given linked account, or null if no system was found. + Task GetSystemByAccount(ulong linkedAccount); + + /// + /// Gets a system by its API token. + /// + /// The with the given API token, or null if no corresponding system was found. + Task GetSystemByToken(string apiToken); + + /// + /// Gets the Discord account IDs linked to a system. + /// + /// An enumerable of Discord account IDs linked to this system. + Task> GetSystemAccounts(PKSystem system); + + /// + /// Gets the member count of a system. + /// + /// Whether the returned count should include private members. + Task GetSystemMemberCount(PKSystem system, bool includePrivate); + + /// + /// Gets a list of members with proxy tags that conflict with the given tags. + /// + /// A set of proxy tags A conflict with proxy tags B if both A's prefix and suffix + /// are a "subset" of B's. In other words, if A's prefix *starts* with B's prefix + /// and A's suffix *ends* with B's suffix, the tag pairs are considered conflicting. + /// + /// The system to check in. + Task> GetConflictingProxies(PKSystem system, ProxyTag tag); + + /// + /// Gets a specific system's guild-specific settings for a given guild. + /// + Task GetSystemGuildSettings(PKSystem system, ulong guild); + + /// + /// Saves a specific system's guild-specific settings. + /// + Task SetSystemGuildSettings(PKSystem system, ulong guild, SystemGuildSettings settings); + + /// + /// Creates a system, auto-generating its corresponding IDs. + /// + /// An optional system name to set. If `null`, will not set a system name. + /// The created system model. + Task CreateSystem(string systemName); + // TODO: throw exception if account is present (when adding) or account isn't present (when removing) + + /// + /// Links a Discord account to a system. + /// + /// Throws an exception (TODO: which?) if the given account is already linked to a system. + Task AddAccount(PKSystem system, ulong accountToAdd); + + /// + /// Unlinks a Discord account from a system. + /// + /// Will *not* throw if this results in an orphaned system - this is the caller's responsibility to ensure. + /// + /// Throws an exception (TODO: which?) if the given account is not linked to the given system. + Task RemoveAccount(PKSystem system, ulong accountToRemove); + + /// + /// Saves the information within the given struct to the data store. + /// + Task SaveSystem(PKSystem system); + + /// + /// Deletes the given system from the database. + /// + /// + /// This will also delete all the system's members, all system switches, and every message that has been proxied + /// by members in the system. + /// + Task DeleteSystem(PKSystem system); + + /// + /// Gets a system by its internal member ID. + /// + /// The with the given internal ID, or null if no member was found. + Task GetMemberById(int memberId); + + /// + /// Gets a member by its user-facing human ID. + /// + /// The with the given human ID, or null if no member was found. + Task GetMemberByHid(string memberHid); + + /// + /// Gets a member by its member name within one system. + /// + /// + /// Member names are *usually* unique within a system (but not always), whereas member names + /// are almost certainly *not* unique globally - therefore only intra-system lookup is + /// allowed. + /// + /// The with the given name, or null if no member was found. + Task GetMemberByName(PKSystem system, string name); + + /// + /// Gets all members inside a given system. + /// + /// An enumerable of structs representing each member in the system, in no particular order. + IAsyncEnumerable GetSystemMembers(PKSystem system, bool orderByName = false); + /// + /// Gets the amount of messages proxied by a given member. + /// + /// The message count of the given member. + Task GetMemberMessageCount(PKMember member); + + /// + /// Collects a breakdown of each member in a system's message count. + /// + /// An enumerable of members along with their message counts. + Task> GetMemberMessageCountBulk(PKSystem system); + + /// + /// Creates a member, auto-generating its corresponding IDs. + /// + /// The system in which to create the member. + /// The name of the member to create. + /// The created system model. + Task CreateMember(PKSystem system, string name); + + /// + /// Creates multiple members, auto-generating each corresponding ID. + /// + /// The system to create the member in. + /// A dictionary containing a mapping from an arbitrary key to the member's name. + /// A dictionary containing the resulting member structs, each mapped to the key given in the argument dictionary. + Task> CreateMembersBulk(PKSystem system, Dictionary memberNames); + + /// + /// Saves the information within the given struct to the data store. + /// + Task SaveMember(PKMember member); + + /// + /// Deletes the given member from the database. + /// + /// + /// This will remove this member from any switches it's involved in, as well as all the messages + /// proxied by this member. + /// + Task DeleteMember(PKMember member); + + /// + /// Gets a specific member's guild-specific settings for a given guild. + /// + Task GetMemberGuildSettings(PKMember member, ulong guild); + + /// + /// Saves a specific member's guild-specific settings. + /// + Task SetMemberGuildSettings(PKMember member, ulong guild, MemberGuildSettings settings); + + /// + /// Gets a message and its information by its ID. + /// + /// The message ID to look up. This can be either the ID of the trigger message containing the proxy tags or the resulting proxied webhook message. + /// An extended message object, containing not only the message data itself but the associated system and member structs. + Task GetMessage(ulong id); // id is both original and trigger, also add return type struct + + /// + /// Saves a posted message to the database. + /// + /// The ID of the account that sent the original trigger message. + /// The ID of the guild the message was posted to. + /// The ID of the channel the message was posted to. + /// The ID of the message posted by the webhook. + /// The ID of the original trigger message containing the proxy tags. + /// The member (and by extension system) that was proxied. + /// + Task AddMessage(ulong senderAccount, ulong guildId, ulong channelId, ulong postedMessageId, ulong triggerMessageId, PKMember proxiedMember); + + /// + /// Deletes a message from the data store. + /// + /// The ID of the webhook message to delete. + Task DeleteMessage(ulong postedMessageId); + + /// + /// Deletes messages from the data store in bulk. + /// + /// The IDs of the webhook messages to delete. + Task DeleteMessagesBulk(IEnumerable postedMessageIds); + + /// + /// Gets the most recent message sent by a given account in a given guild. + /// + /// The full message object, or null if none was found. + Task GetLastMessageInGuild(ulong account, ulong guild); + + /// + /// Gets switches from a system. + /// + /// An enumerable of the *count* latest switches in the system, in latest-first order. May contain fewer elements than requested. + IAsyncEnumerable GetSwitches(PKSystem system); + + /// + /// Gets the total amount of switches in a given system. + /// + Task GetSwitchCount(PKSystem system); + + + /// + /// Gets the latest (temporally; closest to now) switch of a given system. + /// + Task GetLatestSwitch(PKSystem system); + + /// + /// Gets the members a given switch consists of. + /// + IAsyncEnumerable GetSwitchMembers(PKSwitch sw); + + /// + /// Gets a list of fronters over a given period of time. + /// + /// + /// This list is returned as an enumerable of "switch members", each containing a timestamp + /// and a member ID. + /// + /// Switches containing multiple members will be returned as multiple switch members each with the same + /// timestamp, and a change in timestamp should be interpreted as the start of a new switch. + /// + /// An enumerable of the aforementioned "switch members". + Task> GetPeriodFronters(PKSystem system, Instant periodStart, Instant periodEnd); + + /// + /// Calculates a breakdown of a system's fronters over a given period, including how long each member has + /// been fronting, and how long *no* member has been fronting. + /// + /// + /// Switches containing multiple members will count the full switch duration for all members, meaning + /// the total duration may add up to longer than the breakdown period. + /// + /// + /// + /// + /// + Task GetFrontBreakdown(PKSystem system, Instant periodStart, Instant periodEnd); + + /// + /// Gets the first listed fronter in a system. + /// + /// The first fronter, or null if none are registered. + Task GetFirstFronter(PKSystem system); + + /// + /// Registers a switch with the given members in the given system. + /// + /// Throws an exception (TODO: which?) if any of the members are not in the given system. + Task AddSwitch(PKSystem system, IEnumerable switchMembers); + + /// + /// Registers switches in bulk. + /// + /// A list of switch structs, each containing a timestamp and a list of members. + /// Throws an exception (TODO: which?) if any of the given members are not in the given system. + Task AddSwitchesBulk(PKSystem system, IEnumerable switches); + + /// + /// Updates the timestamp of a given switch. + /// + Task MoveSwitch(PKSwitch sw, Instant time); + + /// + /// Deletes a given switch from the data store. + /// + Task DeleteSwitch(PKSwitch sw); + + /// + /// Deletes all switches in a given system from the data store. + /// + Task DeleteAllSwitches(PKSystem system); + + /// + /// Gets the total amount of systems in the data store. + /// + Task GetTotalSystems(); + + /// + /// Gets the total amount of members in the data store. + /// + Task GetTotalMembers(); + + /// + /// Gets the total amount of switches in the data store. + /// + Task GetTotalSwitches(); + + /// + /// Gets the total amount of messages in the data store. + /// + Task GetTotalMessages(); + + /// + /// Gets the guild configuration struct for a given guild, creating and saving one if none was found. + /// + /// The guild's configuration struct. + Task GetOrCreateGuildConfig(ulong guild); + + /// + /// Saves the given guild configuration struct to the data store. + /// + Task SaveGuildConfig(GuildConfig cfg); + + Task GetAuxillaryProxyInformation(ulong guild, PKSystem system, PKMember member); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Stores.cs b/PluralKit.Core/Services/PostgresDataStore.cs similarity index 64% rename from PluralKit.Core/Stores.cs rename to PluralKit.Core/Services/PostgresDataStore.cs index cbbda0c9..80883a0b 100644 --- a/PluralKit.Core/Stores.cs +++ b/PluralKit.Core/Services/PostgresDataStore.cs @@ -1,432 +1,14 @@ -using System.Collections.Generic; -using System.Data.Common; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Dapper; -using NodaTime; -using PluralKit.Core; +using NodaTime; using Serilog; -namespace PluralKit { - public enum AutoproxyMode - { - Off = 1, - Front = 2, - Latch = 3, - Member = 4 - } - - public class FullMessage - { - public PKMessage Message; - public PKMember Member; - public PKSystem System; - } - - public struct PKMessage - { - public ulong Mid; - public ulong? Guild; // null value means "no data" (ie. from before this field being added) - public ulong Channel; - public ulong Sender; - public ulong? OriginalMid; - } - - public struct ImportedSwitch - { - public Instant Timestamp; - public IReadOnlyCollection Members; - } - - public struct SwitchListEntry - { - public ICollection Members; - public Instant TimespanStart; - public Instant TimespanEnd; - } - - public struct MemberMessageCount - { - public int Member; - public int MessageCount; - } - - public struct FrontBreakdown - { - public Dictionary MemberSwitchDurations; - public Duration NoFronterDuration; - public Instant RangeStart; - public Instant RangeEnd; - } - - public struct SwitchMembersListEntry - { - public int Member; - public Instant Timestamp; - } - - public struct GuildConfig - { - public ulong Id { get; set; } - public ulong? LogChannel { get; set; } - public ISet LogBlacklist { get; set; } - public ISet Blacklist { get; set; } - } - - public class SystemGuildSettings - { - public ulong Guild { get; set; } - public bool ProxyEnabled { get; set; } = true; - - public AutoproxyMode AutoproxyMode { get; set; } = AutoproxyMode.Off; - public int? AutoproxyMember { get; set; } - } - - public class MemberGuildSettings - { - public int Member { get; set; } - public ulong Guild { get; set; } - public string DisplayName { get; set; } - } - - public class AuxillaryProxyInformation - { - public GuildConfig Guild { get; set; } - public SystemGuildSettings SystemGuild { get; set; } - public MemberGuildSettings MemberGuild { get; set; } - } - - public interface IDataStore - { - /// - /// Gets a system by its internal system ID. - /// - /// The with the given internal ID, or null if no system was found. - Task GetSystemById(int systemId); - - /// - /// Gets a system by its user-facing human ID. - /// - /// The with the given human ID, or null if no system was found. - Task GetSystemByHid(string systemHid); - - /// - /// Gets a system by one of its linked Discord account IDs. Multiple IDs can return the same system. - /// - /// The with the given linked account, or null if no system was found. - Task GetSystemByAccount(ulong linkedAccount); - - /// - /// Gets a system by its API token. - /// - /// The with the given API token, or null if no corresponding system was found. - Task GetSystemByToken(string apiToken); - - /// - /// Gets the Discord account IDs linked to a system. - /// - /// An enumerable of Discord account IDs linked to this system. - Task> GetSystemAccounts(PKSystem system); - - /// - /// Gets the member count of a system. - /// - /// Whether the returned count should include private members. - Task GetSystemMemberCount(PKSystem system, bool includePrivate); - - /// - /// Gets a list of members with proxy tags that conflict with the given tags. - /// - /// A set of proxy tags A conflict with proxy tags B if both A's prefix and suffix - /// are a "subset" of B's. In other words, if A's prefix *starts* with B's prefix - /// and A's suffix *ends* with B's suffix, the tag pairs are considered conflicting. - /// - /// The system to check in. - Task> GetConflictingProxies(PKSystem system, ProxyTag tag); - - /// - /// Gets a specific system's guild-specific settings for a given guild. - /// - Task GetSystemGuildSettings(PKSystem system, ulong guild); - - /// - /// Saves a specific system's guild-specific settings. - /// - Task SetSystemGuildSettings(PKSystem system, ulong guild, SystemGuildSettings settings); - - /// - /// Creates a system, auto-generating its corresponding IDs. - /// - /// An optional system name to set. If `null`, will not set a system name. - /// The created system model. - Task CreateSystem(string systemName); - // TODO: throw exception if account is present (when adding) or account isn't present (when removing) - - /// - /// Links a Discord account to a system. - /// - /// Throws an exception (TODO: which?) if the given account is already linked to a system. - Task AddAccount(PKSystem system, ulong accountToAdd); - - /// - /// Unlinks a Discord account from a system. - /// - /// Will *not* throw if this results in an orphaned system - this is the caller's responsibility to ensure. - /// - /// Throws an exception (TODO: which?) if the given account is not linked to the given system. - Task RemoveAccount(PKSystem system, ulong accountToRemove); - - /// - /// Saves the information within the given struct to the data store. - /// - Task SaveSystem(PKSystem system); - - /// - /// Deletes the given system from the database. - /// - /// - /// This will also delete all the system's members, all system switches, and every message that has been proxied - /// by members in the system. - /// - Task DeleteSystem(PKSystem system); - - /// - /// Gets a system by its internal member ID. - /// - /// The with the given internal ID, or null if no member was found. - Task GetMemberById(int memberId); - - /// - /// Gets a member by its user-facing human ID. - /// - /// The with the given human ID, or null if no member was found. - Task GetMemberByHid(string memberHid); - - /// - /// Gets a member by its member name within one system. - /// - /// - /// Member names are *usually* unique within a system (but not always), whereas member names - /// are almost certainly *not* unique globally - therefore only intra-system lookup is - /// allowed. - /// - /// The with the given name, or null if no member was found. - Task GetMemberByName(PKSystem system, string name); - - /// - /// Gets all members inside a given system. - /// - /// An enumerable of structs representing each member in the system, in no particular order. - IAsyncEnumerable GetSystemMembers(PKSystem system, bool orderByName = false); - /// - /// Gets the amount of messages proxied by a given member. - /// - /// The message count of the given member. - Task GetMemberMessageCount(PKMember member); - - /// - /// Collects a breakdown of each member in a system's message count. - /// - /// An enumerable of members along with their message counts. - Task> GetMemberMessageCountBulk(PKSystem system); - - /// - /// Creates a member, auto-generating its corresponding IDs. - /// - /// The system in which to create the member. - /// The name of the member to create. - /// The created system model. - Task CreateMember(PKSystem system, string name); - - /// - /// Creates multiple members, auto-generating each corresponding ID. - /// - /// The system to create the member in. - /// A dictionary containing a mapping from an arbitrary key to the member's name. - /// A dictionary containing the resulting member structs, each mapped to the key given in the argument dictionary. - Task> CreateMembersBulk(PKSystem system, Dictionary memberNames); - - /// - /// Saves the information within the given struct to the data store. - /// - Task SaveMember(PKMember member); - - /// - /// Deletes the given member from the database. - /// - /// - /// This will remove this member from any switches it's involved in, as well as all the messages - /// proxied by this member. - /// - Task DeleteMember(PKMember member); - - /// - /// Gets a specific member's guild-specific settings for a given guild. - /// - Task GetMemberGuildSettings(PKMember member, ulong guild); - - /// - /// Saves a specific member's guild-specific settings. - /// - Task SetMemberGuildSettings(PKMember member, ulong guild, MemberGuildSettings settings); - - /// - /// Gets a message and its information by its ID. - /// - /// The message ID to look up. This can be either the ID of the trigger message containing the proxy tags or the resulting proxied webhook message. - /// An extended message object, containing not only the message data itself but the associated system and member structs. - Task GetMessage(ulong id); // id is both original and trigger, also add return type struct - - /// - /// Saves a posted message to the database. - /// - /// The ID of the account that sent the original trigger message. - /// The ID of the guild the message was posted to. - /// The ID of the channel the message was posted to. - /// The ID of the message posted by the webhook. - /// The ID of the original trigger message containing the proxy tags. - /// The member (and by extension system) that was proxied. - /// - Task AddMessage(ulong senderAccount, ulong guildId, ulong channelId, ulong postedMessageId, ulong triggerMessageId, PKMember proxiedMember); - - /// - /// Deletes a message from the data store. - /// - /// The ID of the webhook message to delete. - Task DeleteMessage(ulong postedMessageId); - - /// - /// Deletes messages from the data store in bulk. - /// - /// The IDs of the webhook messages to delete. - Task DeleteMessagesBulk(IEnumerable postedMessageIds); - - /// - /// Gets the most recent message sent by a given account in a given guild. - /// - /// The full message object, or null if none was found. - Task GetLastMessageInGuild(ulong account, ulong guild); - - /// - /// Gets switches from a system. - /// - /// An enumerable of the *count* latest switches in the system, in latest-first order. May contain fewer elements than requested. - IAsyncEnumerable GetSwitches(PKSystem system); - - /// - /// Gets the total amount of switches in a given system. - /// - Task GetSwitchCount(PKSystem system); - - - /// - /// Gets the latest (temporally; closest to now) switch of a given system. - /// - Task GetLatestSwitch(PKSystem system); - - /// - /// Gets the members a given switch consists of. - /// - IAsyncEnumerable GetSwitchMembers(PKSwitch sw); - - /// - /// Gets a list of fronters over a given period of time. - /// - /// - /// This list is returned as an enumerable of "switch members", each containing a timestamp - /// and a member ID. - /// - /// Switches containing multiple members will be returned as multiple switch members each with the same - /// timestamp, and a change in timestamp should be interpreted as the start of a new switch. - /// - /// An enumerable of the aforementioned "switch members". - Task> GetPeriodFronters(PKSystem system, Instant periodStart, Instant periodEnd); - - /// - /// Calculates a breakdown of a system's fronters over a given period, including how long each member has - /// been fronting, and how long *no* member has been fronting. - /// - /// - /// Switches containing multiple members will count the full switch duration for all members, meaning - /// the total duration may add up to longer than the breakdown period. - /// - /// - /// - /// - /// - Task GetFrontBreakdown(PKSystem system, Instant periodStart, Instant periodEnd); - - /// - /// Gets the first listed fronter in a system. - /// - /// The first fronter, or null if none are registered. - Task GetFirstFronter(PKSystem system); - - /// - /// Registers a switch with the given members in the given system. - /// - /// Throws an exception (TODO: which?) if any of the members are not in the given system. - Task AddSwitch(PKSystem system, IEnumerable switchMembers); - - /// - /// Registers switches in bulk. - /// - /// A list of switch structs, each containing a timestamp and a list of members. - /// Throws an exception (TODO: which?) if any of the given members are not in the given system. - Task AddSwitchesBulk(PKSystem system, IEnumerable switches); - - /// - /// Updates the timestamp of a given switch. - /// - Task MoveSwitch(PKSwitch sw, Instant time); - - /// - /// Deletes a given switch from the data store. - /// - Task DeleteSwitch(PKSwitch sw); - - /// - /// Deletes all switches in a given system from the data store. - /// - Task DeleteAllSwitches(PKSystem system); - - /// - /// Gets the total amount of systems in the data store. - /// - Task GetTotalSystems(); - - /// - /// Gets the total amount of members in the data store. - /// - Task GetTotalMembers(); - - /// - /// Gets the total amount of switches in the data store. - /// - Task GetTotalSwitches(); - - /// - /// Gets the total amount of messages in the data store. - /// - Task GetTotalMessages(); - - /// - /// Gets the guild configuration struct for a given guild, creating and saving one if none was found. - /// - /// The guild's configuration struct. - Task GetOrCreateGuildConfig(ulong guild); - - /// - /// Saves the given guild configuration struct to the data store. - /// - Task SaveGuildConfig(GuildConfig cfg); - - Task GetAuxillaryProxyInformation(ulong guild, PKSystem system, PKMember member); - } - +namespace PluralKit.Core { public class PostgresDataStore: IDataStore { private DbConnectionFactory _conn; private ILogger _logger; @@ -460,8 +42,8 @@ namespace PluralKit { { using (var conn = await _conn.Obtain()) return await conn.QuerySingleOrDefaultAsync( - "select * from system_guild where system = @System and guild = @Guild", - new {System = system.Id, Guild = guild}) ?? new SystemGuildSettings(); + "select * from system_guild where system = @System and guild = @Guild", + new {System = system.Id, Guild = guild}) ?? new SystemGuildSettings(); } public async Task SetSystemGuildSettings(PKSystem system, ulong guild, SystemGuildSettings settings) { @@ -481,7 +63,7 @@ namespace PluralKit { string hid; do { - hid = Utils.GenerateHid(); + hid = StringUtils.GenerateHid(); } while (await GetSystemByHid(hid) != null); PKSystem system; @@ -575,7 +157,7 @@ namespace PluralKit { string hid; do { - hid = Utils.GenerateHid(); + hid = StringUtils.GenerateHid(); } while (await GetMemberByHid(hid) != null); PKMember member; @@ -604,7 +186,7 @@ namespace PluralKit { { hid = await conn.QuerySingleOrDefaultAsync("SELECT @Hid WHERE NOT EXISTS (SELECT id FROM members WHERE hid = @Hid LIMIT 1)", new { - Hid = Utils.GenerateHid() + Hid = StringUtils.GenerateHid() }); } while (hid == null); var member = await conn.QuerySingleAsync("INSERT INTO members (hid, system, name) VALUES (@Hid, @SystemId, @Name) RETURNING *", new @@ -666,8 +248,8 @@ namespace PluralKit { { using var conn = await _conn.Obtain(); return await conn.QuerySingleOrDefaultAsync( - "select * from member_guild where member = @Member and guild = @Guild", new { Member = member.Id, Guild = guild}) - ?? new MemberGuildSettings(); + "select * from member_guild where member = @Member and guild = @Guild", new { Member = member.Id, Guild = guild}) + ?? new MemberGuildSettings(); } public async Task SetMemberGuildSettings(PKMember member, ulong guild, MemberGuildSettings settings) @@ -1042,7 +624,7 @@ namespace PluralKit { await conn.QueryAsync( "select * from members where id = any(@Switches)", // lol postgres specific `= any()` syntax new { Switches = switchMembers.Select(m => m.Member).Distinct().ToList() }) - ).ToDictionary(m => m.Id); + ).ToDictionary(m => m.Id); } // Initialize entries - still need to loop to determine the TimespanEnd below diff --git a/PluralKit.Core/ProxyCache.cs b/PluralKit.Core/Services/ProxyCacheService.cs similarity index 100% rename from PluralKit.Core/ProxyCache.cs rename to PluralKit.Core/Services/ProxyCacheService.cs diff --git a/PluralKit.Core/SchemaService.cs b/PluralKit.Core/Services/SchemaService.cs similarity index 99% rename from PluralKit.Core/SchemaService.cs rename to PluralKit.Core/Services/SchemaService.cs index 3be9938c..9233c7b6 100644 --- a/PluralKit.Core/SchemaService.cs +++ b/PluralKit.Core/Services/SchemaService.cs @@ -1,13 +1,14 @@ using System; using System.IO; using System.Threading.Tasks; + using Dapper; using Npgsql; using Serilog; -namespace PluralKit { +namespace PluralKit.Core { public class SchemaService { private const int TargetSchemaVersion = 3; diff --git a/PluralKit.Core/Utils.cs b/PluralKit.Core/Utils.cs deleted file mode 100644 index e8585816..00000000 --- a/PluralKit.Core/Utils.cs +++ /dev/null @@ -1,645 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using App.Metrics; -using App.Metrics.Timer; -using Dapper; -using Microsoft.Extensions.Configuration; -using Newtonsoft.Json; -using NodaTime; -using NodaTime.Serialization.JsonNet; -using NodaTime.Text; -using Npgsql; -using PluralKit.Core; -using Serilog; -using Serilog.Core; -using Serilog.Events; -using Serilog.Formatting.Compact; -using Serilog.Formatting.Display; -using Serilog.Formatting.Json; -using Serilog.Sinks.SystemConsole.Themes; - - -namespace PluralKit -{ - public static class Utils - { - public static string GenerateHid() - { - var rnd = new Random(); - var charset = "abcdefghijklmnopqrstuvwxyz"; - string hid = ""; - for (int i = 0; i < 5; i++) - { - hid += charset[rnd.Next(charset.Length)]; - } - return hid; - } - - public static string GenerateToken() - { - var buf = new byte[48]; // Results in a 64-byte Base64 string (no padding) - new RNGCryptoServiceProvider().GetBytes(buf); - return Convert.ToBase64String(buf); - } - - public static bool IsLongerThan(this string str, int length) - { - if (str != null) return str.Length > length; - return false; - } - - public static Duration? ParsePeriod(string str) - { - - Duration d = Duration.Zero; - - foreach (Match match in Regex.Matches(str, "(\\d{1,6})(\\w)")) - { - var amount = int.Parse(match.Groups[1].Value); - var type = match.Groups[2].Value; - - if (type == "w") d += Duration.FromDays(7) * amount; - else if (type == "d") d += Duration.FromDays(1) * amount; - else if (type == "h") d += Duration.FromHours(1) * amount; - else if (type == "m") d += Duration.FromMinutes(1) * amount; - else if (type == "s") d += Duration.FromSeconds(1) * amount; - else return null; - } - - if (d == Duration.Zero) return null; - return d; - } - - public static LocalDate? ParseDate(string str, bool allowNullYear = false) - { - // NodaTime can't parse constructs like "1st" and "2nd" so we quietly replace those away - // Gotta make sure to do the regex otherwise we'll catch things like the "st" in "August" too - str = Regex.Replace(str, "(\\d+)(st|nd|rd|th)", "$1"); - - var patterns = new[] - { - "MMM d yyyy", // Jan 1 2019 - "MMM d, yyyy", // Jan 1, 2019 - "MMMM d yyyy", // January 1 2019 - "MMMM d, yyyy", // January 1, 2019 - "yyyy-MM-dd", // 2019-01-01 - "yyyy MM dd", // 2019 01 01 - "yyyy/MM/dd" // 2019/01/01 - }.ToList(); - - if (allowNullYear) patterns.AddRange(new[] - { - "MMM d", // Jan 1 - "MMMM d", // January 1 - "MM-dd", // 01-01 - "MM dd", // 01 01 - "MM/dd" // 01/01 - }); - - // Giving a template value so year will be parsed as 0004 if not present - // This means we can later disambiguate whether a null year was given - // We use the basis year 0004 (rather than, say, 0001) because 0004 is a leap year in the Gregorian calendar - // which means the date "Feb 29, 0004" is a valid date. 0001 is still accepted as a null year for legacy reasons. - // TODO: should we be using invariant culture here? - foreach (var pattern in patterns.Select(p => LocalDatePattern.CreateWithInvariantCulture(p).WithTemplateValue(new LocalDate(0004, 1, 1)))) - { - var result = pattern.Parse(str); - if (result.Success) return result.Value; - } - - return null; - } - - public static ZonedDateTime? ParseDateTime(string str, bool nudgeToPast = false, DateTimeZone zone = null) - { - if (zone == null) zone = DateTimeZone.Utc; - - // Find the current timestamp in the given zone, find the (naive) midnight timestamp, then put that into the same zone (and make it naive again) - // Should yield a 12:00:00 AM. - var now = SystemClock.Instance.GetCurrentInstant().InZone(zone).LocalDateTime; - var midnight = now.Date.AtMidnight(); - - // First we try to parse the string as a relative time using the period parser - var relResult = ParsePeriod(str); - if (relResult != null) - { - // if we can, we just subtract that amount from the - return now.InZoneLeniently(zone).Minus(relResult.Value); - } - - var timePatterns = new[] - { - "H:mm", // 4:30 - "HH:mm", // 23:30 - "H:mm:ss", // 4:30:29 - "HH:mm:ss", // 23:30:29 - "h tt", // 2 PM - "htt", // 2PM - "h:mm tt", // 4:30 PM - "h:mmtt", // 4:30PM - "h:mm:ss tt", // 4:30:29 PM - "h:mm:sstt", // 4:30:29PM - "hh:mm tt", // 11:30 PM - "hh:mmtt", // 11:30PM - "hh:mm:ss tt", // 11:30:29 PM - "hh:mm:sstt" // 11:30:29PM - }; - - var datePatterns = new[] - { - "MMM d yyyy", // Jan 1 2019 - "MMM d, yyyy", // Jan 1, 2019 - "MMMM d yyyy", // January 1 2019 - "MMMM d, yyyy", // January 1, 2019 - "yyyy-MM-dd", // 2019-01-01 - "yyyy MM dd", // 2019 01 01 - "yyyy/MM/dd", // 2019/01/01 - "MMM d", // Jan 1 - "MMMM d", // January 1 - "MM-dd", // 01-01 - "MM dd", // 01 01 - "MM/dd" // 01-01 - }; - - // First, we try all the timestamps that only have a time - foreach (var timePattern in timePatterns) - { - var pat = LocalDateTimePattern.CreateWithInvariantCulture(timePattern).WithTemplateValue(midnight); - var result = pat.Parse(str); - if (result.Success) - { - // If we have a successful match and we need a time in the past, we try to shove a future-time a date before - // Example: "4:30 pm" at 3:30 pm likely refers to 4:30 pm the previous day - var val = result.Value; - - // If we need to nudge, we just subtract a day. This only occurs when we're parsing specifically *just time*, so - // we know we won't nudge it by more than a day since we use today's midnight timestamp as a date template. - - // Since this is a naive datetime, this ensures we're actually moving by one calendar day even if - // DST changes occur, since they'll be resolved later wrt. the right side of the boundary - if (val > now && nudgeToPast) val = val.PlusDays(-1); - return val.InZoneLeniently(zone); - } - } - - // Then we try specific date+time combinations, both date first and time first, with and without commas - foreach (var timePattern in timePatterns) - { - foreach (var datePattern in datePatterns) - { - foreach (var patternStr in new[] - { - $"{timePattern}, {datePattern}", $"{datePattern}, {timePattern}", - $"{timePattern} {datePattern}", $"{datePattern} {timePattern}" - }) - { - var pattern = LocalDateTimePattern.CreateWithInvariantCulture(patternStr).WithTemplateValue(midnight); - var res = pattern.Parse(str); - if (res.Success) return res.Value.InZoneLeniently(zone); - } - } - } - - // Finally, just date patterns, still using midnight as the template - foreach (var datePattern in datePatterns) - { - var pat = LocalDateTimePattern.CreateWithInvariantCulture(datePattern).WithTemplateValue(midnight); - var res = pat.Parse(str); - if (res.Success) return res.Value.InZoneLeniently(zone); - } - - // Still haven't parsed something, we just give up lmao - return null; - } - - public static string ExtractCountryFlag(string flag) - { - if (flag.Length != 4) return null; - try - { - var cp1 = char.ConvertToUtf32(flag, 0); - var cp2 = char.ConvertToUtf32(flag, 2); - if (cp1 < 0x1F1E6 || cp1 > 0x1F1FF) return null; - if (cp2 < 0x1F1E6 || cp2 > 0x1F1FF) return null; - return $"{(char) (cp1 - 0x1F1E6 + 'A')}{(char) (cp2 - 0x1F1E6 + 'A')}"; - } - catch (ArgumentException) - { - return null; - } - } - - public static IEnumerable TakeWhileIncluding(this IEnumerable list, Func predicate) - { - // modified from https://stackoverflow.com/a/6817553 - foreach(var el in list) - { - yield return el; - if (!predicate(el)) - yield break; - } - } - - public static string NullIfEmpty(this string input) - { - if (input == null) return null; - if (input.Trim().Length == 0) return null; - return input; - } - - public static string BoundsCheck(this string input, int maxLength, string nameInError) - { - if (input != null && input.Length > maxLength) - throw new PKParseError($"{nameInError} too long ({input.Length} > {maxLength})."); - return input; - } - } - - public static class Emojis { - public static readonly string Warn = "\u26A0"; - public static readonly string Success = "\u2705"; - public static readonly string Error = "\u274C"; - public static readonly string Note = "\U0001f4dd"; - public static readonly string ThumbsUp = "\U0001f44d"; - public static readonly string RedQuestion = "\u2753"; - public static readonly string Bell = "\U0001F514"; - } - - public static class Formats - { - public static IPattern TimestampExportFormat = InstantPattern.ExtendedIso; - public static IPattern DateExportFormat = LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd"); - - // We create a composite pattern that only shows the two most significant things - // eg. if we have something with nonzero day component, we show d h, but if it's - // a smaller duration we may only bother with showing h m or m s - public static IPattern DurationFormat = new CompositePatternBuilder - { - {DurationPattern.CreateWithInvariantCulture("s's'"), d => true}, - {DurationPattern.CreateWithInvariantCulture("m'm' s's'"), d => d.Minutes > 0}, - {DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0}, - {DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0} - }.Build(); - - public static IPattern LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); - public static IPattern ZonedDateTimeFormat = ZonedDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss x", DateTimeZoneProviders.Tzdb); - } - public static class InitUtils - { - public static IConfigurationBuilder BuildConfiguration(string[] args) => new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("pluralkit.conf", true) - .AddEnvironmentVariables() - .AddCommandLine(args); - - public static void Init() - { - InitDatabase(); - } - - private static void InitDatabase() - { - // Dapper by default tries to pass ulongs to Npgsql, which rejects them since PostgreSQL technically - // doesn't support unsigned types on its own. - // Instead we add a custom mapper to encode them as signed integers instead, converting them back and forth. - SqlMapper.RemoveTypeMap(typeof(ulong)); - SqlMapper.AddTypeHandler(new UlongEncodeAsLongHandler()); - Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; - - // Also, use NodaTime. it's good. - NpgsqlConnection.GlobalTypeMapper.UseNodaTime(); - // With the thing we add above, Npgsql already handles NodaTime integration - // This makes Dapper confused since it thinks it has to convert it anyway and doesn't understand the types - // So we add a custom type handler that literally just passes the type through to Npgsql - SqlMapper.AddTypeHandler(new PassthroughTypeHandler()); - SqlMapper.AddTypeHandler(new PassthroughTypeHandler()); - - // Add global type mapper for ProxyTag compound type in Postgres - NpgsqlConnection.GlobalTypeMapper.MapComposite("proxy_tag"); - } - - public static JsonSerializerSettings BuildSerializerSettings() => new JsonSerializerSettings().BuildSerializerSettings(); - - public static JsonSerializerSettings BuildSerializerSettings(this JsonSerializerSettings settings) - { - settings.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); - return settings; - } - } - - public class UlongEncodeAsLongHandler : SqlMapper.TypeHandler - { - public override ulong Parse(object value) - { - // Cast to long to unbox, then to ulong (???) - return (ulong)(long)value; - } - - public override void SetValue(IDbDataParameter parameter, ulong value) - { - parameter.Value = (long)value; - } - } - - public class PassthroughTypeHandler : SqlMapper.TypeHandler - { - public override void SetValue(IDbDataParameter parameter, T value) - { - parameter.Value = value; - } - - public override T Parse(object value) - { - return (T) value; - } - } - - public class DbConnectionFactory - { - private CoreConfig _config; - private ILogger _logger; - private IMetrics _metrics; - private DbConnectionCountHolder _countHolder; - - public DbConnectionFactory(CoreConfig config, DbConnectionCountHolder countHolder, ILogger logger, IMetrics metrics) - { - _config = config; - _countHolder = countHolder; - _metrics = metrics; - _logger = logger; - } - - public async Task Obtain() - { - // Mark the request (for a handle, I guess) in the metrics - _metrics.Measure.Meter.Mark(CoreMetrics.DatabaseRequests); - - // Actually create and try to open the connection - var conn = new NpgsqlConnection(_config.Database); - await conn.OpenAsync(); - - // Increment the count - _countHolder.Increment(); - // Return a wrapped connection which will decrement the counter on dispose - return new PerformanceTrackingConnection(conn, _countHolder, _logger, _metrics); - } - } - - public class DbConnectionCountHolder - { - private int _connectionCount; - public int ConnectionCount => _connectionCount; - - public void Increment() - { - Interlocked.Increment(ref _connectionCount); - } - - public void Decrement() - { - Interlocked.Decrement(ref _connectionCount); - } - } - - public class PerformanceTrackingConnection: IDbConnection - { - // Simple delegation of everything. - internal NpgsqlConnection _impl; - - private DbConnectionCountHolder _countHolder; - private ILogger _logger; - private IMetrics _metrics; - - public PerformanceTrackingConnection(NpgsqlConnection impl, DbConnectionCountHolder countHolder, - ILogger logger, IMetrics metrics) - { - _impl = impl; - _countHolder = countHolder; - _logger = logger; - _metrics = metrics; - } - - public void Dispose() - { - _impl.Dispose(); - - _countHolder.Decrement(); - } - - public IDbTransaction BeginTransaction() - { - return _impl.BeginTransaction(); - } - - public IDbTransaction BeginTransaction(IsolationLevel il) - { - return _impl.BeginTransaction(il); - } - - public void ChangeDatabase(string databaseName) - { - _impl.ChangeDatabase(databaseName); - } - - public void Close() - { - _impl.Close(); - } - - public IDbCommand CreateCommand() - { - return new PerformanceTrackingCommand(_impl.CreateCommand(), _logger, _metrics); - } - - public void Open() - { - _impl.Open(); - } - - public NpgsqlBinaryImporter BeginBinaryImport(string copyFromCommand) - { - return _impl.BeginBinaryImport(copyFromCommand); - } - - public string ConnectionString - { - get => _impl.ConnectionString; - set => _impl.ConnectionString = value; - } - - public int ConnectionTimeout => _impl.ConnectionTimeout; - - public string Database => _impl.Database; - - public ConnectionState State => _impl.State; - } - - public class PerformanceTrackingCommand : DbCommand - { - private NpgsqlCommand _impl; - private ILogger _logger; - private IMetrics _metrics; - - public PerformanceTrackingCommand(NpgsqlCommand impl, ILogger logger, IMetrics metrics) - { - _impl = impl; - _metrics = metrics; - _logger = logger; - } - - public override void Cancel() - { - _impl.Cancel(); - } - - public override int ExecuteNonQuery() - { - return _impl.ExecuteNonQuery(); - } - - public override object ExecuteScalar() - { - return _impl.ExecuteScalar(); - } - - public override void Prepare() - { - _impl.Prepare(); - } - - public override string CommandText - { - get => _impl.CommandText; - set => _impl.CommandText = value; - } - - public override int CommandTimeout - { - get => _impl.CommandTimeout; - set => _impl.CommandTimeout = value; - } - - public override CommandType CommandType - { - get => _impl.CommandType; - set => _impl.CommandType = value; - } - - public override UpdateRowSource UpdatedRowSource - { - get => _impl.UpdatedRowSource; - set => _impl.UpdatedRowSource = value; - } - - protected override DbConnection DbConnection - { - get => _impl.Connection; - set => _impl.Connection = (NpgsqlConnection) value; - } - - protected override DbParameterCollection DbParameterCollection => _impl.Parameters; - - protected override DbTransaction DbTransaction - { - get => _impl.Transaction; - set => _impl.Transaction = (NpgsqlTransaction) value; - } - - public override bool DesignTimeVisible - { - get => _impl.DesignTimeVisible; - set => _impl.DesignTimeVisible = value; - } - - protected override DbParameter CreateDbParameter() - { - return _impl.CreateParameter(); - } - - protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) - { - return _impl.ExecuteReader(behavior); - } - - private IDisposable LogQuery() - { - return new QueryLogger(_logger, _metrics, CommandText); - } - - protected override async Task ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken) - { - using (LogQuery()) - return await _impl.ExecuteReaderAsync(behavior, cancellationToken); - } - - public override async Task ExecuteNonQueryAsync(CancellationToken cancellationToken) - { - using (LogQuery()) - return await _impl.ExecuteNonQueryAsync(cancellationToken); - } - - public override async Task ExecuteScalarAsync(CancellationToken cancellationToken) - { - using (LogQuery()) - return await _impl.ExecuteScalarAsync(cancellationToken); - } - } - - public class QueryLogger : IDisposable - { - private ILogger _logger; - private IMetrics _metrics; - private string _commandText; - private Stopwatch _stopwatch; - - public QueryLogger(ILogger logger, IMetrics metrics, string commandText) - { - _metrics = metrics; - _commandText = commandText; - _logger = logger; - - _stopwatch = new Stopwatch(); - _stopwatch.Start(); - } - - public void Dispose() - { - _stopwatch.Stop(); - _logger.Verbose("Executed query {Query} in {ElapsedTime}", _commandText, _stopwatch.Elapsed); - - // One tick is 100 nanoseconds - _metrics.Provider.Timer.Instance(CoreMetrics.DatabaseQuery, new MetricTags("query", _commandText)) - .Record(_stopwatch.ElapsedTicks / 10, TimeUnit.Microseconds, _commandText); - } - } - - public static class ConnectionUtils - { - public static async IAsyncEnumerable QueryStreamAsync(this DbConnectionFactory connFactory, string sql, object param) - { - using var conn = await connFactory.Obtain(); - - await using var reader = (DbDataReader) await conn.ExecuteReaderAsync(sql, param); - var parser = reader.GetRowParser(); - while (reader.Read()) - yield return parser(reader); - } - - public static async IAsyncEnumerable QueryStreamAsync(this IDbConnection conn, string sql, object param) - { - await using var reader = (DbDataReader) await conn.ExecuteReaderAsync(sql, param); - var parser = reader.GetRowParser(); - while (reader.Read()) - yield return parser(reader); - } - } -} diff --git a/PluralKit.Core/Utils/ConnectionUtils.cs b/PluralKit.Core/Utils/ConnectionUtils.cs new file mode 100644 index 00000000..544a8070 --- /dev/null +++ b/PluralKit.Core/Utils/ConnectionUtils.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Data; +using System.Data.Common; + +using Dapper; + +namespace PluralKit.Core { + public static class ConnectionUtils + { + public static async IAsyncEnumerable QueryStreamAsync(this DbConnectionFactory connFactory, string sql, object param) + { + using var conn = await connFactory.Obtain(); + + await using var reader = (DbDataReader) await conn.ExecuteReaderAsync(sql, param); + var parser = reader.GetRowParser(); + while (reader.Read()) + yield return parser(reader); + } + + public static async IAsyncEnumerable QueryStreamAsync(this IDbConnection conn, string sql, object param) + { + await using var reader = (DbDataReader) await conn.ExecuteReaderAsync(sql, param); + var parser = reader.GetRowParser(); + while (reader.Read()) + yield return parser(reader); + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Utils/DatabaseUtils.cs b/PluralKit.Core/Utils/DatabaseUtils.cs new file mode 100644 index 00000000..172dbce3 --- /dev/null +++ b/PluralKit.Core/Utils/DatabaseUtils.cs @@ -0,0 +1,305 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +using App.Metrics; + +using Dapper; + +using Npgsql; + +using Serilog; + +namespace PluralKit.Core +{ + public class QueryLogger : IDisposable + { + private ILogger _logger; + private IMetrics _metrics; + private string _commandText; + private Stopwatch _stopwatch; + + public QueryLogger(ILogger logger, IMetrics metrics, string commandText) + { + _metrics = metrics; + _commandText = commandText; + _logger = logger; + + _stopwatch = new Stopwatch(); + _stopwatch.Start(); + } + + public void Dispose() + { + _stopwatch.Stop(); + _logger.Verbose("Executed query {Query} in {ElapsedTime}", _commandText, _stopwatch.Elapsed); + + // One tick is 100 nanoseconds + _metrics.Provider.Timer.Instance(CoreMetrics.DatabaseQuery, new MetricTags("query", _commandText)) + .Record(_stopwatch.ElapsedTicks / 10, TimeUnit.Microseconds, _commandText); + } + } + + public class PerformanceTrackingCommand: DbCommand + { + private NpgsqlCommand _impl; + private ILogger _logger; + private IMetrics _metrics; + + public PerformanceTrackingCommand(NpgsqlCommand impl, ILogger logger, IMetrics metrics) + { + _impl = impl; + _metrics = metrics; + _logger = logger; + } + + public override void Cancel() + { + _impl.Cancel(); + } + + public override int ExecuteNonQuery() + { + return _impl.ExecuteNonQuery(); + } + + public override object ExecuteScalar() + { + return _impl.ExecuteScalar(); + } + + public override void Prepare() + { + _impl.Prepare(); + } + + public override string CommandText + { + get => _impl.CommandText; + set => _impl.CommandText = value; + } + + public override int CommandTimeout + { + get => _impl.CommandTimeout; + set => _impl.CommandTimeout = value; + } + + public override CommandType CommandType + { + get => _impl.CommandType; + set => _impl.CommandType = value; + } + + public override UpdateRowSource UpdatedRowSource + { + get => _impl.UpdatedRowSource; + set => _impl.UpdatedRowSource = value; + } + + protected override DbConnection DbConnection + { + get => _impl.Connection; + set => _impl.Connection = (NpgsqlConnection) value; + } + + protected override DbParameterCollection DbParameterCollection => _impl.Parameters; + + protected override DbTransaction DbTransaction + { + get => _impl.Transaction; + set => _impl.Transaction = (NpgsqlTransaction) value; + } + + public override bool DesignTimeVisible + { + get => _impl.DesignTimeVisible; + set => _impl.DesignTimeVisible = value; + } + + protected override DbParameter CreateDbParameter() + { + return _impl.CreateParameter(); + } + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + return _impl.ExecuteReader(behavior); + } + + private IDisposable LogQuery() + { + return new QueryLogger(_logger, _metrics, CommandText); + } + + protected override async Task ExecuteDbDataReaderAsync( + CommandBehavior behavior, CancellationToken cancellationToken) + { + using (LogQuery()) + return await _impl.ExecuteReaderAsync(behavior, cancellationToken); + } + + public override async Task ExecuteNonQueryAsync(CancellationToken cancellationToken) + { + using (LogQuery()) + return await _impl.ExecuteNonQueryAsync(cancellationToken); + } + + public override async Task ExecuteScalarAsync(CancellationToken cancellationToken) + { + using (LogQuery()) + return await _impl.ExecuteScalarAsync(cancellationToken); + } + } + + public class PerformanceTrackingConnection: IDbConnection + { + // Simple delegation of everything. + internal NpgsqlConnection _impl; + + private DbConnectionCountHolder _countHolder; + private ILogger _logger; + private IMetrics _metrics; + + public PerformanceTrackingConnection(NpgsqlConnection impl, DbConnectionCountHolder countHolder, + ILogger logger, IMetrics metrics) + { + _impl = impl; + _countHolder = countHolder; + _logger = logger; + _metrics = metrics; + } + + public void Dispose() + { + _impl.Dispose(); + + _countHolder.Decrement(); + } + + public IDbTransaction BeginTransaction() + { + return _impl.BeginTransaction(); + } + + public IDbTransaction BeginTransaction(IsolationLevel il) + { + return _impl.BeginTransaction(il); + } + + public void ChangeDatabase(string databaseName) + { + _impl.ChangeDatabase(databaseName); + } + + public void Close() + { + _impl.Close(); + } + + public IDbCommand CreateCommand() + { + return new PerformanceTrackingCommand(_impl.CreateCommand(), _logger, _metrics); + } + + public void Open() + { + _impl.Open(); + } + + public NpgsqlBinaryImporter BeginBinaryImport(string copyFromCommand) + { + return _impl.BeginBinaryImport(copyFromCommand); + } + + public string ConnectionString + { + get => _impl.ConnectionString; + set => _impl.ConnectionString = value; + } + + public int ConnectionTimeout => _impl.ConnectionTimeout; + + public string Database => _impl.Database; + + public ConnectionState State => _impl.State; + } + + public class DbConnectionCountHolder + { + private int _connectionCount; + public int ConnectionCount => _connectionCount; + + public void Increment() + { + Interlocked.Increment(ref _connectionCount); + } + + public void Decrement() + { + Interlocked.Decrement(ref _connectionCount); + } + } + + public class DbConnectionFactory + { + private CoreConfig _config; + private ILogger _logger; + private IMetrics _metrics; + private DbConnectionCountHolder _countHolder; + + public DbConnectionFactory(CoreConfig config, DbConnectionCountHolder countHolder, ILogger logger, + IMetrics metrics) + { + _config = config; + _countHolder = countHolder; + _metrics = metrics; + _logger = logger; + } + + public async Task Obtain() + { + // Mark the request (for a handle, I guess) in the metrics + _metrics.Measure.Meter.Mark(CoreMetrics.DatabaseRequests); + + // Actually create and try to open the connection + var conn = new NpgsqlConnection(_config.Database); + await conn.OpenAsync(); + + // Increment the count + _countHolder.Increment(); + // Return a wrapped connection which will decrement the counter on dispose + return new PerformanceTrackingConnection(conn, _countHolder, _logger, _metrics); + } + } + + public class PassthroughTypeHandler: SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, T value) + { + parameter.Value = value; + } + + public override T Parse(object value) + { + return (T) value; + } + } + + public class UlongEncodeAsLongHandler: SqlMapper.TypeHandler + { + public override ulong Parse(object value) + { + // Cast to long to unbox, then to ulong (???) + return (ulong) (long) value; + } + + public override void SetValue(IDbDataParameter parameter, ulong value) + { + parameter.Value = (long) value; + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Utils/DateTimeFormats.cs b/PluralKit.Core/Utils/DateTimeFormats.cs new file mode 100644 index 00000000..12983238 --- /dev/null +++ b/PluralKit.Core/Utils/DateTimeFormats.cs @@ -0,0 +1,24 @@ +using NodaTime; +using NodaTime.Text; + +namespace PluralKit.Core { + public static class DateTimeFormats + { + public static IPattern TimestampExportFormat = InstantPattern.ExtendedIso; + public static IPattern DateExportFormat = LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd"); + + // We create a composite pattern that only shows the two most significant things + // eg. if we have something with nonzero day component, we show d h, but if it's + // a smaller duration we may only bother with showing h m or m s + public static IPattern DurationFormat = new CompositePatternBuilder + { + {DurationPattern.CreateWithInvariantCulture("s's'"), d => true}, + {DurationPattern.CreateWithInvariantCulture("m'm' s's'"), d => d.Minutes > 0}, + {DurationPattern.CreateWithInvariantCulture("H'h' m'm'"), d => d.Hours > 0}, + {DurationPattern.CreateWithInvariantCulture("D'd' h'h'"), d => d.Days > 0} + }.Build(); + + public static IPattern LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss"); + public static IPattern ZonedDateTimeFormat = ZonedDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss x", DateTimeZoneProviders.Tzdb); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Utils/DateUtils.cs b/PluralKit.Core/Utils/DateUtils.cs new file mode 100644 index 00000000..0f917f1a --- /dev/null +++ b/PluralKit.Core/Utils/DateUtils.cs @@ -0,0 +1,174 @@ +using System.Linq; +using System.Text.RegularExpressions; + +using NodaTime; +using NodaTime.Text; + +namespace PluralKit.Core +{ + public class DateUtils + { + public static Duration? ParsePeriod(string str) + { + Duration d = Duration.Zero; + + foreach (Match match in Regex.Matches(str, "(\\d{1,6})(\\w)")) + { + var amount = int.Parse(match.Groups[1].Value); + var type = match.Groups[2].Value; + + if (type == "w") d += Duration.FromDays(7) * amount; + else if (type == "d") d += Duration.FromDays(1) * amount; + else if (type == "h") d += Duration.FromHours(1) * amount; + else if (type == "m") d += Duration.FromMinutes(1) * amount; + else if (type == "s") d += Duration.FromSeconds(1) * amount; + else return null; + } + + if (d == Duration.Zero) return null; + return d; + } + + public static LocalDate? ParseDate(string str, bool allowNullYear = false) + { + // NodaTime can't parse constructs like "1st" and "2nd" so we quietly replace those away + // Gotta make sure to do the regex otherwise we'll catch things like the "st" in "August" too + str = Regex.Replace(str, "(\\d+)(st|nd|rd|th)", "$1"); + + var patterns = new[] + { + "MMM d yyyy", // Jan 1 2019 + "MMM d, yyyy", // Jan 1, 2019 + "MMMM d yyyy", // January 1 2019 + "MMMM d, yyyy", // January 1, 2019 + "yyyy-MM-dd", // 2019-01-01 + "yyyy MM dd", // 2019 01 01 + "yyyy/MM/dd" // 2019/01/01 + }.ToList(); + + if (allowNullYear) patterns.AddRange(new[] + { + "MMM d", // Jan 1 + "MMMM d", // January 1 + "MM-dd", // 01-01 + "MM dd", // 01 01 + "MM/dd" // 01/01 + }); + + // Giving a template value so year will be parsed as 0004 if not present + // This means we can later disambiguate whether a null year was given + // We use the basis year 0004 (rather than, say, 0001) because 0004 is a leap year in the Gregorian calendar + // which means the date "Feb 29, 0004" is a valid date. 0001 is still accepted as a null year for legacy reasons. + // TODO: should we be using invariant culture here? + foreach (var pattern in patterns.Select(p => LocalDatePattern.CreateWithInvariantCulture(p).WithTemplateValue(new LocalDate(0004, 1, 1)))) + { + var result = pattern.Parse(str); + if (result.Success) return result.Value; + } + + return null; + } + + public static ZonedDateTime? ParseDateTime(string str, bool nudgeToPast = false, DateTimeZone zone = null) + { + if (zone == null) zone = DateTimeZone.Utc; + + // Find the current timestamp in the given zone, find the (naive) midnight timestamp, then put that into the same zone (and make it naive again) + // Should yield a 12:00:00 AM. + var now = SystemClock.Instance.GetCurrentInstant().InZone(zone).LocalDateTime; + var midnight = now.Date.AtMidnight(); + + // First we try to parse the string as a relative time using the period parser + var relResult = ParsePeriod(str); + if (relResult != null) + { + // if we can, we just subtract that amount from the + return now.InZoneLeniently(zone).Minus(relResult.Value); + } + + var timePatterns = new[] + { + "H:mm", // 4:30 + "HH:mm", // 23:30 + "H:mm:ss", // 4:30:29 + "HH:mm:ss", // 23:30:29 + "h tt", // 2 PM + "htt", // 2PM + "h:mm tt", // 4:30 PM + "h:mmtt", // 4:30PM + "h:mm:ss tt", // 4:30:29 PM + "h:mm:sstt", // 4:30:29PM + "hh:mm tt", // 11:30 PM + "hh:mmtt", // 11:30PM + "hh:mm:ss tt", // 11:30:29 PM + "hh:mm:sstt" // 11:30:29PM + }; + + var datePatterns = new[] + { + "MMM d yyyy", // Jan 1 2019 + "MMM d, yyyy", // Jan 1, 2019 + "MMMM d yyyy", // January 1 2019 + "MMMM d, yyyy", // January 1, 2019 + "yyyy-MM-dd", // 2019-01-01 + "yyyy MM dd", // 2019 01 01 + "yyyy/MM/dd", // 2019/01/01 + "MMM d", // Jan 1 + "MMMM d", // January 1 + "MM-dd", // 01-01 + "MM dd", // 01 01 + "MM/dd" // 01-01 + }; + + // First, we try all the timestamps that only have a time + foreach (var timePattern in timePatterns) + { + var pat = LocalDateTimePattern.CreateWithInvariantCulture(timePattern).WithTemplateValue(midnight); + var result = pat.Parse(str); + if (result.Success) + { + // If we have a successful match and we need a time in the past, we try to shove a future-time a date before + // Example: "4:30 pm" at 3:30 pm likely refers to 4:30 pm the previous day + var val = result.Value; + + // If we need to nudge, we just subtract a day. This only occurs when we're parsing specifically *just time*, so + // we know we won't nudge it by more than a day since we use today's midnight timestamp as a date template. + + // Since this is a naive datetime, this ensures we're actually moving by one calendar day even if + // DST changes occur, since they'll be resolved later wrt. the right side of the boundary + if (val > now && nudgeToPast) val = val.PlusDays(-1); + return val.InZoneLeniently(zone); + } + } + + // Then we try specific date+time combinations, both date first and time first, with and without commas + foreach (var timePattern in timePatterns) + { + foreach (var datePattern in datePatterns) + { + foreach (var patternStr in new[] + { + $"{timePattern}, {datePattern}", $"{datePattern}, {timePattern}", + $"{timePattern} {datePattern}", $"{datePattern} {timePattern}" + }) + { + var pattern = LocalDateTimePattern.CreateWithInvariantCulture(patternStr).WithTemplateValue(midnight); + var res = pattern.Parse(str); + if (res.Success) return res.Value.InZoneLeniently(zone); + } + } + } + + // Finally, just date patterns, still using midnight as the template + foreach (var datePattern in datePatterns) + { + var pat = LocalDateTimePattern.CreateWithInvariantCulture(datePattern).WithTemplateValue(midnight); + var res = pat.Parse(str); + if (res.Success) return res.Value.InZoneLeniently(zone); + } + + // Still haven't parsed something, we just give up lmao + return null; + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Utils/Emojis.cs b/PluralKit.Core/Utils/Emojis.cs new file mode 100644 index 00000000..21acf293 --- /dev/null +++ b/PluralKit.Core/Utils/Emojis.cs @@ -0,0 +1,11 @@ +namespace PluralKit.Core { + public static class Emojis { + public static readonly string Warn = "\u26A0"; + public static readonly string Success = "\u2705"; + public static readonly string Error = "\u274C"; + public static readonly string Note = "\U0001f4dd"; + public static readonly string ThumbsUp = "\U0001f44d"; + public static readonly string RedQuestion = "\u2753"; + public static readonly string Bell = "\U0001F514"; + } +} \ No newline at end of file diff --git a/PluralKit.Core/Utils/InitUtils.cs b/PluralKit.Core/Utils/InitUtils.cs new file mode 100644 index 00000000..38b71197 --- /dev/null +++ b/PluralKit.Core/Utils/InitUtils.cs @@ -0,0 +1,57 @@ +using System.IO; + +using Dapper; + +using Microsoft.Extensions.Configuration; + +using Newtonsoft.Json; + +using NodaTime; +using NodaTime.Serialization.JsonNet; + +using Npgsql; + +namespace PluralKit.Core { + public static class InitUtils + { + public static IConfigurationBuilder BuildConfiguration(string[] args) => new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("pluralkit.conf", true) + .AddEnvironmentVariables() + .AddCommandLine(args); + + public static void Init() + { + InitDatabase(); + } + + private static void InitDatabase() + { + // Dapper by default tries to pass ulongs to Npgsql, which rejects them since PostgreSQL technically + // doesn't support unsigned types on its own. + // Instead we add a custom mapper to encode them as signed integers instead, converting them back and forth. + SqlMapper.RemoveTypeMap(typeof(ulong)); + SqlMapper.AddTypeHandler(new UlongEncodeAsLongHandler()); + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + + // Also, use NodaTime. it's good. + NpgsqlConnection.GlobalTypeMapper.UseNodaTime(); + // With the thing we add above, Npgsql already handles NodaTime integration + // This makes Dapper confused since it thinks it has to convert it anyway and doesn't understand the types + // So we add a custom type handler that literally just passes the type through to Npgsql + SqlMapper.AddTypeHandler(new PassthroughTypeHandler()); + SqlMapper.AddTypeHandler(new PassthroughTypeHandler()); + + // Add global type mapper for ProxyTag compound type in Postgres + NpgsqlConnection.GlobalTypeMapper.MapComposite("proxy_tag"); + } + + public static JsonSerializerSettings BuildSerializerSettings() => new JsonSerializerSettings().BuildSerializerSettings(); + + public static JsonSerializerSettings BuildSerializerSettings(this JsonSerializerSettings settings) + { + settings.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); + return settings; + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Limits.cs b/PluralKit.Core/Utils/Limits.cs similarity index 100% rename from PluralKit.Core/Limits.cs rename to PluralKit.Core/Utils/Limits.cs diff --git a/PluralKit.Core/Utils/StringUtils.cs b/PluralKit.Core/Utils/StringUtils.cs new file mode 100644 index 00000000..c6878c82 --- /dev/null +++ b/PluralKit.Core/Utils/StringUtils.cs @@ -0,0 +1,57 @@ +using System; +using System.Security.Cryptography; + +namespace PluralKit.Core +{ + public static class StringUtils + { + public static string GenerateHid() + { + var rnd = new Random(); + var charset = "abcdefghijklmnopqrstuvwxyz"; + string hid = ""; + for (int i = 0; i < 5; i++) + { + hid += charset[rnd.Next(charset.Length)]; + } + return hid; + } + + public static string GenerateToken() + { + var buf = new byte[48]; // Results in a 64-byte Base64 string (no padding) + new RNGCryptoServiceProvider().GetBytes(buf); + return Convert.ToBase64String(buf); + } + + public static bool IsLongerThan(this string str, int length) + { + if (str != null) return str.Length > length; + return false; + } + + public static string ExtractCountryFlag(string flag) + { + if (flag.Length != 4) return null; + try + { + var cp1 = char.ConvertToUtf32(flag, 0); + var cp2 = char.ConvertToUtf32(flag, 2); + if (cp1 < 0x1F1E6 || cp1 > 0x1F1FF) return null; + if (cp2 < 0x1F1E6 || cp2 > 0x1F1FF) return null; + return $"{(char) (cp1 - 0x1F1E6 + 'A')}{(char) (cp2 - 0x1F1E6 + 'A')}"; + } + catch (ArgumentException) + { + return null; + } + } + + public static string NullIfEmpty(this string input) + { + if (input == null) return null; + if (input.Trim().Length == 0) return null; + return input; + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/TaskUtils.cs b/PluralKit.Core/Utils/TaskUtils.cs similarity index 97% rename from PluralKit.Core/TaskUtils.cs rename to PluralKit.Core/Utils/TaskUtils.cs index f48e7d73..9ae836bd 100644 --- a/PluralKit.Core/TaskUtils.cs +++ b/PluralKit.Core/Utils/TaskUtils.cs @@ -2,7 +2,7 @@ using System; using System.Threading; using System.Threading.Tasks; -namespace PluralKit { +namespace PluralKit.Core { public static class TaskUtils { public static async Task CatchException(this Task task, Action handler) { try {