feat: implement proper ("static") parameters handling command parser -> bot

feat: handle few more commands bot side
fix(commands): handle missing parameters and return error
refactor(commands): use ordermap instead of relying on a sort function to sort tokens
This commit is contained in:
dusk 2025-01-05 13:00:06 +09:00
parent 1a781014bd
commit eec9f64026
No known key found for this signature in database
16 changed files with 358 additions and 502 deletions

View file

@ -4,22 +4,27 @@ namespace PluralKit.Bot;
public partial class CommandTree
{
public Task ExecuteCommand(Context ctx)
public Task ExecuteCommand(Context ctx, ResolvedParameters parameters)
{
switch (ctx.Parameters.Callback())
switch (parameters.Raw.Callback())
{
case "fun_thunder":
return ctx.Execute<Fun>(null, m => m.Thunder(ctx));
case "help":
return ctx.Execute<Help>(Help, m => m.HelpRoot(ctx));
case "help_commands":
return ctx.Reply("For the list of commands, see the website: <https://pluralkit.me/commands>");
return ctx.Reply(
"For the list of commands, see the website: <https://pluralkit.me/commands>");
case "help_proxy":
return ctx.Reply(
"The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying");
case "member_show":
return ctx.Execute<Member>(MemberInfo, m => m.ViewMember(ctx, parameters.MemberParams["target"]));
case "member_new":
return ctx.Execute<Member>(MemberNew, m => m.NewMember(ctx, parameters.Raw.Params()["name"]));
default:
// remove compiler warning
return ctx.Reply($"{Emojis.Error} Parsed command {ctx.Parameters.Callback().AsCode()} not implemented in PluralKit.Bot!");
return ctx.Reply(
$"{Emojis.Error} Parsed command {parameters.Raw.Callback().AsCode()} not implemented in PluralKit.Bot!");
}
if (ctx.Match("system", "s"))
return HandleSystemCommand(ctx);
@ -224,43 +229,44 @@ public partial class CommandTree
// finally, parse commands that *can* take a system target
else
{
// try matching a system ID
var target = await ctx.MatchSystem();
var previousPtr = ctx.Parameters._ptr;
// TODO: actually implement this
// // try matching a system ID
// var target = await ctx.MatchSystem();
// var previousPtr = ctx.Parameters._ptr;
// if we have a parsed target and no more commands, don't bother with the command flow
// we skip the `target != null` check here since the argument isn't be popped if it's not a system
if (!ctx.HasNext())
{
await ctx.Execute<System>(SystemInfo, m => m.Query(ctx, target ?? ctx.System));
return;
}
// // if we have a parsed target and no more commands, don't bother with the command flow
// // we skip the `target != null` check here since the argument isn't be popped if it's not a system
// if (!ctx.HasNext())
// {
// await ctx.Execute<System>(SystemInfo, m => m.Query(ctx, target ?? ctx.System));
// return;
// }
// hacky, but we need to CheckSystem(target) which throws a PKError
// normally PKErrors are only handled in ctx.Execute
try
{
await HandleSystemCommandTargeted(ctx, target ?? ctx.System);
}
catch (PKError e)
{
await ctx.Reply($"{Emojis.Error} {e.Message}");
return;
}
// // hacky, but we need to CheckSystem(target) which throws a PKError
// // normally PKErrors are only handled in ctx.Execute
// try
// {
// await HandleSystemCommandTargeted(ctx, target ?? ctx.System);
// }
// catch (PKError e)
// {
// await ctx.Reply($"{Emojis.Error} {e.Message}");
// return;
// }
// if we *still* haven't matched anything, the user entered an invalid command name or system reference
if (ctx.Parameters._ptr == previousPtr)
{
if (!ctx.Parameters.Peek().TryParseHid(out _) && !ctx.Parameters.Peek().TryParseMention(out _))
{
await PrintCommandNotFoundError(ctx, SystemCommands);
return;
}
// // if we *still* haven't matched anything, the user entered an invalid command name or system reference
// if (ctx.Parameters._ptr == previousPtr)
// {
// if (!ctx.Parameters.Peek().TryParseHid(out _) && !ctx.Parameters.Peek().TryParseMention(out _))
// {
// await PrintCommandNotFoundError(ctx, SystemCommands);
// return;
// }
var list = CreatePotentialCommandList(ctx.DefaultPrefix, SystemCommands);
await ctx.Reply($"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\n"
+ $"Perhaps you meant to use one of the following commands?\n{list}");
}
// var list = CreatePotentialCommandList(ctx.DefaultPrefix, SystemCommands);
// await ctx.Reply($"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\n"
// + $"Perhaps you meant to use one of the following commands?\n{list}");
// }
}
}
@ -324,20 +330,21 @@ public partial class CommandTree
private async Task HandleMemberCommand(Context ctx)
{
if (ctx.Match("new", "n", "add", "create", "register"))
await ctx.Execute<Member>(MemberNew, m => m.NewMember(ctx));
else if (ctx.Match("list"))
await ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, ctx.System));
else if (ctx.Match("commands", "help"))
await PrintCommandList(ctx, "members", MemberCommands);
else if (await ctx.MatchMember() is PKMember target)
await HandleMemberCommandTargeted(ctx, target);
else if (!ctx.HasNext())
await PrintCommandExpectedError(ctx, MemberNew, MemberInfo, MemberRename, MemberDisplayName,
MemberServerName, MemberDesc, MemberPronouns,
MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar);
else
await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Member", ctx.PopArgument())}");
// TODO: implement
// if (ctx.Match("new", "n", "add", "create", "register"))
// await ctx.Execute<Member>(MemberNew, m => m.NewMember(ctx));
// else if (ctx.Match("list"))
// await ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, ctx.System));
// else if (ctx.Match("commands", "help"))
// await PrintCommandList(ctx, "members", MemberCommands);
// else if (await ctx.MatchMember() is PKMember target)
// await HandleMemberCommandTargeted(ctx, target);
// else if (!ctx.HasNext())
// await PrintCommandExpectedError(ctx, MemberNew, MemberInfo, MemberRename, MemberDisplayName,
// MemberServerName, MemberDesc, MemberPronouns,
// MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar);
// else
// await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Member", ctx.PopArgument())}");
}
private async Task HandleMemberCommandTargeted(Context ctx, PKMember target)
@ -408,59 +415,60 @@ public partial class CommandTree
private async Task HandleGroupCommand(Context ctx)
{
// Commands with no group argument
if (ctx.Match("n", "new"))
await ctx.Execute<Groups>(GroupNew, g => g.CreateGroup(ctx));
else if (ctx.Match("list", "l"))
await ctx.Execute<Groups>(GroupList, g => g.ListSystemGroups(ctx, null));
else if (ctx.Match("commands", "help"))
await PrintCommandList(ctx, "groups", GroupCommands);
else if (await ctx.MatchGroup() is { } target)
{
// Commands with group argument
if (ctx.Match("rename", "name", "changename", "setname", "rn"))
await ctx.Execute<Groups>(GroupRename, g => g.RenameGroup(ctx, target));
else if (ctx.Match("nick", "dn", "displayname", "nickname"))
await ctx.Execute<Groups>(GroupDisplayName, g => g.GroupDisplayName(ctx, target));
else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro"))
await ctx.Execute<Groups>(GroupDesc, g => g.GroupDescription(ctx, target));
else if (ctx.Match("add", "a"))
await ctx.Execute<GroupMember>(GroupAdd,
g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add));
else if (ctx.Match("remove", "rem"))
await ctx.Execute<GroupMember>(GroupRemove,
g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove));
else if (ctx.Match("members", "list", "ms", "l", "ls"))
await ctx.Execute<GroupMember>(GroupMemberList, g => g.ListGroupMembers(ctx, target));
else if (ctx.Match("random", "rand", "r"))
await ctx.Execute<Random>(GroupMemberRandom, r => r.GroupMember(ctx, target));
else if (ctx.Match("privacy"))
await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null));
else if (ctx.Match("public", "pub"))
await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Public));
else if (ctx.Match("private", "priv"))
await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Private));
else if (ctx.Match("delete", "destroy", "erase", "yeet"))
await ctx.Execute<Groups>(GroupDelete, g => g.DeleteGroup(ctx, target));
else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp"))
await ctx.Execute<Groups>(GroupIcon, g => g.GroupIcon(ctx, target));
else if (ctx.Match("banner", "splash", "cover"))
await ctx.Execute<Groups>(GroupBannerImage, g => g.GroupBannerImage(ctx, target));
else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown"))
await ctx.Execute<SystemFront>(GroupFrontPercent, g => g.FrontPercent(ctx, group: target));
else if (ctx.Match("color", "colour"))
await ctx.Execute<Groups>(GroupColor, g => g.GroupColor(ctx, target));
else if (ctx.Match("id"))
await ctx.Execute<Groups>(GroupId, g => g.DisplayId(ctx, target));
else if (!ctx.HasNext())
await ctx.Execute<Groups>(GroupInfo, g => g.ShowGroupCard(ctx, target));
else
await PrintCommandNotFoundError(ctx, GroupCommandsTargeted);
}
else if (!ctx.HasNext())
await PrintCommandExpectedError(ctx, GroupCommands);
else
await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Group", ctx.PopArgument())}");
// TODO: implement
// // Commands with no group argument
// if (ctx.Match("n", "new"))
// await ctx.Execute<Groups>(GroupNew, g => g.CreateGroup(ctx));
// else if (ctx.Match("list", "l"))
// await ctx.Execute<Groups>(GroupList, g => g.ListSystemGroups(ctx, null));
// else if (ctx.Match("commands", "help"))
// await PrintCommandList(ctx, "groups", GroupCommands);
// else if (await ctx.MatchGroup() is { } target)
// {
// // Commands with group argument
// if (ctx.Match("rename", "name", "changename", "setname", "rn"))
// await ctx.Execute<Groups>(GroupRename, g => g.RenameGroup(ctx, target));
// else if (ctx.Match("nick", "dn", "displayname", "nickname"))
// await ctx.Execute<Groups>(GroupDisplayName, g => g.GroupDisplayName(ctx, target));
// else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro"))
// await ctx.Execute<Groups>(GroupDesc, g => g.GroupDescription(ctx, target));
// else if (ctx.Match("add", "a"))
// await ctx.Execute<GroupMember>(GroupAdd,
// g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add));
// else if (ctx.Match("remove", "rem"))
// await ctx.Execute<GroupMember>(GroupRemove,
// g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove));
// else if (ctx.Match("members", "list", "ms", "l", "ls"))
// await ctx.Execute<GroupMember>(GroupMemberList, g => g.ListGroupMembers(ctx, target));
// else if (ctx.Match("random", "rand", "r"))
// await ctx.Execute<Random>(GroupMemberRandom, r => r.GroupMember(ctx, target));
// else if (ctx.Match("privacy"))
// await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null));
// else if (ctx.Match("public", "pub"))
// await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Public));
// else if (ctx.Match("private", "priv"))
// await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Private));
// else if (ctx.Match("delete", "destroy", "erase", "yeet"))
// await ctx.Execute<Groups>(GroupDelete, g => g.DeleteGroup(ctx, target));
// else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp"))
// await ctx.Execute<Groups>(GroupIcon, g => g.GroupIcon(ctx, target));
// else if (ctx.Match("banner", "splash", "cover"))
// await ctx.Execute<Groups>(GroupBannerImage, g => g.GroupBannerImage(ctx, target));
// else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown"))
// await ctx.Execute<SystemFront>(GroupFrontPercent, g => g.FrontPercent(ctx, group: target));
// else if (ctx.Match("color", "colour"))
// await ctx.Execute<Groups>(GroupColor, g => g.GroupColor(ctx, target));
// else if (ctx.Match("id"))
// await ctx.Execute<Groups>(GroupId, g => g.DisplayId(ctx, target));
// else if (!ctx.HasNext())
// await ctx.Execute<Groups>(GroupInfo, g => g.ShowGroupCard(ctx, target));
// else
// await PrintCommandNotFoundError(ctx, GroupCommandsTargeted);
// }
// else if (!ctx.HasNext())
// await PrintCommandExpectedError(ctx, GroupCommands);
// else
// await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Group", ctx.PopArgument())}");
}
private async Task HandleSwitchCommand(Context ctx)

View file

@ -50,21 +50,6 @@ public class Context
DefaultPrefix = prefixes[0];
Rest = provider.Resolve<DiscordApiClient>();
Cluster = provider.Resolve<Cluster>();
try
{
Parameters = new ParametersFFI(message.Content?.Substring(commandParseOffset));
}
catch (PKError e)
{
// don't send an "invalid command" response if the guild has those turned off
if (!(GuildConfig != null && GuildConfig!.InvalidCommandResponseEnabled != true))
{
// todo: not this
Reply($"{Emojis.Error} {e.Message}");
}
throw;
}
}
public readonly IDiscordCache Cache;
@ -90,7 +75,6 @@ public class Context
public readonly string CommandPrefix;
public readonly string DefaultPrefix;
public readonly ParametersFFI Parameters;
internal readonly IDatabase Database;
internal readonly ModelRepository Repository;

View file

@ -8,20 +8,15 @@ namespace PluralKit.Bot;
public static class ContextArgumentsExt
{
public static string PopArgument(this Context ctx) =>
ctx.Parameters.Pop();
public static string PopArgument(this Context ctx) => throw new PKError("todo: PopArgument");
public static string PeekArgument(this Context ctx) =>
ctx.Parameters.Peek();
public static string PeekArgument(this Context ctx) => throw new PKError("todo: PeekArgument");
public static string RemainderOrNull(this Context ctx, bool skipFlags = true) =>
ctx.Parameters.Remainder(skipFlags).Length == 0 ? null : ctx.Parameters.Remainder(skipFlags);
public static string RemainderOrNull(this Context ctx, bool skipFlags = true) => throw new PKError("todo: RemainderOrNull");
public static bool HasNext(this Context ctx, bool skipFlags = true) =>
ctx.RemainderOrNull(skipFlags) != null;
public static bool HasNext(this Context ctx, bool skipFlags = true) => throw new PKError("todo: HasNext");
public static string FullCommand(this Context ctx) =>
ctx.Parameters.FullCommand;
public static string FullCommand(this Context ctx) => throw new PKError("todo: FullCommand");
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords and pops it from the stack. Case-insensitive.
@ -53,12 +48,7 @@ public static class ContextArgumentsExt
/// </summary>
public static bool PeekMatch(this Context ctx, ref int ptr, string[] potentialMatches)
{
var arg = ctx.Parameters.PeekWithPtr(ref ptr);
foreach (var match in potentialMatches)
if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase))
return true;
return false;
throw new PKError("todo: PeekMatch");
}
/// <summary>
@ -69,23 +59,14 @@ public static class ContextArgumentsExt
/// </summary>
public static bool MatchMultiple(this Context ctx, params string[][] potentialParametersMatches)
{
int ptr = ctx.Parameters._ptr;
foreach (var param in potentialParametersMatches)
if (!ctx.PeekMatch(ref ptr, param)) return false;
ctx.Parameters._ptr = ptr;
return true;
throw new PKError("todo: MatchMultiple");
}
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));
throw new NotImplementedException();
}
public static bool MatchClear(this Context ctx)
@ -100,11 +81,7 @@ public static class ContextArgumentsExt
public static ReplyFormat PeekMatchFormat(this Context ctx)
{
int ptr1 = ctx.Parameters._ptr;
int ptr2 = ctx.Parameters._ptr;
if (ctx.PeekMatch(ref ptr1, new[] { "r", "raw" }) || ctx.MatchFlag("r", "raw")) return ReplyFormat.Raw;
if (ctx.PeekMatch(ref ptr2, new[] { "pt", "plaintext" }) || ctx.MatchFlag("pt", "plaintext")) return ReplyFormat.Plaintext;
return ReplyFormat.Standard;
throw new PKError("todo: PeekMatchFormat");
}
public static bool MatchToggle(this Context ctx, bool? defaultValue = null)
@ -153,49 +130,12 @@ public static class ContextArgumentsExt
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;
throw new NotImplementedException();
}
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;
throw new NotImplementedException();
}
}

View file

@ -34,19 +34,15 @@ public static class ContextEntityArgumentsExt
return id != 0;
}
public static Task<PKSystem> PeekSystem(this Context ctx) => ctx.MatchSystemInner();
public static Task<PKSystem> PeekSystem(this Context ctx) => throw new NotImplementedException();
public static async Task<PKSystem> MatchSystem(this Context ctx)
{
var system = await ctx.MatchSystemInner();
if (system != null) ctx.PopArgument();
return system;
throw new NotImplementedException();
}
private static async Task<PKSystem> MatchSystemInner(this Context ctx)
public static async Task<PKSystem> ParseSystem(this Context ctx, string input)
{
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>)
@ -63,10 +59,8 @@ public static class ContextEntityArgumentsExt
return null;
}
public static async Task<PKMember> PeekMember(this Context ctx, SystemId? restrictToSystem = null)
public static async Task<PKMember> ParseMember(this Context ctx, Parameters parameters, string input, 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
@ -75,7 +69,7 @@ public static class ContextEntityArgumentsExt
// 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"))
if (ctx.System != null && !parameters.HasFlag("id", "by-id"))
{
// First, try finding by member name in system
if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName)
@ -124,6 +118,11 @@ public static class ContextEntityArgumentsExt
return null;
}
public static async Task<PKMember> PeekMember(this Context ctx, SystemId? restrictToSystem = null)
{
throw new NotImplementedException();
}
/// <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.
@ -170,9 +169,9 @@ public static class ContextEntityArgumentsExt
return group;
}
public static string CreateNotFoundError(this Context ctx, string entity, string input)
public static string CreateNotFoundError(this Context ctx, Parameters parameters, string entity, string input)
{
var isIDOnlyQuery = ctx.System == null || ctx.MatchFlag("id", "by-id");
var isIDOnlyQuery = ctx.System == null || parameters.HasFlag("id", "by-id");
var inputIsHid = HidUtils.ParseHid(input) != null;
if (isIDOnlyQuery)

View file

@ -1,185 +0,0 @@
namespace PluralKit.Bot;
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()
{
// 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", "\u00AB\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
public 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)
{
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()
{
// temp ptr so we don't move the real ptr
int ptr = _ptr;
return PeekWithPtr(ref ptr);
}
public string PeekWithPtr(ref int ptr)
{
// 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 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;
}
}

View file

@ -1,18 +1,19 @@
using PluralKit.Core;
using uniffi.commands;
namespace PluralKit.Bot;
public class ParametersFFI
public class Parameters
{
private string _cb { get; init; }
private List<string> _args { get; init; }
public int _ptr = -1;
private Dictionary<string, string?> _flags { get; init; }
private Dictionary<string, Parameter> _params { get; init; }
// just used for errors, temporarily
public string FullCommand { get; init; }
public ParametersFFI(string cmd)
public Parameters(string cmd)
{
FullCommand = cmd;
var result = CommandsMethods.ParseCommand(cmd);
@ -22,6 +23,7 @@ public class ParametersFFI
_cb = command.@commandRef;
_args = command.@args;
_flags = command.@flags;
_params = command.@params;
}
else
{
@ -29,43 +31,67 @@ public class ParametersFFI
}
}
public async Task<ResolvedParameters> ResolveParameters(Context ctx)
{
var parsed_members = await MemberParams().ToAsyncEnumerable().ToDictionaryAwaitAsync(async item => item.Key, async item =>
await ctx.ParseMember(this, item.Value) ?? throw new PKError(ctx.CreateNotFoundError(this, "Member", item.Value))
);
var parsed_systems = await SystemParams().ToAsyncEnumerable().ToDictionaryAwaitAsync(async item => item.Key, async item =>
await ctx.ParseSystem(item.Value) ?? throw new PKError(ctx.CreateNotFoundError(this, "System", item.Value))
);
return new ResolvedParameters(this, parsed_members, parsed_systems);
}
public string Callback()
{
return _cb;
}
public string Pop()
public IDictionary<string, string> Flags()
{
if (_args.Count > _ptr + 1) Console.WriteLine($"pop: {_ptr + 1}, {_args[_ptr + 1]}");
else Console.WriteLine("pop: no more arguments");
if (_args.Count() == _ptr + 1) return "";
_ptr++;
return _args[_ptr];
return _flags;
}
public string Peek()
private Dictionary<string, string> Params(Func<ParameterKind, bool> filter)
{
if (_args.Count > _ptr + 1) Console.WriteLine($"peek: {_ptr + 1}, {_args[_ptr + 1]}");
else Console.WriteLine("peek: no more arguments");
if (_args.Count() == _ptr + 1) return "";
return _args[_ptr + 1];
return _params.Where(item => filter(item.Value.@kind)).ToDictionary(item => item.Key, item => item.Value.@raw);
}
// this might not work quite right
public string PeekWithPtr(ref int ptr)
public IDictionary<string, string> Params()
{
return _args[ptr];
return Params(_ => true);
}
public ISet<string> Flags()
public IDictionary<string, string> MemberParams()
{
return new HashSet<string>(_flags.Keys);
return Params(kind => kind == ParameterKind.MemberRef);
}
// parsed differently in new commands, does this work right?
// note: skipFlags here does nothing
public string Remainder(bool skipFlags = false)
public IDictionary<string, string> SystemParams()
{
return Pop();
return Params(kind => kind == ParameterKind.SystemRef);
}
}
public class ResolvedParameters
{
public readonly Parameters Raw;
public readonly Dictionary<string, PKMember> MemberParams;
public readonly Dictionary<string, PKSystem> SystemParams;
public ResolvedParameters(Parameters parameters, Dictionary<string, PKMember> member_params, Dictionary<string, PKSystem> system_params)
{
Raw = parameters;
MemberParams = member_params;
SystemParams = system_params;
}
}
// TODO: move this to another file
public static class ParametersExt
{
public static bool HasFlag(this Parameters parameters, params string[] potentialMatches)
{
return potentialMatches.Any(parameters.Flags().ContainsKey);
}
}

View file

@ -27,10 +27,10 @@ public class Member
_avatarHosting = avatarHosting;
}
public async Task NewMember(Context ctx)
public async Task NewMember(Context ctx, string memberName)
{
if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
var memberName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a member name.");
memberName = memberName ?? throw new PKSyntaxError("You must pass a member name.");
// Hard name length cap
if (memberName.Length > Limits.MaxMemberNameLength)

View file

@ -137,8 +137,22 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
var system = await _repo.GetSystemByAccount(evt.Author.Id);
var config = system != null ? await _repo.GetSystemConfig(system.Id) : null;
var guildConfig = guild != null ? await _repo.GetGuild(guild.Id) : null;
await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes));
var ctx = new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes);
try
{
var parameters = new Parameters(evt.Content?.Substring(cmdStart));
var resolved_parameters = await parameters.ResolveParameters(ctx);
await _tree.ExecuteCommand(ctx, resolved_parameters);
}
catch (PKError e)
{
// don't send an "invalid command" response if the guild has those turned off
if (!(ctx.GuildConfig != null && ctx.GuildConfig!.InvalidCommandResponseEnabled != true))
{
await ctx.Reply($"{Emojis.Error} {e.Message}");
}
throw;
}
}
catch (PKError)
{