feat: upgrade to .NET 6, refactor everything

This commit is contained in:
spiral
2021-11-26 21:10:56 -05:00
parent d28e99ba43
commit 1918c56937
314 changed files with 27954 additions and 27966 deletions

View File

@@ -1,16 +1,15 @@
namespace PluralKit.Bot
{
public class Command
{
public string Key { get; }
public string Usage { get; }
public string Description { get; }
namespace PluralKit.Bot;
public Command(string key, string usage, string description)
{
Key = key;
Usage = usage;
Description = description;
}
public class Command
{
public Command(string key, string usage, string description)
{
Key = key;
Usage = usage;
Description = description;
}
public string Key { get; }
public string Usage { get; }
public string Description { get; }
}

View File

@@ -1,19 +1,16 @@
using System.Collections.Generic;
namespace PluralKit.Bot;
namespace PluralKit.Bot
public class CommandGroup
{
public class CommandGroup
public CommandGroup(string key, string description, ICollection<Command> children)
{
public string Key { get; }
public string Description { get; }
public ICollection<Command> Children { get; }
public CommandGroup(string key, string description, ICollection<Command> children)
{
Key = key;
Description = description;
Children = children;
}
Key = key;
Description = description;
Children = children;
}
public string Key { get; }
public string Description { get; }
public ICollection<Command> Children { get; }
}

View File

@@ -15,138 +15,126 @@ using Myriad.Types;
using PluralKit.Core;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public class Context
{
public class Context
private readonly ILifetimeScope _provider;
private readonly Parameters _parameters;
private readonly IMetrics _metrics;
private readonly CommandMessageService _commandMessageService;
private Command? _currentCommand;
public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset,
PKSystem senderSystem, MessageContext messageContext)
{
private readonly ILifetimeScope _provider;
private readonly DiscordApiClient _rest;
private readonly Cluster _cluster;
private readonly Shard _shard;
private readonly Guild? _guild;
private readonly Channel _channel;
private readonly MessageCreateEvent _message;
private readonly Parameters _parameters;
private readonly MessageContext _messageContext;
private readonly IDatabase _db;
private readonly ModelRepository _repo;
private readonly PKSystem _senderSystem;
private readonly IMetrics _metrics;
private readonly CommandMessageService _commandMessageService;
private readonly IDiscordCache _cache;
private Command? _currentCommand;
public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset,
PKSystem senderSystem, MessageContext messageContext)
{
_message = message;
_shard = shard;
_guild = guild;
_channel = channel;
_senderSystem = senderSystem;
_messageContext = messageContext;
_cache = provider.Resolve<IDiscordCache>();
_db = provider.Resolve<IDatabase>();
_repo = provider.Resolve<ModelRepository>();
_metrics = provider.Resolve<IMetrics>();
_provider = provider;
_commandMessageService = provider.Resolve<CommandMessageService>();
_parameters = new Parameters(message.Content?.Substring(commandParseOffset));
_rest = provider.Resolve<DiscordApiClient>();
_cluster = provider.Resolve<Cluster>();
}
public IDiscordCache Cache => _cache;
public Channel Channel => _channel;
public User Author => _message.Author;
public GuildMemberPartial Member => _message.Member;
public Message Message => _message;
public Guild Guild => _guild;
public Shard Shard => _shard;
public Cluster Cluster => _cluster;
public MessageContext MessageContext => _messageContext;
public Task<PermissionSet> BotPermissions => _provider.Resolve<IDiscordCache>().PermissionsIn(_channel.Id);
public Task<PermissionSet> UserPermissions => _cache.PermissionsFor(_message);
public DiscordApiClient Rest => _rest;
public PKSystem System => _senderSystem;
public Parameters Parameters => _parameters;
internal IDatabase Database => _db;
internal ModelRepository Repository => _repo;
public async Task<Message> Reply(string text = null, Embed embed = null, AllowedMentions? mentions = null)
{
var botPerms = await BotPermissions;
if (!botPerms.HasFlag(PermissionSet.SendMessages))
// Will be "swallowed" during the error handler anyway, this message is never shown.
throw new PKError("PluralKit does not have permission to send messages in this channel.");
if (embed != null && !botPerms.HasFlag(PermissionSet.EmbedLinks))
throw new PKError("PluralKit does not have permission to send embeds in this channel. Please ensure I have the **Embed Links** permission enabled.");
var msg = await _rest.CreateMessage(_channel.Id, new MessageRequest
{
Content = text,
Embed = embed,
// Default to an empty allowed mentions object instead of null (which means no mentions allowed)
AllowedMentions = mentions ?? new AllowedMentions()
});
if (embed != null)
{
// Sensitive information that might want to be deleted by :x: reaction is typically in an embed format (member cards, for example)
// This may need to be changed at some point but works well enough for now
await _commandMessageService.RegisterMessage(msg.Id, msg.ChannelId, Author.Id);
}
return msg;
}
public async Task Execute<T>(Command? commandDef, Func<T, Task> handler)
{
_currentCommand = commandDef;
try
{
using (_metrics.Measure.Timer.Time(BotMetrics.CommandTime, new MetricTags("Command", commandDef?.Key ?? "null")))
await handler(_provider.Resolve<T>());
_metrics.Measure.Meter.Mark(BotMetrics.CommandsRun);
}
catch (PKSyntaxError e)
{
await Reply($"{Emojis.Error} {e.Message}\n**Command usage:**\n> pk;{commandDef?.Usage}");
}
catch (PKError e)
{
await Reply($"{Emojis.Error} {e.Message}");
}
catch (TimeoutException)
{
// Got a complaint the old error was a bit too patronizing. Hopefully this is better?
await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?");
}
}
public LookupContext LookupContextFor(PKSystem target) =>
System?.Id == target.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner;
public LookupContext LookupContextFor(SystemId systemId) =>
System?.Id == systemId ? LookupContext.ByOwner : LookupContext.ByNonOwner;
public LookupContext LookupContextFor(PKMember target) =>
System?.Id == target.System ? LookupContext.ByOwner : LookupContext.ByNonOwner;
public IComponentContext Services => _provider;
Message = (Message)message;
Shard = shard;
Guild = guild;
Channel = channel;
System = senderSystem;
MessageContext = messageContext;
Cache = provider.Resolve<IDiscordCache>();
Database = provider.Resolve<IDatabase>();
Repository = provider.Resolve<ModelRepository>();
_metrics = provider.Resolve<IMetrics>();
_provider = provider;
_commandMessageService = provider.Resolve<CommandMessageService>();
_parameters = new Parameters(message.Content?.Substring(commandParseOffset));
Rest = provider.Resolve<DiscordApiClient>();
Cluster = provider.Resolve<Cluster>();
}
public readonly IDiscordCache Cache;
public readonly DiscordApiClient Rest;
public readonly Channel Channel;
public User Author => Message.Author;
public GuildMemberPartial Member => ((MessageCreateEvent)Message).Member;
public readonly Message Message;
public readonly Guild Guild;
public readonly Shard Shard;
public readonly Cluster Cluster;
public readonly MessageContext MessageContext;
public Task<PermissionSet> BotPermissions => Cache.PermissionsIn(Channel.Id);
public Task<PermissionSet> UserPermissions => Cache.PermissionsFor((MessageCreateEvent)Message);
public readonly PKSystem System;
public readonly Parameters Parameters;
internal readonly IDatabase Database;
internal readonly ModelRepository Repository;
public async Task<Message> Reply(string text = null, Embed embed = null, AllowedMentions? mentions = null)
{
var botPerms = await BotPermissions;
if (!botPerms.HasFlag(PermissionSet.SendMessages))
// Will be "swallowed" during the error handler anyway, this message is never shown.
throw new PKError("PluralKit does not have permission to send messages in this channel.");
if (embed != null && !botPerms.HasFlag(PermissionSet.EmbedLinks))
throw new PKError("PluralKit does not have permission to send embeds in this channel. Please ensure I have the **Embed Links** permission enabled.");
var msg = await Rest.CreateMessage(Channel.Id, new MessageRequest
{
Content = text,
Embed = embed,
// Default to an empty allowed mentions object instead of null (which means no mentions allowed)
AllowedMentions = mentions ?? new AllowedMentions()
});
if (embed != null)
{
// Sensitive information that might want to be deleted by :x: reaction is typically in an embed format (member cards, for example)
// This may need to be changed at some point but works well enough for now
await _commandMessageService.RegisterMessage(msg.Id, msg.ChannelId, Author.Id);
}
return msg;
}
public async Task Execute<T>(Command? commandDef, Func<T, Task> handler)
{
_currentCommand = commandDef;
try
{
using (_metrics.Measure.Timer.Time(BotMetrics.CommandTime, new MetricTags("Command", commandDef?.Key ?? "null")))
await handler(_provider.Resolve<T>());
_metrics.Measure.Meter.Mark(BotMetrics.CommandsRun);
}
catch (PKSyntaxError e)
{
await Reply($"{Emojis.Error} {e.Message}\n**Command usage:**\n> pk;{commandDef?.Usage}");
}
catch (PKError e)
{
await Reply($"{Emojis.Error} {e.Message}");
}
catch (TimeoutException)
{
// Got a complaint the old error was a bit too patronizing. Hopefully this is better?
await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?");
}
}
public LookupContext LookupContextFor(PKSystem target) =>
System?.Id == target.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner;
public LookupContext LookupContextFor(SystemId systemId) =>
System?.Id == systemId ? LookupContext.ByOwner : LookupContext.ByNonOwner;
public LookupContext LookupContextFor(PKMember target) =>
System?.Id == target.System ? LookupContext.ByOwner : LookupContext.ByNonOwner;
public IComponentContext Services => _provider;
}

View File

@@ -1,145 +1,139 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Myriad.Types;
using PluralKit.Core;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public static class ContextArgumentsExt
{
public static class ContextArgumentsExt
public static string PopArgument(this Context ctx) =>
ctx.Parameters.Pop();
public static string PeekArgument(this Context ctx) =>
ctx.Parameters.Peek();
public static string RemainderOrNull(this Context ctx, bool skipFlags = true) =>
ctx.Parameters.Remainder(skipFlags).Length == 0 ? null : ctx.Parameters.Remainder(skipFlags);
public static bool HasNext(this Context ctx, bool skipFlags = true) =>
ctx.RemainderOrNull(skipFlags) != null;
public static string FullCommand(this Context ctx) =>
ctx.Parameters.FullCommand;
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
/// </summary>
public static bool Match(this Context ctx, ref string used, params string[] potentialMatches)
{
public static string PopArgument(this Context ctx) =>
ctx.Parameters.Pop();
public static string PeekArgument(this Context ctx) =>
ctx.Parameters.Peek();
public static string RemainderOrNull(this Context ctx, bool skipFlags = true) =>
ctx.Parameters.Remainder(skipFlags).Length == 0 ? null : ctx.Parameters.Remainder(skipFlags);
public static bool HasNext(this Context ctx, bool skipFlags = true) =>
ctx.RemainderOrNull(skipFlags) != null;
public static string FullCommand(this Context ctx) =>
ctx.Parameters.FullCommand;
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
/// </summary>
public static bool Match(this Context ctx, ref string used, params string[] potentialMatches)
{
var arg = ctx.PeekArgument();
foreach (var match in potentialMatches)
var arg = ctx.PeekArgument();
foreach (var match in potentialMatches)
if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase))
{
if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase))
{
used = ctx.PopArgument();
return true;
}
used = ctx.PopArgument();
return true;
}
return false;
}
return false;
}
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
/// </summary>
public static bool Match(this Context ctx, params string[] potentialMatches)
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
/// </summary>
public static bool Match(this Context ctx, params string[] potentialMatches)
{
string used = null; // Unused and unreturned, we just yeet it
return ctx.Match(ref used, potentialMatches);
}
public static bool MatchFlag(this Context ctx, params string[] potentialMatches)
{
// Flags are *ALWAYS PARSED LOWERCASE*. This means we skip out on a "ToLower" call here.
// Can assume the caller array only contains lowercase *and* the set below only contains lowercase
var flags = ctx.Parameters.Flags();
return potentialMatches.Any(potentialMatch => flags.Contains(potentialMatch));
}
public static async Task<bool> MatchClear(this Context ctx, string toClear = null)
{
var matched = ctx.Match("clear", "reset") || ctx.MatchFlag("c", "clear");
if (matched && toClear != null)
return await ctx.ConfirmClear(toClear);
return matched;
}
public static bool MatchRaw(this Context ctx) =>
ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw");
public static (ulong? messageId, ulong? channelId) MatchMessage(this Context ctx, bool parseRawMessageId)
{
if (ctx.Message.Type == Message.MessageType.Reply && ctx.Message.MessageReference?.MessageId != null)
return (ctx.Message.MessageReference.MessageId, ctx.Message.MessageReference.ChannelId);
var word = ctx.PeekArgument();
if (word == null)
return (null, null);
if (parseRawMessageId && ulong.TryParse(word, out var mid))
return (mid, null);
var match = Regex.Match(word, "https://(?:\\w+.)?discord(?:app)?.com/channels/\\d+/(\\d+)/(\\d+)");
if (!match.Success)
return (null, null);
var channelId = ulong.Parse(match.Groups[1].Value);
var messageId = ulong.Parse(match.Groups[2].Value);
ctx.PopArgument();
return (messageId, channelId);
}
public static async Task<List<PKMember>> ParseMemberList(this Context ctx, SystemId? restrictToSystem)
{
var members = new List<PKMember>();
// Loop through all the given arguments
while (ctx.HasNext())
{
string used = null; // Unused and unreturned, we just yeet it
return ctx.Match(ref used, potentialMatches);
// and attempt to match a member
var member = await ctx.MatchMember(restrictToSystem);
if (member == null)
// if we can't, big error. Every member name must be valid.
throw new PKError(ctx.CreateNotFoundError("Member", ctx.PopArgument()));
members.Add(member); // Then add to the final output list
}
public static bool MatchFlag(this Context ctx, params string[] potentialMatches)
if (members.Count == 0) throw new PKSyntaxError("You must input at least one member.");
return members;
}
public static async Task<List<PKGroup>> ParseGroupList(this Context ctx, SystemId? restrictToSystem)
{
var groups = new List<PKGroup>();
// Loop through all the given arguments
while (ctx.HasNext())
{
// Flags are *ALWAYS PARSED LOWERCASE*. This means we skip out on a "ToLower" call here.
// Can assume the caller array only contains lowercase *and* the set below only contains lowercase
// and attempt to match a group
var group = await ctx.MatchGroup(restrictToSystem);
if (group == null)
// if we can't, big error. Every group name must be valid.
throw new PKError(ctx.CreateNotFoundError("Group", ctx.PopArgument()));
var flags = ctx.Parameters.Flags();
return potentialMatches.Any(potentialMatch => flags.Contains(potentialMatch));
// todo: remove this, the database query enforces the restriction
if (restrictToSystem != null && group.System != restrictToSystem)
throw Errors.NotOwnGroupError; // TODO: name *which* group?
groups.Add(group); // Then add to the final output list
}
public static async Task<bool> MatchClear(this Context ctx, string toClear = null)
{
var matched = ctx.Match("clear", "reset") || ctx.MatchFlag("c", "clear");
if (matched && toClear != null)
return await ctx.ConfirmClear(toClear);
return matched;
}
if (groups.Count == 0) throw new PKSyntaxError("You must input at least one group.");
public static bool MatchRaw(this Context ctx) =>
ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw");
public static (ulong? messageId, ulong? channelId) MatchMessage(this Context ctx, bool parseRawMessageId)
{
if (ctx.Message.Type == Message.MessageType.Reply && ctx.Message.MessageReference?.MessageId != null)
return (ctx.Message.MessageReference.MessageId, ctx.Message.MessageReference.ChannelId);
var word = ctx.PeekArgument();
if (word == null)
return (null, null);
if (parseRawMessageId && ulong.TryParse(word, out var mid))
return (mid, null);
var match = Regex.Match(word, "https://(?:\\w+.)?discord(?:app)?.com/channels/\\d+/(\\d+)/(\\d+)");
if (!match.Success)
return (null, null);
var channelId = ulong.Parse(match.Groups[1].Value);
var messageId = ulong.Parse(match.Groups[2].Value);
ctx.PopArgument();
return (messageId, channelId);
}
public static async Task<List<PKMember>> ParseMemberList(this Context ctx, SystemId? restrictToSystem)
{
var members = new List<PKMember>();
// Loop through all the given arguments
while (ctx.HasNext())
{
// and attempt to match a member
var member = await ctx.MatchMember(restrictToSystem);
if (member == null)
// if we can't, big error. Every member name must be valid.
throw new PKError(ctx.CreateNotFoundError("Member", ctx.PopArgument()));
members.Add(member); // Then add to the final output list
}
if (members.Count == 0) throw new PKSyntaxError($"You must input at least one member.");
return members;
}
public static async Task<List<PKGroup>> ParseGroupList(this Context ctx, SystemId? restrictToSystem)
{
var groups = new List<PKGroup>();
// Loop through all the given arguments
while (ctx.HasNext())
{
// and attempt to match a group
var group = await ctx.MatchGroup(restrictToSystem);
if (group == null)
// if we can't, big error. Every group name must be valid.
throw new PKError(ctx.CreateNotFoundError("Group", ctx.PopArgument()));
// todo: remove this, the database query enforces the restriction
if (restrictToSystem != null && group.System != restrictToSystem)
throw Errors.NotOwnGroupError; // TODO: name *which* group?
groups.Add(group); // Then add to the final output list
}
if (groups.Count == 0) throw new PKSyntaxError($"You must input at least one group.");
return groups;
}
return groups;
}
}

View File

@@ -1,6 +1,3 @@
using System.Linq;
using System.Threading.Tasks;
using Autofac;
using Myriad.Extensions;
@@ -8,97 +5,100 @@ using Myriad.Types;
using PluralKit.Core;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public static class ContextChecksExt
{
public static class ContextChecksExt
public static Context CheckGuildContext(this Context ctx)
{
public static Context CheckGuildContext(this Context ctx)
{
if (ctx.Channel.GuildId != null) return ctx;
throw new PKError("This command can not be run in a DM.");
}
if (ctx.Channel.GuildId != null) return ctx;
throw new PKError("This command can not be run in a DM.");
}
public static Context CheckDMContext(this Context ctx)
{
if (ctx.Channel.GuildId == null) return ctx;
throw new PKError("This command must be run in a DM.");
}
public static Context CheckDMContext(this Context ctx)
{
if (ctx.Channel.GuildId == null) return ctx;
throw new PKError("This command must be run in a DM.");
}
public static Context CheckSystemPrivacy(this Context ctx, PKSystem target, PrivacyLevel level)
{
if (level.CanAccess(ctx.LookupContextFor(target))) return ctx;
throw new PKError("You do not have permission to access this information.");
}
public static Context CheckSystemPrivacy(this Context ctx, PKSystem target, PrivacyLevel level)
{
if (level.CanAccess(ctx.LookupContextFor(target))) return ctx;
throw new PKError("You do not have permission to access this information.");
}
public static Context CheckOwnMember(this Context ctx, PKMember member)
{
if (member.System != ctx.System?.Id)
throw Errors.NotOwnMemberError;
return ctx;
}
public static Context CheckOwnMember(this Context ctx, PKMember member)
{
if (member.System != ctx.System?.Id)
throw Errors.NotOwnMemberError;
return ctx;
}
public static Context CheckOwnGroup(this Context ctx, PKGroup group)
{
if (group.System != ctx.System?.Id)
throw Errors.NotOwnGroupError;
return ctx;
}
public static Context CheckOwnGroup(this Context ctx, PKGroup group)
{
if (group.System != ctx.System?.Id)
throw Errors.NotOwnGroupError;
return ctx;
}
public static Context CheckSystem(this Context ctx)
{
if (ctx.System == null)
throw Errors.NoSystemError;
return ctx;
}
public static Context CheckSystem(this Context ctx)
{
if (ctx.System == null)
throw Errors.NoSystemError;
return ctx;
}
public static Context CheckNoSystem(this Context ctx)
{
if (ctx.System != null)
throw Errors.ExistingSystemError;
return ctx;
}
public static Context CheckNoSystem(this Context ctx)
{
if (ctx.System != null)
throw Errors.ExistingSystemError;
return ctx;
}
public static async Task<Context> CheckAuthorPermission(this Context ctx, PermissionSet neededPerms, string permissionName)
{
if ((await ctx.UserPermissions & neededPerms) != neededPerms)
throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command.");
return ctx;
}
public static async Task<Context> CheckAuthorPermission(this Context ctx, PermissionSet neededPerms,
string permissionName)
{
if ((await ctx.UserPermissions & neededPerms) != neededPerms)
throw new PKError(
$"You must have the \"{permissionName}\" permission in this server to use this command.");
return ctx;
}
public static async Task<bool> CheckPermissionsInGuildChannel(this Context ctx, Channel channel, PermissionSet neededPerms)
public static async Task<bool> CheckPermissionsInGuildChannel(this Context ctx, Channel channel,
PermissionSet neededPerms)
{
var guild = await ctx.Cache.GetGuild(channel.GuildId.Value);
if (guild == null)
return false;
var guildMember = ctx.Member;
if (ctx.Guild?.Id != channel.GuildId)
{
var guild = await ctx.Cache.GetGuild(channel.GuildId.Value);
if (guild == null)
guildMember = await ctx.Rest.GetGuildMember(channel.GuildId.Value, ctx.Author.Id);
if (guildMember == null)
return false;
var guildMember = ctx.Member;
if (ctx.Guild?.Id != channel.GuildId)
{
guildMember = await ctx.Rest.GetGuildMember(channel.GuildId.Value, ctx.Author.Id);
if (guildMember == null)
return false;
}
var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.Author.Id, guildMember);
if ((userPermissions & neededPerms) == 0)
return false;
return true;
}
public static bool CheckBotAdmin(this Context ctx)
{
var botConfig = ctx.Services.Resolve<BotConfig>();
return botConfig.AdminRole != null && ctx.Member != null && ctx.Member.Roles.Contains(botConfig.AdminRole.Value);
}
var userPermissions = PermissionExtensions.PermissionsFor(guild, channel, ctx.Author.Id, guildMember);
if ((userPermissions & neededPerms) == 0)
return false;
public static Context AssertBotAdmin(this Context ctx)
{
if (!ctx.CheckBotAdmin())
throw new PKError("This command is only usable by bot admins.");
return true;
}
return ctx;
}
public static bool CheckBotAdmin(this Context ctx)
{
var botConfig = ctx.Services.Resolve<BotConfig>();
return botConfig.AdminRole != null && ctx.Member != null &&
ctx.Member.Roles.Contains(botConfig.AdminRole.Value);
}
public static Context AssertBotAdmin(this Context ctx)
{
if (!ctx.CheckBotAdmin())
throw new PKError("This command is only usable by bot admins.");
return ctx;
}
}

View File

@@ -1,187 +1,180 @@
using System;
using System.Threading.Tasks;
using Myriad.Extensions;
using Myriad.Types;
using PluralKit.Bot.Utils;
using PluralKit.Core;
namespace PluralKit.Bot
namespace PluralKit.Bot;
public static class ContextEntityArgumentsExt
{
public static class ContextEntityArgumentsExt
public static async Task<User> MatchUser(this Context ctx)
{
public static async Task<User> MatchUser(this Context ctx)
{
var text = ctx.PeekArgument();
if (text.TryParseMention(out var id))
return await ctx.Cache.GetOrFetchUser(ctx.Rest, id);
var text = ctx.PeekArgument();
if (text.TryParseMention(out var id))
return await ctx.Cache.GetOrFetchUser(ctx.Rest, id);
return null;
}
public static bool MatchUserRaw(this Context ctx, out ulong id)
{
id = 0;
var text = ctx.PeekArgument();
if (text.TryParseMention(out var mentionId))
id = mentionId;
return id != 0;
}
public static Task<PKSystem> PeekSystem(this Context ctx) => ctx.MatchSystemInner();
public static async Task<PKSystem> MatchSystem(this Context ctx)
{
var system = await ctx.MatchSystemInner();
if (system != null) ctx.PopArgument();
return system;
}
private static async Task<PKSystem> MatchSystemInner(this Context ctx)
{
var input = ctx.PeekArgument();
// System references can take three forms:
// - The direct user ID of an account connected to the system
// - A @mention of an account connected to the system (<@uid>)
// - A system hid
// Direct IDs and mentions are both handled by the below method:
if (input.TryParseMention(out var id))
return await ctx.Repository.GetSystemByAccount(id);
// Finally, try HID parsing
var system = await ctx.Repository.GetSystemByHid(input);
return system;
}
public static async Task<PKMember> PeekMember(this Context ctx, SystemId? restrictToSystem = null)
{
var input = ctx.PeekArgument();
// Member references can have one of three forms, depending on
// whether you're in a system or not:
// - A member hid
// - A textual name of a member *in your own system*
// - a textual display name of a member *in your own system*
// Skip name / display name matching if the user does not have a system
// or if they specifically request by-HID matching
if (ctx.System != null && !ctx.MatchFlag("id", "by-id"))
{
// First, try finding by member name in system
if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName)
return memberByName;
// And if that fails, we try finding a member with a display name matching the argument from the system
if (ctx.System != null &&
await ctx.Repository.GetMemberByDisplayName(ctx.System.Id, input) is PKMember memberByDisplayName)
return memberByDisplayName;
}
// Finally (or if by-HID lookup is specified), try member HID parsing:
if (await ctx.Repository.GetMemberByHid(input, restrictToSystem) is PKMember memberByHid)
return memberByHid;
// We didn't find anything, so we return null.
return null;
}
/// <summary>
/// Attempts to pop a member descriptor from the stack, returning it if present. If a member could not be
/// resolved by the next word in the argument stack, does *not* touch the stack, and returns null.
/// </summary>
public static async Task<PKMember> MatchMember(this Context ctx, SystemId? restrictToSystem = null)
{
// First, peek a member
var member = await ctx.PeekMember(restrictToSystem);
// If the peek was successful, we've used up the next argument, so we pop that just to get rid of it.
if (member != null) ctx.PopArgument();
// Finally, we return the member value.
return member;
}
public static async Task<PKGroup> PeekGroup(this Context ctx, SystemId? restrictToSystem = null)
{
var input = ctx.PeekArgument();
// see PeekMember for an explanation of the logic used here
if (ctx.System != null && !ctx.MatchFlag("id", "by-id"))
{
if (await ctx.Repository.GetGroupByName(ctx.System.Id, input) is { } byName)
return byName;
if (await ctx.Repository.GetGroupByDisplayName(ctx.System.Id, input) is { } byDisplayName)
return byDisplayName;
}
if (await ctx.Repository.GetGroupByHid(input, restrictToSystem) is { } byHid)
return byHid;
return null;
}
public static async Task<PKGroup> MatchGroup(this Context ctx, SystemId? restrictToSystem = null)
{
var group = await ctx.PeekGroup(restrictToSystem);
if (group != null) ctx.PopArgument();
return group;
}
public static string CreateNotFoundError(this Context ctx, string entity, string input)
{
var isIDOnlyQuery = ctx.System == null || ctx.MatchFlag("id", "by-id");
if (isIDOnlyQuery)
{
if (input.Length == 5)
return $"{entity} with ID \"{input}\" not found.";
return $"{entity} not found. Note that a {entity.ToLower()} ID is 5 characters long.";
}
if (input.Length == 5)
return $"{entity} with ID or name \"{input}\" not found.";
return $"{entity} with name \"{input}\" not found. Note that a {entity.ToLower()} ID is 5 characters long.";
}
public static async Task<Channel> MatchChannel(this Context ctx)
{
if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id))
return null;
}
public static bool MatchUserRaw(this Context ctx, out ulong id)
{
id = 0;
var text = ctx.PeekArgument();
if (text.TryParseMention(out var mentionId))
id = mentionId;
return id != 0;
}
public static Task<PKSystem> PeekSystem(this Context ctx) => ctx.MatchSystemInner();
public static async Task<PKSystem> MatchSystem(this Context ctx)
{
var system = await ctx.MatchSystemInner();
if (system != null) ctx.PopArgument();
return system;
}
private static async Task<PKSystem> MatchSystemInner(this Context ctx)
{
var input = ctx.PeekArgument();
// System references can take three forms:
// - The direct user ID of an account connected to the system
// - A @mention of an account connected to the system (<@uid>)
// - A system hid
// Direct IDs and mentions are both handled by the below method:
if (input.TryParseMention(out var id))
return await ctx.Repository.GetSystemByAccount(id);
// Finally, try HID parsing
var system = await ctx.Repository.GetSystemByHid(input);
return system;
}
public static async Task<PKMember> PeekMember(this Context ctx, SystemId? restrictToSystem = null)
{
var input = ctx.PeekArgument();
// Member references can have one of three forms, depending on
// whether you're in a system or not:
// - A member hid
// - A textual name of a member *in your own system*
// - a textual display name of a member *in your own system*
// Skip name / display name matching if the user does not have a system
// or if they specifically request by-HID matching
if (ctx.System != null && !ctx.MatchFlag("id", "by-id"))
{
// First, try finding by member name in system
if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName)
return memberByName;
// And if that fails, we try finding a member with a display name matching the argument from the system
if (ctx.System != null && await ctx.Repository.GetMemberByDisplayName(ctx.System.Id, input) is PKMember memberByDisplayName)
return memberByDisplayName;
}
// Finally (or if by-HID lookup is specified), try member HID parsing:
if (await ctx.Repository.GetMemberByHid(input, restrictToSystem) is PKMember memberByHid)
return memberByHid;
// We didn't find anything, so we return null.
if (!(await ctx.Cache.TryGetChannel(id) is Channel channel))
return null;
}
/// <summary>
/// Attempts to pop a member descriptor from the stack, returning it if present. If a member could not be
/// resolved by the next word in the argument stack, does *not* touch the stack, and returns null.
/// </summary>
public static async Task<PKMember> MatchMember(this Context ctx, SystemId? restrictToSystem = null)
{
// First, peek a member
var member = await ctx.PeekMember(restrictToSystem);
// If the peek was successful, we've used up the next argument, so we pop that just to get rid of it.
if (member != null) ctx.PopArgument();
// Finally, we return the member value.
return member;
}
public static async Task<PKGroup> PeekGroup(this Context ctx, SystemId? restrictToSystem = null)
{
var input = ctx.PeekArgument();
// see PeekMember for an explanation of the logic used here
if (ctx.System != null && !ctx.MatchFlag("id", "by-id"))
{
if (await ctx.Repository.GetGroupByName(ctx.System.Id, input) is { } byName)
return byName;
if (await ctx.Repository.GetGroupByDisplayName(ctx.System.Id, input) is { } byDisplayName)
return byDisplayName;
}
if (await ctx.Repository.GetGroupByHid(input, restrictToSystem) is { } byHid)
return byHid;
if (!DiscordUtils.IsValidGuildChannel(channel))
return null;
}
public static async Task<PKGroup> MatchGroup(this Context ctx, SystemId? restrictToSystem = null)
ctx.PopArgument();
return channel;
}
public static async Task<Guild> MatchGuild(this Context ctx)
{
try
{
var group = await ctx.PeekGroup(restrictToSystem);
if (group != null) ctx.PopArgument();
return group;
var id = ulong.Parse(ctx.PeekArgument());
var guild = await ctx.Cache.TryGetGuild(id);
if (guild != null)
ctx.PopArgument();
return guild;
}
public static string CreateNotFoundError(this Context ctx, string entity, string input)
catch (FormatException)
{
var isIDOnlyQuery = ctx.System == null || ctx.MatchFlag("id", "by-id");
if (isIDOnlyQuery)
{
if (input.Length == 5)
return $"{entity} with ID \"{input}\" not found.";
else
return $"{entity} not found. Note that a {entity.ToLower()} ID is 5 characters long.";
}
else
{
if (input.Length == 5)
return $"{entity} with ID or name \"{input}\" not found.";
else
return $"{entity} with name \"{input}\" not found. Note that a {entity.ToLower()} ID is 5 characters long.";
}
}
public static async Task<Channel> MatchChannel(this Context ctx)
{
if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id))
return null;
if (!(await ctx.Cache.TryGetChannel(id) is Channel channel))
return null;
if (!DiscordUtils.IsValidGuildChannel(channel))
return null;
ctx.PopArgument();
return channel;
}
public static async Task<Guild> MatchGuild(this Context ctx)
{
try
{
var id = ulong.Parse(ctx.PeekArgument());
var guild = await ctx.Cache.TryGetGuild(id);
if (guild != null)
ctx.PopArgument();
return guild;
}
catch (FormatException)
{
return null;
}
return null;
}
}
}

View File

@@ -1,187 +1,178 @@
using System;
using System.Collections.Generic;
namespace PluralKit.Bot;
namespace PluralKit.Bot
public class Parameters
{
public class Parameters
// Dictionary of (left, right) quote pairs
// Each char in the string is an individual quote, multi-char strings imply "one of the following chars"
private static readonly Dictionary<string, string> _quotePairs = new()
{
// Dictionary of (left, right) quote pairs
// Each char in the string is an individual quote, multi-char strings imply "one of the following chars"
private static readonly Dictionary<string, string> _quotePairs = new Dictionary<string, string>
// Basic
{ "'", "'" }, // ASCII single quotes
{ "\"", "\"" }, // ASCII double quotes
// "Smart quotes"
// Specifically ignore the left/right status of the quotes and match any combination of them
// Left string also includes "low" quotes to allow for the low-high style used in some locales
{ "\u201C\u201D\u201F\u201E", "\u201C\u201D\u201F" }, // double quotes
{ "\u2018\u2019\u201B\u201A", "\u2018\u2019\u201B" }, // single quotes
// Chevrons (normal and "fullwidth" variants)
{ "\u00AB\u300A", "\u00BB\u300B" }, // double chevrons, pointing away (<<text>>)
{ "\u00BB\u300B", "\u00AA\u300A" }, // double chevrons, pointing together (>>text<<)
{ "\u2039\u3008", "\u203A\u3009" }, // single chevrons, pointing away (<text>)
{ "\u203A\u3009", "\u2039\u3008" }, // single chevrons, pointing together (>text<)
// Other
{ "\u300C\u300E", "\u300D\u300F" } // corner brackets (Japanese/Chinese)
};
private ISet<string> _flags; // Only parsed when requested first time
private int _ptr;
public string FullCommand { get; }
private struct WordPosition
{
// Start of the word
internal readonly int startPos;
// End of the word
internal readonly int endPos;
// How much to advance word pointer afterwards to point at the start of the *next* word
internal readonly int advanceAfterWord;
internal readonly bool wasQuoted;
public WordPosition(int startPos, int endPos, int advanceAfterWord, bool wasQuoted)
{
// Basic
{"'", "'"}, // ASCII single quotes
{"\"", "\""}, // ASCII double quotes
// "Smart quotes"
// Specifically ignore the left/right status of the quotes and match any combination of them
// Left string also includes "low" quotes to allow for the low-high style used in some locales
{"\u201C\u201D\u201F\u201E", "\u201C\u201D\u201F"}, // double quotes
{"\u2018\u2019\u201B\u201A", "\u2018\u2019\u201B"}, // single quotes
// Chevrons (normal and "fullwidth" variants)
{"\u00AB\u300A", "\u00BB\u300B"}, // double chevrons, pointing away (<<text>>)
{"\u00BB\u300B", "\u00AA\u300A"}, // double chevrons, pointing together (>>text<<)
{"\u2039\u3008", "\u203A\u3009"}, // single chevrons, pointing away (<text>)
{"\u203A\u3009", "\u2039\u3008"}, // single chevrons, pointing together (>text<)
// Other
{"\u300C\u300E", "\u300D\u300F"}, // corner brackets (Japanese/Chinese)
};
private readonly string _cmd;
private int _ptr;
private ISet<string> _flags = null; // Only parsed when requested first time
private struct WordPosition
{
// Start of the word
internal readonly int startPos;
// End of the word
internal readonly int endPos;
// How much to advance word pointer afterwards to point at the start of the *next* word
internal readonly int advanceAfterWord;
internal readonly bool wasQuoted;
public WordPosition(int startPos, int endPos, int advanceAfterWord, bool wasQuoted)
{
this.startPos = startPos;
this.endPos = endPos;
this.advanceAfterWord = advanceAfterWord;
this.wasQuoted = wasQuoted;
}
}
public Parameters(string cmd)
{
// This is a SUPER dirty hack to avoid having to match both spaces and newlines in the word detection below
// Instead, we just add a space before every newline (which then gets stripped out later).
_cmd = cmd.Replace("\n", " \n");
_ptr = 0;
}
private void ParseFlags()
{
_flags = new HashSet<string>();
var ptr = 0;
while (NextWordPosition(ptr) is { } wp)
{
ptr = wp.endPos + wp.advanceAfterWord;
// Is this word a *flag* (as in, starts with a - AND is not quoted)
if (_cmd[wp.startPos] != '-' || wp.wasQuoted) continue; // (if not, carry on w/ next word)
// Find the *end* of the flag start (technically allowing arbitrary amounts of dashes)
var flagNameStart = wp.startPos;
while (flagNameStart < _cmd.Length && _cmd[flagNameStart] == '-')
flagNameStart++;
// Then add the word to the flag set
var word = _cmd.Substring(flagNameStart, wp.endPos - flagNameStart).Trim();
if (word.Length > 0)
_flags.Add(word.ToLowerInvariant());
}
}
public string Pop()
{
// Loop to ignore and skip past flags
while (NextWordPosition(_ptr) is { } pos)
{
_ptr = pos.endPos + pos.advanceAfterWord;
if (_cmd[pos.startPos] == '-' && !pos.wasQuoted) continue;
return _cmd.Substring(pos.startPos, pos.endPos - pos.startPos).Trim();
}
return "";
}
public string Peek()
{
// Loop to ignore and skip past flags, temp ptr so we don't move the real ptr
var ptr = _ptr;
while (NextWordPosition(ptr) is { } pos)
{
ptr = pos.endPos + pos.advanceAfterWord;
if (_cmd[pos.startPos] == '-' && !pos.wasQuoted) continue;
return _cmd.Substring(pos.startPos, pos.endPos - pos.startPos).Trim();
}
return "";
}
public ISet<string> Flags()
{
if (_flags == null) ParseFlags();
return _flags;
}
public string Remainder(bool skipFlags = true)
{
if (skipFlags)
{
// Skip all *leading* flags when taking the remainder
while (NextWordPosition(_ptr) is { } wp)
{
if (_cmd[wp.startPos] != '-' || wp.wasQuoted) break;
_ptr = wp.endPos + wp.advanceAfterWord;
}
}
// *Then* get the remainder
return _cmd.Substring(Math.Min(_ptr, _cmd.Length)).Trim();
}
public string FullCommand => _cmd;
private WordPosition? NextWordPosition(int position)
{
// Skip leading spaces before actual content
while (position < _cmd.Length && _cmd[position] == ' ') position++;
// Is this the end of the string?
if (_cmd.Length <= position) return null;
// Is this a quoted word?
if (TryCheckQuote(_cmd[position], out var endQuotes))
{
// We found a quoted word - find an instance of one of the corresponding end quotes
var endQuotePosition = -1;
for (var i = position + 1; i < _cmd.Length; i++)
if (endQuotePosition == -1 && endQuotes.Contains(_cmd[i]))
endQuotePosition = i; // need a break; don't feel like brackets tho lol
// Position after the end quote should be EOL or a space
// Otherwise we fallthrough to the unquoted word handler below
if (_cmd.Length == endQuotePosition + 1 || _cmd[endQuotePosition + 1] == ' ')
return new WordPosition(position + 1, endQuotePosition, 2, true);
}
// Not a quoted word, just find the next space and return if it's the end of the command
var wordEnd = _cmd.IndexOf(' ', position + 1);
return wordEnd == -1
? new WordPosition(position, _cmd.Length, 0, false)
: new WordPosition(position, wordEnd, 1, false);
}
private bool TryCheckQuote(char potentialLeftQuote, out string correspondingRightQuotes)
{
foreach (var (left, right) in _quotePairs)
{
if (left.Contains(potentialLeftQuote))
{
correspondingRightQuotes = right;
return true;
}
}
correspondingRightQuotes = null;
return false;
this.startPos = startPos;
this.endPos = endPos;
this.advanceAfterWord = advanceAfterWord;
this.wasQuoted = wasQuoted;
}
}
public Parameters(string cmd)
{
// This is a SUPER dirty hack to avoid having to match both spaces and newlines in the word detection below
// Instead, we just add a space before every newline (which then gets stripped out later).
FullCommand = cmd.Replace("\n", " \n");
_ptr = 0;
}
private void ParseFlags()
{
_flags = new HashSet<string>();
var ptr = 0;
while (NextWordPosition(ptr) is { } wp)
{
ptr = wp.endPos + wp.advanceAfterWord;
// Is this word a *flag* (as in, starts with a - AND is not quoted)
if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) continue; // (if not, carry on w/ next word)
// Find the *end* of the flag start (technically allowing arbitrary amounts of dashes)
var flagNameStart = wp.startPos;
while (flagNameStart < FullCommand.Length && FullCommand[flagNameStart] == '-')
flagNameStart++;
// Then add the word to the flag set
var word = FullCommand.Substring(flagNameStart, wp.endPos - flagNameStart).Trim();
if (word.Length > 0)
_flags.Add(word.ToLowerInvariant());
}
}
public string Pop()
{
// Loop to ignore and skip past flags
while (NextWordPosition(_ptr) is { } pos)
{
_ptr = pos.endPos + pos.advanceAfterWord;
if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue;
return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim();
}
return "";
}
public string Peek()
{
// Loop to ignore and skip past flags, temp ptr so we don't move the real ptr
var ptr = _ptr;
while (NextWordPosition(ptr) is { } pos)
{
ptr = pos.endPos + pos.advanceAfterWord;
if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue;
return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim();
}
return "";
}
public ISet<string> Flags()
{
if (_flags == null) ParseFlags();
return _flags;
}
public string Remainder(bool skipFlags = true)
{
if (skipFlags)
// Skip all *leading* flags when taking the remainder
while (NextWordPosition(_ptr) is { } wp)
{
if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) break;
_ptr = wp.endPos + wp.advanceAfterWord;
}
// *Then* get the remainder
return FullCommand.Substring(Math.Min(_ptr, FullCommand.Length)).Trim();
}
private WordPosition? NextWordPosition(int position)
{
// Skip leading spaces before actual content
while (position < FullCommand.Length && FullCommand[position] == ' ') position++;
// Is this the end of the string?
if (FullCommand.Length <= position) return null;
// Is this a quoted word?
if (TryCheckQuote(FullCommand[position], out var endQuotes))
{
// We found a quoted word - find an instance of one of the corresponding end quotes
var endQuotePosition = -1;
for (var i = position + 1; i < FullCommand.Length; i++)
if (endQuotePosition == -1 && endQuotes.Contains(FullCommand[i]))
endQuotePosition = i; // need a break; don't feel like brackets tho lol
// Position after the end quote should be EOL or a space
// Otherwise we fallthrough to the unquoted word handler below
if (FullCommand.Length == endQuotePosition + 1 || FullCommand[endQuotePosition + 1] == ' ')
return new WordPosition(position + 1, endQuotePosition, 2, true);
}
// Not a quoted word, just find the next space and return if it's the end of the command
var wordEnd = FullCommand.IndexOf(' ', position + 1);
return wordEnd == -1
? new WordPosition(position, FullCommand.Length, 0, false)
: new WordPosition(position, wordEnd, 1, false);
}
private bool TryCheckQuote(char potentialLeftQuote, out string correspondingRightQuotes)
{
foreach (var (left, right) in _quotePairs)
if (left.Contains(potentialLeftQuote))
{
correspondingRightQuotes = right;
return true;
}
correspondingRightQuotes = null;
return false;
}
}