Refactor command system
This commit is contained in:
16
PluralKit.Bot/CommandSystem/Command.cs
Normal file
16
PluralKit.Bot/CommandSystem/Command.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace PluralKit.Bot.CommandSystem
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
public string Key { get; }
|
||||
public string Usage { get; }
|
||||
public string Description { get; }
|
||||
|
||||
public Command(string key, string usage, string description)
|
||||
{
|
||||
Key = key;
|
||||
Usage = usage;
|
||||
Description = description;
|
||||
}
|
||||
}
|
||||
}
|
19
PluralKit.Bot/CommandSystem/CommandGroup.cs
Normal file
19
PluralKit.Bot/CommandSystem/CommandGroup.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PluralKit.Bot.CommandSystem
|
||||
{
|
||||
public class CommandGroup
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
242
PluralKit.Bot/CommandSystem/Context.cs
Normal file
242
PluralKit.Bot/CommandSystem/Context.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Discord;
|
||||
using Discord.Net;
|
||||
using Discord.WebSocket;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace PluralKit.Bot.CommandSystem
|
||||
{
|
||||
public class Context
|
||||
{
|
||||
private IServiceProvider _provider;
|
||||
|
||||
private readonly DiscordShardedClient _client;
|
||||
private readonly SocketUserMessage _message;
|
||||
private readonly Parameters _parameters;
|
||||
|
||||
private readonly SystemStore _systems;
|
||||
private readonly MemberStore _members;
|
||||
private readonly PKSystem _senderSystem;
|
||||
|
||||
private Command _currentCommand;
|
||||
|
||||
public Context(IServiceProvider provider, SocketUserMessage message, int commandParseOffset,
|
||||
PKSystem senderSystem)
|
||||
{
|
||||
_client = provider.GetRequiredService<IDiscordClient>() as DiscordShardedClient;
|
||||
_message = message;
|
||||
_systems = provider.GetRequiredService<SystemStore>();
|
||||
_members = provider.GetRequiredService<MemberStore>();
|
||||
_senderSystem = senderSystem;
|
||||
_provider = provider;
|
||||
_parameters = new Parameters(message.Content.Substring(commandParseOffset));
|
||||
}
|
||||
|
||||
public IUser Author => _message.Author;
|
||||
public IMessageChannel Channel => _message.Channel;
|
||||
public IUserMessage Message => _message;
|
||||
public IGuild Guild => (_message.Channel as ITextChannel)?.Guild;
|
||||
public DiscordSocketClient Shard => _client.GetShardFor(Guild);
|
||||
public DiscordShardedClient Client => _client;
|
||||
public PKSystem System => _senderSystem;
|
||||
|
||||
public string PopArgument() => _parameters.Pop();
|
||||
public string PeekArgument() => _parameters.Peek();
|
||||
public string Remainder() => _parameters.Remainder();
|
||||
public string RemainderOrNull() => Remainder().Trim().Length == 0 ? null : Remainder();
|
||||
public bool HasNext() => RemainderOrNull() != null;
|
||||
public string FullCommand => _parameters.FullCommand;
|
||||
|
||||
public Task<IUserMessage> Reply(string text = null, Embed embed = null) =>
|
||||
Channel.SendMessageAsync(text, embed: embed);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
|
||||
/// </summary>
|
||||
public bool Match(params string[] potentialMatches)
|
||||
{
|
||||
foreach (var match in potentialMatches)
|
||||
{
|
||||
if (PeekArgument().Equals(match, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
PopArgument();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task Execute<T>(Command commandDef, Func<T, Task> handler)
|
||||
{
|
||||
_currentCommand = commandDef;
|
||||
|
||||
try
|
||||
{
|
||||
await handler(_provider.GetRequiredService<T>());
|
||||
}
|
||||
catch (PKSyntaxError e)
|
||||
{
|
||||
await Reply($"{Emojis.Error} {e.Message}\n**Command usage:** pk;{commandDef.Usage}");
|
||||
}
|
||||
catch (PKError e)
|
||||
{
|
||||
await Reply($"{Emojis.Error} {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IUser> MatchUser()
|
||||
{
|
||||
var text = PeekArgument();
|
||||
if (MentionUtils.TryParseUser(text, out var id))
|
||||
return await Shard.Rest.GetUserAsync(id); // TODO: this should properly fetch
|
||||
return null;
|
||||
}
|
||||
|
||||
public Task<PKSystem> PeekSystem() => MatchSystemInner();
|
||||
|
||||
public async Task<PKSystem> MatchSystem()
|
||||
{
|
||||
var system = await MatchSystemInner();
|
||||
if (system != null) PopArgument();
|
||||
return system;
|
||||
}
|
||||
|
||||
private async Task<PKSystem> MatchSystemInner()
|
||||
{
|
||||
var input = 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 _systems.GetByAccount(id);
|
||||
|
||||
// Finally, try HID parsing
|
||||
var system = await _systems.GetByHid(input);
|
||||
return system;
|
||||
}
|
||||
|
||||
public async Task<PKMember> PeekMember()
|
||||
{
|
||||
var input = PeekArgument();
|
||||
|
||||
// Member references can have one or two forms, depending on
|
||||
// whether you're in a system or not:
|
||||
// - A member hid
|
||||
// - A textual name of a member *in your own system*
|
||||
|
||||
// First, try member HID parsing:
|
||||
if (await _members.GetByHid(input) is PKMember memberByHid)
|
||||
return memberByHid;
|
||||
|
||||
// Then, if we have a system, try finding by member name in system
|
||||
if (_senderSystem != null && await _members.GetByName(_senderSystem, input) is PKMember memberByName)
|
||||
return memberByName;
|
||||
|
||||
// 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 async Task<PKMember> MatchMember()
|
||||
{
|
||||
// First, peek a member
|
||||
var member = await PeekMember();
|
||||
|
||||
// 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) PopArgument();
|
||||
|
||||
// Finally, we return the member value.
|
||||
return member;
|
||||
}
|
||||
|
||||
public string CreateMemberNotFoundError(string input)
|
||||
{
|
||||
// TODO: does this belong here?
|
||||
if (input.Length == 5)
|
||||
{
|
||||
if (_senderSystem != null)
|
||||
return $"Member with ID or name `{input}` not found.";
|
||||
return $"Member with ID `{input}` not found."; // Accounts without systems can't query by name
|
||||
}
|
||||
|
||||
if (_senderSystem != null)
|
||||
return $"Member with name `{input}` not found. Note that a member ID is 5 characters long.";
|
||||
return $"Member not found. Note that a member ID is 5 characters long.";
|
||||
}
|
||||
|
||||
public Context CheckSystem()
|
||||
{
|
||||
if (_senderSystem == null)
|
||||
throw Errors.NoSystemError;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Context CheckNoSystem()
|
||||
{
|
||||
if (_senderSystem != null)
|
||||
throw Errors.ExistingSystemError;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Context CheckOwnMember(PKMember member)
|
||||
{
|
||||
if (member.System != _senderSystem.Id)
|
||||
throw Errors.NotOwnMemberError;
|
||||
return this;
|
||||
}
|
||||
|
||||
public GuildPermissions GetGuildPermissions(IUser user)
|
||||
{
|
||||
if (Channel is SocketGuildChannel gc)
|
||||
return gc.GetUser(user.Id).GuildPermissions;
|
||||
return GuildPermissions.None;
|
||||
}
|
||||
|
||||
public ChannelPermissions GetChannelPermissions(IUser user)
|
||||
{
|
||||
if (Channel is SocketGuildChannel gc)
|
||||
return gc.GetUser(user.Id).GetPermissions(gc);
|
||||
return ChannelPermissions.DM;
|
||||
}
|
||||
|
||||
public Context CheckAuthorPermission(GuildPermission permission, string permissionName)
|
||||
{
|
||||
if (!GetGuildPermissions(Author).Has(permission))
|
||||
throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command.");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Context CheckAuthorPermission(ChannelPermission permission, string permissionName)
|
||||
{
|
||||
if (!GetChannelPermissions(Author).Has(permission))
|
||||
throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command.");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Context CheckGuildContext()
|
||||
{
|
||||
if (Channel is IGuildChannel) return this;
|
||||
throw new PKError("This command can not be run in a DM.");
|
||||
}
|
||||
|
||||
public ITextChannel MatchChannel()
|
||||
{
|
||||
if (!MentionUtils.TryParseChannel(PeekArgument(), out var channel)) return null;
|
||||
if (!(_client.GetChannel(channel) is ITextChannel textChannel)) return null;
|
||||
|
||||
PopArgument();
|
||||
return textChannel;
|
||||
}
|
||||
}
|
||||
}
|
77
PluralKit.Bot/CommandSystem/Parameters.cs
Normal file
77
PluralKit.Bot/CommandSystem/Parameters.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PluralKit.Bot.CommandSystem
|
||||
{
|
||||
public class Parameters
|
||||
{
|
||||
private static readonly Dictionary<char, char> _quotePairs = new Dictionary<char, char>()
|
||||
{
|
||||
{'\'', '\''}, {'"', '"'}
|
||||
};
|
||||
|
||||
private readonly string _cmd;
|
||||
private int _ptr;
|
||||
|
||||
public Parameters(string cmd)
|
||||
{
|
||||
_cmd = cmd;
|
||||
_ptr = 0;
|
||||
}
|
||||
|
||||
public string Pop()
|
||||
{
|
||||
var positions = NextWordPosition();
|
||||
if (positions == null) return "";
|
||||
|
||||
var (start, end, advance) = positions.Value;
|
||||
_ptr = end + advance;
|
||||
return _cmd.Substring(start, end - start).Trim();
|
||||
}
|
||||
|
||||
public string Peek()
|
||||
{
|
||||
var positions = NextWordPosition();
|
||||
if (positions == null) return "";
|
||||
|
||||
var (start, end, _) = positions.Value;
|
||||
return _cmd.Substring(start, end - start).Trim();
|
||||
}
|
||||
|
||||
public string Remainder() => _cmd.Substring(_ptr).Trim();
|
||||
public string FullCommand => _cmd;
|
||||
|
||||
// Returns tuple of (startpos, endpos, advanceafter)
|
||||
private ValueTuple<int, int, int>? NextWordPosition()
|
||||
{
|
||||
// Is this the end of the string?
|
||||
if (_cmd.Length <= _ptr) return null;
|
||||
|
||||
// Is this a quoted word?
|
||||
if (_quotePairs.ContainsKey(_cmd[_ptr]))
|
||||
{
|
||||
// This is a quoted word, find corresponding end quote and return span
|
||||
var endQuote = _quotePairs[_cmd[_ptr]];
|
||||
var endQuotePosition = _cmd.IndexOf(endQuote, _ptr + 1);
|
||||
|
||||
// Position after the end quote should be a space (or EOL)
|
||||
// Otherwise treat it as a standard word that's not quoted
|
||||
if (_cmd.Length == endQuotePosition + 1 || _cmd[endQuotePosition + 1] == ' ')
|
||||
{
|
||||
if (endQuotePosition == -1)
|
||||
{
|
||||
// This is an unterminated quoted word, just return the entire word including the start quote
|
||||
// TODO: should we do something else here?
|
||||
return (_ptr, _cmd.Length, 0);
|
||||
}
|
||||
|
||||
return (_ptr + 1, endQuotePosition, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Not a quoted word, just find the next space and return as appropriate
|
||||
var wordEnd = _cmd.IndexOf(' ', _ptr + 1);
|
||||
return wordEnd != -1 ? (_ptr, wordEnd, 1) : (_ptr, _cmd.Length, 0);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user