Large refactor and project restructuring
This commit is contained in:
parent
c10e197c39
commit
6d5004bf54
@ -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")]
|
||||
|
@ -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<string>("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);
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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);
|
||||
}
|
||||
|
4
PluralKit.API/PluralKit.API.csproj.DotSettings
Normal file
4
PluralKit.API/PluralKit.API.csproj.DotSettings
Normal file
@ -0,0 +1,4 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=controllers/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=utils/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
@ -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
|
||||
|
@ -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
|
107
PluralKit.API/Utils/JsonModelExt.cs
Normal file
107
PluralKit.API/Utils/JsonModelExt.cs
Normal file
@ -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<string>("name").NullIfEmpty().BoundsCheckField(Limits.MaxSystemNameLength, "System name");
|
||||
if (o.ContainsKey("description")) system.Description = o.Value<string>("description").NullIfEmpty().BoundsCheckField(Limits.MaxDescriptionLength, "System description");
|
||||
if (o.ContainsKey("tag")) system.Tag = o.Value<string>("tag").NullIfEmpty().BoundsCheckField(Limits.MaxSystemTagLength, "System tag");
|
||||
if (o.ContainsKey("avatar_url")) system.AvatarUrl = o.Value<string>("avatar_url").NullIfEmpty();
|
||||
if (o.ContainsKey("tz")) system.UiTz = o.Value<string>("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<string>("name").BoundsCheckField(Limits.MaxMemberNameLength, "Member name");
|
||||
if (o.ContainsKey("color")) member.Color = o.Value<string>("color").NullIfEmpty();
|
||||
if (o.ContainsKey("display_name")) member.DisplayName = o.Value<string>("display_name").NullIfEmpty().BoundsCheckField(Limits.MaxMemberNameLength, "Member display name");
|
||||
if (o.ContainsKey("birthday"))
|
||||
{
|
||||
var str = o.Value<string>("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<string>("pronouns").NullIfEmpty().BoundsCheckField(Limits.MaxPronounsLength, "Member pronouns");
|
||||
if (o.ContainsKey("description")) member.Description = o.Value<string>("description").NullIfEmpty().BoundsCheckField(Limits.MaxDescriptionLength, "Member descriptoin");
|
||||
if (o.ContainsKey("keep_proxy")) member.KeepProxy = o.Value<bool>("keep_proxy");
|
||||
|
||||
if (o.ContainsKey("prefix") || o.ContainsKey("suffix") && !o.ContainsKey("proxy_tags"))
|
||||
member.ProxyTags = new[] {new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix"))};
|
||||
else if (o.ContainsKey("proxy_tags"))
|
||||
{
|
||||
member.ProxyTags = o.Value<JArray>("proxy_tags")
|
||||
.OfType<JObject>().Select(o => new ProxyTag(o.Value<string>("prefix"), o.Value<string>("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) { }
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
using App.Metrics;
|
||||
using App.Metrics.Gauge;
|
||||
using App.Metrics.Histogram;
|
||||
using App.Metrics.Meter;
|
||||
using App.Metrics.Timer;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
namespace PluralKit.Bot.CommandSystem
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
|
@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PluralKit.Bot.CommandSystem
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public class CommandGroup
|
||||
{
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PluralKit.Bot.CommandSystem
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public class Parameters
|
||||
{
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -1,8 +1,6 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using PluralKit.Bot.CommandSystem;
|
||||
|
||||
namespace PluralKit.Bot.Commands
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public class Fun
|
||||
{
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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?");
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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<DateTimeZone> 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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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<string> MakeAndSetNewToken(PKSystem system)
|
||||
{
|
||||
system.Token = PluralKit.Utils.GenerateToken();
|
||||
system.Token = Core.StringUtils.GenerateToken();
|
||||
await _data.SaveSystem(system);
|
||||
return system.Token;
|
||||
}
|
||||
|
@ -7,6 +7,27 @@ using NodaTime;
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot {
|
||||
/// <summary>
|
||||
/// An exception class representing user-facing errors caused when parsing and executing commands.
|
||||
/// </summary>
|
||||
public class PKError : Exception
|
||||
{
|
||||
public PKError(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A subclass of <see cref="PKError"/> that represent command syntax errors, meaning they'll have their command
|
||||
/// usages printed in the message.
|
||||
/// </summary>
|
||||
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'.");
|
||||
|
@ -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<Misc>().AsSelf();
|
||||
builder.RegisterType<ServerConfig>().AsSelf();
|
||||
builder.RegisterType<Switch>().AsSelf();
|
||||
builder.RegisterType<Commands.System>().AsSelf();
|
||||
builder.RegisterType<System>().AsSelf();
|
||||
builder.RegisterType<SystemEdit>().AsSelf();
|
||||
builder.RegisterType<SystemFront>().AsSelf();
|
||||
builder.RegisterType<SystemLink>().AsSelf();
|
||||
|
@ -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}" ))}");
|
||||
}
|
||||
}
|
||||
}
|
5
PluralKit.Bot/PluralKit.Bot.csproj.DotSettings
Normal file
5
PluralKit.Bot/PluralKit.Bot.csproj.DotSettings
Normal file
@ -0,0 +1,5 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=commands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=commandsystem/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=utils/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
|
||||
using Discord;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
using Serilog;
|
||||
|
||||
namespace PluralKit.Bot {
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
@ -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<ChannelPermissions> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An exception class representing user-facing errors caused when parsing and executing commands.
|
||||
/// </summary>
|
||||
public class PKError : Exception
|
||||
{
|
||||
public PKError(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A subclass of <see cref="PKError"/> that represent command syntax errors, meaning they'll have their command
|
||||
/// usages printed in the message.
|
||||
/// </summary>
|
||||
public class PKSyntaxError : PKError
|
||||
{
|
||||
public PKSyntaxError(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
55
PluralKit.Bot/Utils/AvatarUtils.cs
Normal file
55
PluralKit.Bot/Utils/AvatarUtils.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
32
PluralKit.Bot/Utils/DiscordUtils.cs
Normal file
32
PluralKit.Bot/Utils/DiscordUtils.cs
Normal file
@ -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<ChannelPermissions> 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<bool> HasPermission(this IChannel channel, ChannelPermission permission) =>
|
||||
(await PermissionsIn(channel)).Has(permission);
|
||||
}
|
||||
}
|
33
PluralKit.Bot/Utils/MiscUtils.cs
Normal file
33
PluralKit.Bot/Utils/MiscUtils.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
52
PluralKit.Bot/Utils/StringUtils.cs
Normal file
52
PluralKit.Bot/Utils/StringUtils.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
namespace PluralKit
|
||||
namespace PluralKit.Core
|
||||
{
|
||||
public class CoreConfig
|
||||
{
|
||||
|
@ -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<string>("name").NullIfEmpty().BoundsCheck(Limits.MaxSystemNameLength, "System name");
|
||||
if (o.ContainsKey("description")) Description = o.Value<string>("description").NullIfEmpty().BoundsCheck(Limits.MaxDescriptionLength, "System description");
|
||||
if (o.ContainsKey("tag")) Tag = o.Value<string>("tag").NullIfEmpty().BoundsCheck(Limits.MaxSystemTagLength, "System tag");
|
||||
if (o.ContainsKey("avatar_url")) AvatarUrl = o.Value<string>("avatar_url").NullIfEmpty();
|
||||
if (o.ContainsKey("tz")) UiTz = o.Value<string>("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<ProxyTag> 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<string>("name").BoundsCheck(Limits.MaxMemberNameLength, "Member name");
|
||||
if (o.ContainsKey("color")) Color = o.Value<string>("color").NullIfEmpty();
|
||||
if (o.ContainsKey("display_name")) DisplayName = o.Value<string>("display_name").NullIfEmpty().BoundsCheck(Limits.MaxMemberNameLength, "Member display name");
|
||||
if (o.ContainsKey("birthday"))
|
||||
{
|
||||
var str = o.Value<string>("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<string>("pronouns").NullIfEmpty().BoundsCheck(Limits.MaxPronounsLength, "Member pronouns");
|
||||
if (o.ContainsKey("description")) Description = o.Value<string>("description").NullIfEmpty().BoundsCheck(Limits.MaxDescriptionLength, "Member descriptoin");
|
||||
if (o.ContainsKey("keep_proxy")) KeepProxy = o.Value<bool>("keep_proxy");
|
||||
|
||||
if (o.ContainsKey("prefix") || o.ContainsKey("suffix") && !o.ContainsKey("proxy_tags"))
|
||||
ProxyTags = new[] {new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix"))};
|
||||
else if (o.ContainsKey("proxy_tags"))
|
||||
{
|
||||
ProxyTags = o.Value<JArray>("proxy_tags")
|
||||
.OfType<JObject>().Select(o => new ProxyTag(o.Value<string>("prefix"), o.Value<string>("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; }
|
||||
}
|
||||
}
|
79
PluralKit.Core/Models/PKMember.cs
Normal file
79
PluralKit.Core/Models/PKMember.cs
Normal file
@ -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<ProxyTag> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
17
PluralKit.Core/Models/PKSwitch.cs
Normal file
17
PluralKit.Core/Models/PKSwitch.cs
Normal file
@ -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; }
|
||||
}
|
||||
}
|
27
PluralKit.Core/Models/PKSystem.cs
Normal file
27
PluralKit.Core/Models/PKSystem.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
21
PluralKit.Core/Models/Privacy.cs
Normal file
21
PluralKit.Core/Models/Privacy.cs
Normal file
@ -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
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
||||
|
4
PluralKit.Core/PluralKit.Core.csproj.DotSettings
Normal file
4
PluralKit.Core/PluralKit.Core.csproj.DotSettings
Normal file
@ -0,0 +1,4 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=migrations/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=models/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
@ -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;
|
||||
}
|
||||
|
422
PluralKit.Core/Services/IDataStore.cs
Normal file
422
PluralKit.Core/Services/IDataStore.cs
Normal file
@ -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<PKMember> Members;
|
||||
}
|
||||
|
||||
public struct SwitchListEntry
|
||||
{
|
||||
public ICollection<PKMember> Members;
|
||||
public Instant TimespanStart;
|
||||
public Instant TimespanEnd;
|
||||
}
|
||||
|
||||
public struct MemberMessageCount
|
||||
{
|
||||
public int Member;
|
||||
public int MessageCount;
|
||||
}
|
||||
|
||||
public struct FrontBreakdown
|
||||
{
|
||||
public Dictionary<PKMember, Duration> 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<ulong> LogBlacklist { get; set; }
|
||||
public ISet<ulong> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a system by its internal system ID.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKSystem"/> with the given internal ID, or null if no system was found.</returns>
|
||||
Task<PKSystem> GetSystemById(int systemId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a system by its user-facing human ID.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKSystem"/> with the given human ID, or null if no system was found.</returns>
|
||||
Task<PKSystem> GetSystemByHid(string systemHid);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a system by one of its linked Discord account IDs. Multiple IDs can return the same system.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKSystem"/> with the given linked account, or null if no system was found.</returns>
|
||||
Task<PKSystem> GetSystemByAccount(ulong linkedAccount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a system by its API token.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKSystem"/> with the given API token, or null if no corresponding system was found.</returns>
|
||||
Task<PKSystem> GetSystemByToken(string apiToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Discord account IDs linked to a system.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable of Discord account IDs linked to this system.</returns>
|
||||
Task<IEnumerable<ulong>> GetSystemAccounts(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the member count of a system.
|
||||
/// </summary>
|
||||
/// <param name="includePrivate">Whether the returned count should include private members.</param>
|
||||
Task<int> GetSystemMemberCount(PKSystem system, bool includePrivate);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="system">The system to check in.</param>
|
||||
Task<IEnumerable<PKMember>> GetConflictingProxies(PKSystem system, ProxyTag tag);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific system's guild-specific settings for a given guild.
|
||||
/// </summary>
|
||||
Task<SystemGuildSettings> GetSystemGuildSettings(PKSystem system, ulong guild);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a specific system's guild-specific settings.
|
||||
/// </summary>
|
||||
Task SetSystemGuildSettings(PKSystem system, ulong guild, SystemGuildSettings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a system, auto-generating its corresponding IDs.
|
||||
/// </summary>
|
||||
/// <param name="systemName">An optional system name to set. If `null`, will not set a system name.</param>
|
||||
/// <returns>The created system model.</returns>
|
||||
Task<PKSystem> CreateSystem(string systemName);
|
||||
// TODO: throw exception if account is present (when adding) or account isn't present (when removing)
|
||||
|
||||
/// <summary>
|
||||
/// Links a Discord account to a system.
|
||||
/// </summary>
|
||||
/// <exception>Throws an exception (TODO: which?) if the given account is already linked to a system.</exception>
|
||||
Task AddAccount(PKSystem system, ulong accountToAdd);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <exception>Throws an exception (TODO: which?) if the given account is not linked to the given system.</exception>
|
||||
Task RemoveAccount(PKSystem system, ulong accountToRemove);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the information within the given <see cref="PKSystem"/> struct to the data store.
|
||||
/// </summary>
|
||||
Task SaveSystem(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the given system from the database.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// This will also delete all the system's members, all system switches, and every message that has been proxied
|
||||
/// by members in the system.
|
||||
/// </para>
|
||||
Task DeleteSystem(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a system by its internal member ID.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKMember"/> with the given internal ID, or null if no member was found.</returns>
|
||||
Task<PKMember> GetMemberById(int memberId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a member by its user-facing human ID.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKMember"/> with the given human ID, or null if no member was found.</returns>
|
||||
Task<PKMember> GetMemberByHid(string memberHid);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a member by its member name within one system.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <returns>The <see cref="PKMember"/> with the given name, or null if no member was found.</returns>
|
||||
Task<PKMember> GetMemberByName(PKSystem system, string name);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all members inside a given system.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable of <see cref="PKMember"/> structs representing each member in the system, in no particular order.</returns>
|
||||
IAsyncEnumerable<PKMember> GetSystemMembers(PKSystem system, bool orderByName = false);
|
||||
/// <summary>
|
||||
/// Gets the amount of messages proxied by a given member.
|
||||
/// </summary>
|
||||
/// <returns>The message count of the given member.</returns>
|
||||
Task<ulong> GetMemberMessageCount(PKMember member);
|
||||
|
||||
/// <summary>
|
||||
/// Collects a breakdown of each member in a system's message count.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable of members along with their message counts.</returns>
|
||||
Task<IEnumerable<MemberMessageCount>> GetMemberMessageCountBulk(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a member, auto-generating its corresponding IDs.
|
||||
/// </summary>
|
||||
/// <param name="system">The system in which to create the member.</param>
|
||||
/// <param name="name">The name of the member to create.</param>
|
||||
/// <returns>The created system model.</returns>
|
||||
Task<PKMember> CreateMember(PKSystem system, string name);
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple members, auto-generating each corresponding ID.
|
||||
/// </summary>
|
||||
/// <param name="system">The system to create the member in.</param>
|
||||
/// <param name="memberNames">A dictionary containing a mapping from an arbitrary key to the member's name.</param>
|
||||
/// <returns>A dictionary containing the resulting member structs, each mapped to the key given in the argument dictionary.</returns>
|
||||
Task<Dictionary<string, PKMember>> CreateMembersBulk(PKSystem system, Dictionary<string, string> memberNames);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the information within the given <see cref="PKMember"/> struct to the data store.
|
||||
/// </summary>
|
||||
Task SaveMember(PKMember member);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the given member from the database.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// This will remove this member from any switches it's involved in, as well as all the messages
|
||||
/// proxied by this member.
|
||||
/// </para>
|
||||
Task DeleteMember(PKMember member);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific member's guild-specific settings for a given guild.
|
||||
/// </summary>
|
||||
Task<MemberGuildSettings> GetMemberGuildSettings(PKMember member, ulong guild);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a specific member's guild-specific settings.
|
||||
/// </summary>
|
||||
Task SetMemberGuildSettings(PKMember member, ulong guild, MemberGuildSettings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a message and its information by its ID.
|
||||
/// </summary>
|
||||
/// <param name="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.</param>
|
||||
/// <returns>An extended message object, containing not only the message data itself but the associated system and member structs.</returns>
|
||||
Task<FullMessage> GetMessage(ulong id); // id is both original and trigger, also add return type struct
|
||||
|
||||
/// <summary>
|
||||
/// Saves a posted message to the database.
|
||||
/// </summary>
|
||||
/// <param name="senderAccount">The ID of the account that sent the original trigger message.</param>
|
||||
/// <param name="guildId">The ID of the guild the message was posted to.</param>
|
||||
/// <param name="channelId">The ID of the channel the message was posted to.</param>
|
||||
/// <param name="postedMessageId">The ID of the message posted by the webhook.</param>
|
||||
/// <param name="triggerMessageId">The ID of the original trigger message containing the proxy tags.</param>
|
||||
/// <param name="proxiedMember">The member (and by extension system) that was proxied.</param>
|
||||
/// <returns></returns>
|
||||
Task AddMessage(ulong senderAccount, ulong guildId, ulong channelId, ulong postedMessageId, ulong triggerMessageId, PKMember proxiedMember);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a message from the data store.
|
||||
/// </summary>
|
||||
/// <param name="postedMessageId">The ID of the webhook message to delete.</param>
|
||||
Task DeleteMessage(ulong postedMessageId);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes messages from the data store in bulk.
|
||||
/// </summary>
|
||||
/// <param name="postedMessageIds">The IDs of the webhook messages to delete.</param>
|
||||
Task DeleteMessagesBulk(IEnumerable<ulong> postedMessageIds);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent message sent by a given account in a given guild.
|
||||
/// </summary>
|
||||
/// <returns>The full message object, or null if none was found.</returns>
|
||||
Task<FullMessage> GetLastMessageInGuild(ulong account, ulong guild);
|
||||
|
||||
/// <summary>
|
||||
/// Gets switches from a system.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable of the *count* latest switches in the system, in latest-first order. May contain fewer elements than requested.</returns>
|
||||
IAsyncEnumerable<PKSwitch> GetSwitches(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of switches in a given system.
|
||||
/// </summary>
|
||||
Task<int> GetSwitchCount(PKSystem system);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest (temporally; closest to now) switch of a given system.
|
||||
/// </summary>
|
||||
Task<PKSwitch> GetLatestSwitch(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the members a given switch consists of.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<PKMember> GetSwitchMembers(PKSwitch sw);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of fronters over a given period of time.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// This list is returned as an enumerable of "switch members", each containing a timestamp
|
||||
/// and a member ID. <seealso cref="GetMemberById"/>
|
||||
///
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <returns>An enumerable of the aforementioned "switch members".</returns>
|
||||
Task<IEnumerable<SwitchListEntry>> GetPeriodFronters(PKSystem system, Instant periodStart, Instant periodEnd);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <param name="system"></param>
|
||||
/// <param name="periodStart"></param>
|
||||
/// <param name="periodEnd"></param>
|
||||
/// <returns></returns>
|
||||
Task<FrontBreakdown> GetFrontBreakdown(PKSystem system, Instant periodStart, Instant periodEnd);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first listed fronter in a system.
|
||||
/// </summary>
|
||||
/// <returns>The first fronter, or null if none are registered.</returns>
|
||||
Task<PKMember> GetFirstFronter(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a switch with the given members in the given system.
|
||||
/// </summary>
|
||||
/// <exception>Throws an exception (TODO: which?) if any of the members are not in the given system.</exception>
|
||||
Task AddSwitch(PKSystem system, IEnumerable<PKMember> switchMembers);
|
||||
|
||||
/// <summary>
|
||||
/// Registers switches in bulk.
|
||||
/// </summary>
|
||||
/// <param name="switches">A list of switch structs, each containing a timestamp and a list of members.</param>
|
||||
/// <exception>Throws an exception (TODO: which?) if any of the given members are not in the given system.</exception>
|
||||
Task AddSwitchesBulk(PKSystem system, IEnumerable<ImportedSwitch> switches);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the timestamp of a given switch.
|
||||
/// </summary>
|
||||
Task MoveSwitch(PKSwitch sw, Instant time);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a given switch from the data store.
|
||||
/// </summary>
|
||||
Task DeleteSwitch(PKSwitch sw);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all switches in a given system from the data store.
|
||||
/// </summary>
|
||||
Task DeleteAllSwitches(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of systems in the data store.
|
||||
/// </summary>
|
||||
Task<ulong> GetTotalSystems();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of members in the data store.
|
||||
/// </summary>
|
||||
Task<ulong> GetTotalMembers();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of switches in the data store.
|
||||
/// </summary>
|
||||
Task<ulong> GetTotalSwitches();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of messages in the data store.
|
||||
/// </summary>
|
||||
Task<ulong> GetTotalMessages();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the guild configuration struct for a given guild, creating and saving one if none was found.
|
||||
/// </summary>
|
||||
/// <returns>The guild's configuration struct.</returns>
|
||||
Task<GuildConfig> GetOrCreateGuildConfig(ulong guild);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the given guild configuration struct to the data store.
|
||||
/// </summary>
|
||||
Task SaveGuildConfig(GuildConfig cfg);
|
||||
|
||||
Task<AuxillaryProxyInformation> GetAuxillaryProxyInformation(ulong guild, PKSystem system, PKMember member);
|
||||
}
|
||||
}
|
@ -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<PKMember> Members;
|
||||
}
|
||||
|
||||
public struct SwitchListEntry
|
||||
{
|
||||
public ICollection<PKMember> Members;
|
||||
public Instant TimespanStart;
|
||||
public Instant TimespanEnd;
|
||||
}
|
||||
|
||||
public struct MemberMessageCount
|
||||
{
|
||||
public int Member;
|
||||
public int MessageCount;
|
||||
}
|
||||
|
||||
public struct FrontBreakdown
|
||||
{
|
||||
public Dictionary<PKMember, Duration> 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<ulong> LogBlacklist { get; set; }
|
||||
public ISet<ulong> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a system by its internal system ID.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKSystem"/> with the given internal ID, or null if no system was found.</returns>
|
||||
Task<PKSystem> GetSystemById(int systemId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a system by its user-facing human ID.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKSystem"/> with the given human ID, or null if no system was found.</returns>
|
||||
Task<PKSystem> GetSystemByHid(string systemHid);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a system by one of its linked Discord account IDs. Multiple IDs can return the same system.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKSystem"/> with the given linked account, or null if no system was found.</returns>
|
||||
Task<PKSystem> GetSystemByAccount(ulong linkedAccount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a system by its API token.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKSystem"/> with the given API token, or null if no corresponding system was found.</returns>
|
||||
Task<PKSystem> GetSystemByToken(string apiToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Discord account IDs linked to a system.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable of Discord account IDs linked to this system.</returns>
|
||||
Task<IEnumerable<ulong>> GetSystemAccounts(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the member count of a system.
|
||||
/// </summary>
|
||||
/// <param name="includePrivate">Whether the returned count should include private members.</param>
|
||||
Task<int> GetSystemMemberCount(PKSystem system, bool includePrivate);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="system">The system to check in.</param>
|
||||
Task<IEnumerable<PKMember>> GetConflictingProxies(PKSystem system, ProxyTag tag);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific system's guild-specific settings for a given guild.
|
||||
/// </summary>
|
||||
Task<SystemGuildSettings> GetSystemGuildSettings(PKSystem system, ulong guild);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a specific system's guild-specific settings.
|
||||
/// </summary>
|
||||
Task SetSystemGuildSettings(PKSystem system, ulong guild, SystemGuildSettings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a system, auto-generating its corresponding IDs.
|
||||
/// </summary>
|
||||
/// <param name="systemName">An optional system name to set. If `null`, will not set a system name.</param>
|
||||
/// <returns>The created system model.</returns>
|
||||
Task<PKSystem> CreateSystem(string systemName);
|
||||
// TODO: throw exception if account is present (when adding) or account isn't present (when removing)
|
||||
|
||||
/// <summary>
|
||||
/// Links a Discord account to a system.
|
||||
/// </summary>
|
||||
/// <exception>Throws an exception (TODO: which?) if the given account is already linked to a system.</exception>
|
||||
Task AddAccount(PKSystem system, ulong accountToAdd);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <exception>Throws an exception (TODO: which?) if the given account is not linked to the given system.</exception>
|
||||
Task RemoveAccount(PKSystem system, ulong accountToRemove);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the information within the given <see cref="PKSystem"/> struct to the data store.
|
||||
/// </summary>
|
||||
Task SaveSystem(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the given system from the database.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// This will also delete all the system's members, all system switches, and every message that has been proxied
|
||||
/// by members in the system.
|
||||
/// </para>
|
||||
Task DeleteSystem(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a system by its internal member ID.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKMember"/> with the given internal ID, or null if no member was found.</returns>
|
||||
Task<PKMember> GetMemberById(int memberId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a member by its user-facing human ID.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="PKMember"/> with the given human ID, or null if no member was found.</returns>
|
||||
Task<PKMember> GetMemberByHid(string memberHid);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a member by its member name within one system.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <returns>The <see cref="PKMember"/> with the given name, or null if no member was found.</returns>
|
||||
Task<PKMember> GetMemberByName(PKSystem system, string name);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all members inside a given system.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable of <see cref="PKMember"/> structs representing each member in the system, in no particular order.</returns>
|
||||
IAsyncEnumerable<PKMember> GetSystemMembers(PKSystem system, bool orderByName = false);
|
||||
/// <summary>
|
||||
/// Gets the amount of messages proxied by a given member.
|
||||
/// </summary>
|
||||
/// <returns>The message count of the given member.</returns>
|
||||
Task<ulong> GetMemberMessageCount(PKMember member);
|
||||
|
||||
/// <summary>
|
||||
/// Collects a breakdown of each member in a system's message count.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable of members along with their message counts.</returns>
|
||||
Task<IEnumerable<MemberMessageCount>> GetMemberMessageCountBulk(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a member, auto-generating its corresponding IDs.
|
||||
/// </summary>
|
||||
/// <param name="system">The system in which to create the member.</param>
|
||||
/// <param name="name">The name of the member to create.</param>
|
||||
/// <returns>The created system model.</returns>
|
||||
Task<PKMember> CreateMember(PKSystem system, string name);
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple members, auto-generating each corresponding ID.
|
||||
/// </summary>
|
||||
/// <param name="system">The system to create the member in.</param>
|
||||
/// <param name="memberNames">A dictionary containing a mapping from an arbitrary key to the member's name.</param>
|
||||
/// <returns>A dictionary containing the resulting member structs, each mapped to the key given in the argument dictionary.</returns>
|
||||
Task<Dictionary<string, PKMember>> CreateMembersBulk(PKSystem system, Dictionary<string, string> memberNames);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the information within the given <see cref="PKMember"/> struct to the data store.
|
||||
/// </summary>
|
||||
Task SaveMember(PKMember member);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the given member from the database.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// This will remove this member from any switches it's involved in, as well as all the messages
|
||||
/// proxied by this member.
|
||||
/// </para>
|
||||
Task DeleteMember(PKMember member);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific member's guild-specific settings for a given guild.
|
||||
/// </summary>
|
||||
Task<MemberGuildSettings> GetMemberGuildSettings(PKMember member, ulong guild);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a specific member's guild-specific settings.
|
||||
/// </summary>
|
||||
Task SetMemberGuildSettings(PKMember member, ulong guild, MemberGuildSettings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a message and its information by its ID.
|
||||
/// </summary>
|
||||
/// <param name="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.</param>
|
||||
/// <returns>An extended message object, containing not only the message data itself but the associated system and member structs.</returns>
|
||||
Task<FullMessage> GetMessage(ulong id); // id is both original and trigger, also add return type struct
|
||||
|
||||
/// <summary>
|
||||
/// Saves a posted message to the database.
|
||||
/// </summary>
|
||||
/// <param name="senderAccount">The ID of the account that sent the original trigger message.</param>
|
||||
/// <param name="guildId">The ID of the guild the message was posted to.</param>
|
||||
/// <param name="channelId">The ID of the channel the message was posted to.</param>
|
||||
/// <param name="postedMessageId">The ID of the message posted by the webhook.</param>
|
||||
/// <param name="triggerMessageId">The ID of the original trigger message containing the proxy tags.</param>
|
||||
/// <param name="proxiedMember">The member (and by extension system) that was proxied.</param>
|
||||
/// <returns></returns>
|
||||
Task AddMessage(ulong senderAccount, ulong guildId, ulong channelId, ulong postedMessageId, ulong triggerMessageId, PKMember proxiedMember);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a message from the data store.
|
||||
/// </summary>
|
||||
/// <param name="postedMessageId">The ID of the webhook message to delete.</param>
|
||||
Task DeleteMessage(ulong postedMessageId);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes messages from the data store in bulk.
|
||||
/// </summary>
|
||||
/// <param name="postedMessageIds">The IDs of the webhook messages to delete.</param>
|
||||
Task DeleteMessagesBulk(IEnumerable<ulong> postedMessageIds);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent message sent by a given account in a given guild.
|
||||
/// </summary>
|
||||
/// <returns>The full message object, or null if none was found.</returns>
|
||||
Task<FullMessage> GetLastMessageInGuild(ulong account, ulong guild);
|
||||
|
||||
/// <summary>
|
||||
/// Gets switches from a system.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable of the *count* latest switches in the system, in latest-first order. May contain fewer elements than requested.</returns>
|
||||
IAsyncEnumerable<PKSwitch> GetSwitches(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of switches in a given system.
|
||||
/// </summary>
|
||||
Task<int> GetSwitchCount(PKSystem system);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest (temporally; closest to now) switch of a given system.
|
||||
/// </summary>
|
||||
Task<PKSwitch> GetLatestSwitch(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the members a given switch consists of.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<PKMember> GetSwitchMembers(PKSwitch sw);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of fronters over a given period of time.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// This list is returned as an enumerable of "switch members", each containing a timestamp
|
||||
/// and a member ID. <seealso cref="GetMemberById"/>
|
||||
///
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <returns>An enumerable of the aforementioned "switch members".</returns>
|
||||
Task<IEnumerable<SwitchListEntry>> GetPeriodFronters(PKSystem system, Instant periodStart, Instant periodEnd);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <param name="system"></param>
|
||||
/// <param name="periodStart"></param>
|
||||
/// <param name="periodEnd"></param>
|
||||
/// <returns></returns>
|
||||
Task<FrontBreakdown> GetFrontBreakdown(PKSystem system, Instant periodStart, Instant periodEnd);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first listed fronter in a system.
|
||||
/// </summary>
|
||||
/// <returns>The first fronter, or null if none are registered.</returns>
|
||||
Task<PKMember> GetFirstFronter(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a switch with the given members in the given system.
|
||||
/// </summary>
|
||||
/// <exception>Throws an exception (TODO: which?) if any of the members are not in the given system.</exception>
|
||||
Task AddSwitch(PKSystem system, IEnumerable<PKMember> switchMembers);
|
||||
|
||||
/// <summary>
|
||||
/// Registers switches in bulk.
|
||||
/// </summary>
|
||||
/// <param name="switches">A list of switch structs, each containing a timestamp and a list of members.</param>
|
||||
/// <exception>Throws an exception (TODO: which?) if any of the given members are not in the given system.</exception>
|
||||
Task AddSwitchesBulk(PKSystem system, IEnumerable<ImportedSwitch> switches);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the timestamp of a given switch.
|
||||
/// </summary>
|
||||
Task MoveSwitch(PKSwitch sw, Instant time);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a given switch from the data store.
|
||||
/// </summary>
|
||||
Task DeleteSwitch(PKSwitch sw);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all switches in a given system from the data store.
|
||||
/// </summary>
|
||||
Task DeleteAllSwitches(PKSystem system);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of systems in the data store.
|
||||
/// </summary>
|
||||
Task<ulong> GetTotalSystems();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of members in the data store.
|
||||
/// </summary>
|
||||
Task<ulong> GetTotalMembers();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of switches in the data store.
|
||||
/// </summary>
|
||||
Task<ulong> GetTotalSwitches();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of messages in the data store.
|
||||
/// </summary>
|
||||
Task<ulong> GetTotalMessages();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the guild configuration struct for a given guild, creating and saving one if none was found.
|
||||
/// </summary>
|
||||
/// <returns>The guild's configuration struct.</returns>
|
||||
Task<GuildConfig> GetOrCreateGuildConfig(ulong guild);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the given guild configuration struct to the data store.
|
||||
/// </summary>
|
||||
Task SaveGuildConfig(GuildConfig cfg);
|
||||
|
||||
Task<AuxillaryProxyInformation> GetAuxillaryProxyInformation(ulong guild, PKSystem system, PKMember member);
|
||||
}
|
||||
|
||||
namespace PluralKit.Core {
|
||||
public class PostgresDataStore: IDataStore {
|
||||
private DbConnectionFactory _conn;
|
||||
private ILogger _logger;
|
||||
@ -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<string>("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<PKMember>("INSERT INTO members (hid, system, name) VALUES (@Hid, @SystemId, @Name) RETURNING *", new
|
@ -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;
|
@ -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 <current *local @ zone* date> 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<T> TakeWhileIncluding<T>(this IEnumerable<T> list, Func<T, bool> 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<Instant> TimestampExportFormat = InstantPattern.ExtendedIso;
|
||||
public static IPattern<LocalDate> 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 <x>d <x>h, but if it's
|
||||
// a smaller duration we may only bother with showing <x>h <x>m or <x>m <x>s
|
||||
public static IPattern<Duration> DurationFormat = new CompositePatternBuilder<Duration>
|
||||
{
|
||||
{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<LocalDateTime> LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss");
|
||||
public static IPattern<ZonedDateTime> 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<ulong>(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<Instant>());
|
||||
SqlMapper.AddTypeHandler(new PassthroughTypeHandler<LocalDate>());
|
||||
|
||||
// Add global type mapper for ProxyTag compound type in Postgres
|
||||
NpgsqlConnection.GlobalTypeMapper.MapComposite<ProxyTag>("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<ulong>
|
||||
{
|
||||
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<T> : SqlMapper.TypeHandler<T>
|
||||
{
|
||||
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<IDbConnection> 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<DbDataReader> ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
|
||||
{
|
||||
using (LogQuery())
|
||||
return await _impl.ExecuteReaderAsync(behavior, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using (LogQuery())
|
||||
return await _impl.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<object> 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<T> QueryStreamAsync<T>(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<T>();
|
||||
while (reader.Read())
|
||||
yield return parser(reader);
|
||||
}
|
||||
|
||||
public static async IAsyncEnumerable<T> QueryStreamAsync<T>(this IDbConnection conn, string sql, object param)
|
||||
{
|
||||
await using var reader = (DbDataReader) await conn.ExecuteReaderAsync(sql, param);
|
||||
var parser = reader.GetRowParser<T>();
|
||||
while (reader.Read())
|
||||
yield return parser(reader);
|
||||
}
|
||||
}
|
||||
}
|
28
PluralKit.Core/Utils/ConnectionUtils.cs
Normal file
28
PluralKit.Core/Utils/ConnectionUtils.cs
Normal file
@ -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<T> QueryStreamAsync<T>(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<T>();
|
||||
while (reader.Read())
|
||||
yield return parser(reader);
|
||||
}
|
||||
|
||||
public static async IAsyncEnumerable<T> QueryStreamAsync<T>(this IDbConnection conn, string sql, object param)
|
||||
{
|
||||
await using var reader = (DbDataReader) await conn.ExecuteReaderAsync(sql, param);
|
||||
var parser = reader.GetRowParser<T>();
|
||||
while (reader.Read())
|
||||
yield return parser(reader);
|
||||
}
|
||||
}
|
||||
}
|
305
PluralKit.Core/Utils/DatabaseUtils.cs
Normal file
305
PluralKit.Core/Utils/DatabaseUtils.cs
Normal file
@ -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<DbDataReader> ExecuteDbDataReaderAsync(
|
||||
CommandBehavior behavior, CancellationToken cancellationToken)
|
||||
{
|
||||
using (LogQuery())
|
||||
return await _impl.ExecuteReaderAsync(behavior, cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using (LogQuery())
|
||||
return await _impl.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task<object> 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<IDbConnection> 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<T>: SqlMapper.TypeHandler<T>
|
||||
{
|
||||
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<ulong>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
24
PluralKit.Core/Utils/DateTimeFormats.cs
Normal file
24
PluralKit.Core/Utils/DateTimeFormats.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using NodaTime;
|
||||
using NodaTime.Text;
|
||||
|
||||
namespace PluralKit.Core {
|
||||
public static class DateTimeFormats
|
||||
{
|
||||
public static IPattern<Instant> TimestampExportFormat = InstantPattern.ExtendedIso;
|
||||
public static IPattern<LocalDate> 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 <x>d <x>h, but if it's
|
||||
// a smaller duration we may only bother with showing <x>h <x>m or <x>m <x>s
|
||||
public static IPattern<Duration> DurationFormat = new CompositePatternBuilder<Duration>
|
||||
{
|
||||
{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<LocalDateTime> LocalDateTimeFormat = LocalDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss");
|
||||
public static IPattern<ZonedDateTime> ZonedDateTimeFormat = ZonedDateTimePattern.CreateWithInvariantCulture("yyyy-MM-dd HH:mm:ss x", DateTimeZoneProviders.Tzdb);
|
||||
}
|
||||
}
|
174
PluralKit.Core/Utils/DateUtils.cs
Normal file
174
PluralKit.Core/Utils/DateUtils.cs
Normal file
@ -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 <current *local @ zone* date> 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;
|
||||
}
|
||||
}
|
||||
}
|
11
PluralKit.Core/Utils/Emojis.cs
Normal file
11
PluralKit.Core/Utils/Emojis.cs
Normal file
@ -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";
|
||||
}
|
||||
}
|
57
PluralKit.Core/Utils/InitUtils.cs
Normal file
57
PluralKit.Core/Utils/InitUtils.cs
Normal file
@ -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<ulong>(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<Instant>());
|
||||
SqlMapper.AddTypeHandler(new PassthroughTypeHandler<LocalDate>());
|
||||
|
||||
// Add global type mapper for ProxyTag compound type in Postgres
|
||||
NpgsqlConnection.GlobalTypeMapper.MapComposite<ProxyTag>("proxy_tag");
|
||||
}
|
||||
|
||||
public static JsonSerializerSettings BuildSerializerSettings() => new JsonSerializerSettings().BuildSerializerSettings();
|
||||
|
||||
public static JsonSerializerSettings BuildSerializerSettings(this JsonSerializerSettings settings)
|
||||
{
|
||||
settings.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
}
|
57
PluralKit.Core/Utils/StringUtils.cs
Normal file
57
PluralKit.Core/Utils/StringUtils.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<Exception> handler) {
|
||||
try {
|
Loading…
x
Reference in New Issue
Block a user