Large refactor and project restructuring

This commit is contained in:
Ske 2020-02-12 15:16:19 +01:00
parent c10e197c39
commit 6d5004bf54
71 changed files with 1664 additions and 1607 deletions

View file

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

View file

@ -1,6 +1,5 @@
using App.Metrics;
using App.Metrics.Gauge;
using App.Metrics.Histogram;
using App.Metrics.Meter;
using App.Metrics.Timer;

View file

@ -1,4 +1,4 @@
namespace PluralKit.Bot.CommandSystem
namespace PluralKit.Bot
{
public class Command
{

View file

@ -1,6 +1,6 @@
using System.Collections.Generic;
namespace PluralKit.Bot.CommandSystem
namespace PluralKit.Bot
{
public class CommandGroup
{

View file

@ -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
{

View file

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace PluralKit.Bot.CommandSystem
namespace PluralKit.Bot
{
public class Parameters
{

View file

@ -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
{

View file

@ -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
{

View file

@ -1,8 +1,6 @@
using System.Threading.Tasks;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands
namespace PluralKit.Bot
{
public class Fun
{

View file

@ -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
{

View file

@ -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
{

View file

@ -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
{

View file

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

View file

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

View file

@ -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
{

View file

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

View file

@ -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
{

View file

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

View file

@ -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
{

View file

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

View file

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

View file

@ -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
{

View file

@ -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
{

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -1,6 +1,9 @@
using System.Threading.Tasks;
using Dapper;
using Discord;
using PluralKit.Core;
using Serilog;
namespace PluralKit.Bot {

View file

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

View file

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

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

View file

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

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

View file

@ -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 {

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

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

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