feat: better parameters handling, implement multi-token matching

This commit is contained in:
dusk 2025-01-07 23:15:18 +09:00
parent b29c51f103
commit 482c923507
No known key found for this signature in database
14 changed files with 521 additions and 251 deletions

View file

@ -4,30 +4,29 @@ namespace PluralKit.Bot;
public partial class CommandTree public partial class CommandTree
{ {
public Task ExecuteCommand(Context ctx, ResolvedParameters parameters) public Task ExecuteCommand(Context ctx)
{ {
switch (parameters.Raw.Callback()) return ctx.Parameters.Callback() switch
{ {
case "help": "help" => ctx.Execute<Help>(Help, m => m.HelpRoot(ctx)),
return ctx.Execute<Help>(Help, m => m.HelpRoot(ctx)); "help_commands" => ctx.Reply(
case "help_commands": "For the list of commands, see the website: <https://pluralkit.me/commands>"),
return ctx.Reply( "help_proxy" => ctx.Reply(
"For the list of commands, see the website: <https://pluralkit.me/commands>"); "The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"),
case "help_proxy": "member_show" => ctx.Execute<Member>(MemberInfo, m => m.ViewMember(ctx)),
return ctx.Reply( "member_new" => ctx.Execute<Member>(MemberNew, m => m.NewMember(ctx)),
"The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"); "member_soulscream" => ctx.Execute<Member>(MemberInfo, m => m.Soulscream(ctx)),
case "member_show": "cfg_ap_account_show" => ctx.Execute<Config>(null, m => m.ViewAutoproxyAccount(ctx)),
return ctx.Execute<Member>(MemberInfo, m => m.ViewMember(ctx, parameters.MemberParams["target"])); "cfg_ap_account_update" => ctx.Execute<Config>(null, m => m.EditAutoproxyAccount(ctx)),
case "member_new": "cfg_ap_timeout_show" => ctx.Execute<Config>(null, m => m.ViewAutoproxyTimeout(ctx)),
return ctx.Execute<Member>(MemberNew, m => m.NewMember(ctx, parameters.Raw.Params()["name"])); "cfg_ap_timeout_update" => ctx.Execute<Config>(null, m => m.EditAutoproxyTimeout(ctx)),
case "fun_thunder": "fun_thunder" => ctx.Execute<Fun>(null, m => m.Thunder(ctx)),
return ctx.Execute<Fun>(null, m => m.Thunder(ctx)); "fun_meow" => ctx.Execute<Fun>(null, m => m.Meow(ctx)),
case "fun_meow": _ =>
return ctx.Execute<Fun>(null, m => m.Meow(ctx)); // this should only ever occur when deving if commands are not implemented...
default: ctx.Reply(
return ctx.Reply( $"{Emojis.Error} Parsed command {ctx.Parameters.Callback().AsCode()} not implemented in PluralKit.Bot!"),
$"{Emojis.Error} Parsed command {parameters.Raw.Callback().AsCode()} not implemented in PluralKit.Bot!"); };
}
if (ctx.Match("system", "s")) if (ctx.Match("system", "s"))
return HandleSystemCommand(ctx); return HandleSystemCommand(ctx);
if (ctx.Match("member", "m")) if (ctx.Match("member", "m"))
@ -405,10 +404,6 @@ public partial class CommandTree
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private)); await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private));
else if (ctx.Match("public", "shown", "show", "unhide", "unhidden")) else if (ctx.Match("public", "shown", "show", "unhide", "unhidden"))
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public)); await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public));
else if (ctx.Match("soulscream"))
await ctx.Execute<Member>(MemberInfo, m => m.Soulscream(ctx, target));
else if (!ctx.HasNext()) // Bare command
await ctx.Execute<Member>(MemberInfo, m => m.ViewMember(ctx, target));
else else
await PrintCommandNotFoundError(ctx, MemberInfo, MemberRename, MemberDisplayName, MemberServerName, await PrintCommandNotFoundError(ctx, MemberInfo, MemberRename, MemberDisplayName, MemberServerName,
MemberDesc, MemberPronouns, MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar, MemberDesc, MemberPronouns, MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar,
@ -576,10 +571,6 @@ public partial class CommandTree
if (!ctx.HasNext()) if (!ctx.HasNext())
return ctx.Execute<Config>(null, m => m.ShowConfig(ctx)); return ctx.Execute<Config>(null, m => m.ShowConfig(ctx));
if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "account", "ac" }))
return ctx.Execute<Config>(null, m => m.AutoproxyAccount(ctx));
if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "timeout", "tm" }))
return ctx.Execute<Config>(null, m => m.AutoproxyTimeout(ctx));
if (ctx.Match("timezone", "zone", "tz")) if (ctx.Match("timezone", "zone", "tz"))
return ctx.Execute<Config>(null, m => m.SystemTimezone(ctx)); return ctx.Execute<Config>(null, m => m.SystemTimezone(ctx));
if (ctx.Match("ping")) if (ctx.Match("ping"))

View file

@ -26,11 +26,9 @@ public class Context
private readonly IMetrics _metrics; private readonly IMetrics _metrics;
private readonly CommandMessageService _commandMessageService; private readonly CommandMessageService _commandMessageService;
private Command? _currentCommand;
public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message, public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message,
int commandParseOffset, PKSystem senderSystem, SystemConfig config, int commandParseOffset, PKSystem senderSystem, SystemConfig config,
GuildConfig? guildConfig, string[] prefixes) GuildConfig? guildConfig, string[] prefixes, Parameters parameters)
{ {
Message = (Message)message; Message = (Message)message;
ShardId = shardId; ShardId = shardId;
@ -50,6 +48,7 @@ public class Context
DefaultPrefix = prefixes[0]; DefaultPrefix = prefixes[0];
Rest = provider.Resolve<DiscordApiClient>(); Rest = provider.Resolve<DiscordApiClient>();
Cluster = provider.Resolve<Cluster>(); Cluster = provider.Resolve<Cluster>();
Parameters = parameters;
} }
public readonly IDiscordCache Cache; public readonly IDiscordCache Cache;
@ -75,6 +74,7 @@ public class Context
public readonly string CommandPrefix; public readonly string CommandPrefix;
public readonly string DefaultPrefix; public readonly string DefaultPrefix;
public readonly Parameters Parameters;
internal readonly IDatabase Database; internal readonly IDatabase Database;
internal readonly ModelRepository Repository; internal readonly ModelRepository Repository;
@ -111,8 +111,6 @@ public class Context
public async Task Execute<T>(Command? commandDef, Func<T, Task> handler, bool deprecated = false) public async Task Execute<T>(Command? commandDef, Func<T, Task> handler, bool deprecated = false)
{ {
_currentCommand = commandDef;
if (deprecated && commandDef != null) if (deprecated && commandDef != null)
{ {
await Reply($"{Emojis.Warn} Server configuration has moved to `{DefaultPrefix}serverconfig`. The command you are trying to run is now `{DefaultPrefix}{commandDef.Key}`."); await Reply($"{Emojis.Warn} Server configuration has moved to `{DefaultPrefix}serverconfig`. The command you are trying to run is now `{DefaultPrefix}{commandDef.Key}`.");
@ -153,8 +151,8 @@ public class Context
public LookupContext LookupContextFor(SystemId systemId) public LookupContext LookupContextFor(SystemId systemId)
{ {
var hasPrivateOverride = this.MatchFlag("private", "priv"); var hasPrivateOverride = Parameters.HasFlag("private", "priv");
var hasPublicOverride = this.MatchFlag("public", "pub"); var hasPublicOverride = Parameters.HasFlag("public", "pub");
if (hasPrivateOverride && hasPublicOverride) if (hasPrivateOverride && hasPublicOverride)
throw new PKError("Cannot match both public and private flags at the same time."); throw new PKError("Cannot match both public and private flags at the same time.");

View file

@ -59,7 +59,7 @@ public static class ContextEntityArgumentsExt
return null; return null;
} }
public static async Task<PKMember> ParseMember(this Context ctx, Parameters parameters, string input, SystemId? restrictToSystem = null) public static async Task<PKMember> ParseMember(this Context ctx, string input, bool byId, SystemId? restrictToSystem = null)
{ {
// Member references can have one of three forms, depending on // Member references can have one of three forms, depending on
// whether you're in a system or not: // whether you're in a system or not:
@ -69,7 +69,7 @@ public static class ContextEntityArgumentsExt
// Skip name / display name matching if the user does not have a system // Skip name / display name matching if the user does not have a system
// or if they specifically request by-HID matching // or if they specifically request by-HID matching
if (ctx.System != null && !parameters.HasFlag("id", "by-id")) if (ctx.System != null && !byId)
{ {
// First, try finding by member name in system // First, try finding by member name in system
if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName) if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName)
@ -169,9 +169,9 @@ public static class ContextEntityArgumentsExt
return group; return group;
} }
public static string CreateNotFoundError(this Context ctx, Parameters parameters, string entity, string input) public static string CreateNotFoundError(this Context ctx, string entity, string input, bool byId = false)
{ {
var isIDOnlyQuery = ctx.System == null || parameters.HasFlag("id", "by-id"); var isIDOnlyQuery = ctx.System == null || byId;
var inputIsHid = HidUtils.ParseHid(input) != null; var inputIsHid = HidUtils.ParseHid(input) != null;
if (isIDOnlyQuery) if (isIDOnlyQuery)

View file

@ -0,0 +1,64 @@
using PluralKit.Core;
namespace PluralKit.Bot;
public static class ContextParametersExt
{
public static async Task<string?> ParamResolveOpaque(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.Opaque)?.value
);
}
public static async Task<PKMember?> ParamResolveMember(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.MemberRef)?.member
);
}
public static async Task<PKSystem?> ParamResolveSystem(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.SystemRef)?.system
);
}
public static async Task<MemberPrivacySubject?> ParamResolveMemberPrivacyTarget(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.MemberPrivacyTarget)?.target
);
}
public static async Task<string?> ParamResolvePrivacyLevel(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.PrivacyLevel)?.level
);
}
public static async Task<bool?> ParamResolveToggle(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.Toggle)?.value
);
}
// this can never really be false (either it's present and is true or it's not present)
// but we keep it nullable for consistency with the other methods
public static async Task<bool?> ParamResolveReset(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter<bool?>(
ctx, param_name,
param => param is Parameter.Reset
);
}
}

View file

@ -1,14 +1,27 @@
using System.Diagnostics;
using PluralKit.Core; using PluralKit.Core;
using uniffi.commands; using uniffi.commands;
namespace PluralKit.Bot; namespace PluralKit.Bot;
// corresponds to the ffi Paramater type, but with stricter types (also avoiding exposing ffi types!)
public abstract record Parameter()
{
public record MemberRef(PKMember member): Parameter;
public record SystemRef(PKSystem system): Parameter;
public record MemberPrivacyTarget(MemberPrivacySubject target): Parameter;
public record PrivacyLevel(string level): Parameter;
public record Toggle(bool value): Parameter;
public record Opaque(string value): Parameter;
public record Reset(): Parameter;
}
public class Parameters public class Parameters
{ {
private string _cb { get; init; } private string _cb { get; init; }
private List<string> _args { get; init; } private List<string> _args { get; init; }
private Dictionary<string, string?> _flags { get; init; } private Dictionary<string, string?> _flags { get; init; }
private Dictionary<string, Parameter> _params { get; init; } private Dictionary<string, uniffi.commands.Parameter> _params { get; init; }
// just used for errors, temporarily // just used for errors, temporarily
public string FullCommand { get; init; } public string FullCommand { get; init; }
@ -31,68 +44,61 @@ public class Parameters
} }
} }
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() public string Callback()
{ {
return _cb; return _cb;
} }
public IDictionary<string, string> Flags() public bool HasFlag(params string[] potentialMatches)
{ {
return _flags; return potentialMatches.Any(_flags.ContainsKey);
} }
private Dictionary<string, string> Params(Func<ParameterKind, bool> filter) // resolves a single parameter
private async Task<Parameter?> ResolveParameter(Context ctx, string param_name)
{ {
return _params.Where(item => filter(item.Value.@kind)).ToDictionary(item => item.Key, item => item.Value.@raw); if (!_params.ContainsKey(param_name)) return null;
switch (_params[param_name])
{
case uniffi.commands.Parameter.MemberRef memberRef:
var byId = HasFlag("id", "by-id");
return new Parameter.MemberRef(
await ctx.ParseMember(memberRef.member, byId)
?? throw new PKError(ctx.CreateNotFoundError("Member", memberRef.member, byId))
);
case uniffi.commands.Parameter.SystemRef systemRef:
// todo: do we need byId here?
return new Parameter.SystemRef(
await ctx.ParseSystem(systemRef.system)
?? throw new PKError(ctx.CreateNotFoundError("System", systemRef.system))
);
case uniffi.commands.Parameter.MemberPrivacyTarget memberPrivacyTarget:
// this should never really fail...
// todo: we shouldn't have *three* different MemberPrivacyTarget types (rust, ffi, c#) syncing the cases will be annoying...
if (!MemberPrivacyUtils.TryParseMemberPrivacy(memberPrivacyTarget.target, out var target))
throw new PKError($"Invalid member privacy target {memberPrivacyTarget.target}");
return new Parameter.MemberPrivacyTarget(target);
case uniffi.commands.Parameter.PrivacyLevel privacyLevel:
return new Parameter.PrivacyLevel(privacyLevel.level);
case uniffi.commands.Parameter.Toggle toggle:
return new Parameter.Toggle(toggle.toggle);
case uniffi.commands.Parameter.OpaqueString opaque:
return new Parameter.Opaque(opaque.raw);
case uniffi.commands.Parameter.Reset _:
return new Parameter.Reset();
}
// this should also never happen
throw new PKError($"Unknown parameter type for parameter {param_name}");
} }
public IDictionary<string, string> Params() public async Task<T> ResolveParameter<T>(Context ctx, string param_name, Func<Parameter, T?> extract_func)
{ {
return Params(_ => true); var param = await ResolveParameter(ctx, param_name);
// todo: i think this should return null for everything...?
if (param == null) return default;
return extract_func(param)
// this should never really happen (hopefully!), but in case the parameter names dont match up (typos...) between rust <-> c#...
// (it would be very cool to have this statically checked somehow..?)
?? throw new PKError($"Parameter {param_name.AsCode()} was not found for command {Callback().AsCode()} -- this is a bug!!");
} }
}
public IDictionary<string, string> MemberParams()
{
return Params(kind => kind == ParameterKind.MemberRef);
}
public IDictionary<string, string> SystemParams()
{
return Params(kind => kind == ParameterKind.SystemRef);
}
}
// TODO: im not really sure if this should be the way to go
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

@ -190,17 +190,17 @@ public class Config
} }
private string EnabledDisabled(bool value) => value ? "enabled" : "disabled"; private string EnabledDisabled(bool value) => value ? "enabled" : "disabled";
public async Task AutoproxyAccount(Context ctx) public async Task ViewAutoproxyAccount(Context ctx)
{ {
var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id); var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id);
if (!ctx.HasNext()) await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(allowAutoproxy)}** for account <@{ctx.Author.Id}>.");
{ }
await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(allowAutoproxy)}** for account <@{ctx.Author.Id}>.");
return;
}
var allow = ctx.MatchToggle(true); public async Task EditAutoproxyAccount(Context ctx)
{
var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id);
var allow = await ctx.ParamResolveToggle("toggle") ?? throw new PKSyntaxError("You need to specify whether to enable or disable autoproxy for this account.");
var statusString = EnabledDisabled(allow); var statusString = EnabledDisabled(allow);
if (allowAutoproxy == allow) if (allowAutoproxy == allow)
@ -213,31 +213,34 @@ public class Config
await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>."); await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.");
} }
public async Task ViewAutoproxyTimeout(Context ctx)
public async Task AutoproxyTimeout(Context ctx)
{ {
if (!ctx.HasNext()) var timeout = ctx.Config.LatchTimeout.HasValue
{ ? Duration.FromSeconds(ctx.Config.LatchTimeout.Value)
var timeout = ctx.Config.LatchTimeout.HasValue : (Duration?)null;
? Duration.FromSeconds(ctx.Config.LatchTimeout.Value)
: (Duration?)null;
if (timeout == null) if (timeout == null)
await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}."); await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}.");
else if (timeout == Duration.Zero) else if (timeout == Duration.Zero)
await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out."); await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out.");
else else
await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}."); await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}.");
return; }
}
public async Task EditAutoproxyTimeout(Context ctx)
{
var _newTimeout = await ctx.ParamResolveOpaque("timeout");
var _reset = await ctx.ParamResolveReset("reset");
var _toggle = await ctx.ParamResolveToggle("toggle");
Duration? newTimeout; Duration? newTimeout;
Duration overflow = Duration.Zero; Duration overflow = Duration.Zero;
if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) newTimeout = Duration.Zero; if (_toggle == false) newTimeout = Duration.Zero;
else if (ctx.MatchClear()) newTimeout = null; else if (_reset == true) newTimeout = null;
else else
{ {
var timeoutStr = ctx.RemainderOrNull(); // todo: we should parse date in the command parser
var timeoutStr = _newTimeout;
var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr); var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr);
if (timeoutPeriod == null) throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes)."); if (timeoutPeriod == null) throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes).");
if (timeoutPeriod.Value.TotalHours > 100000) if (timeoutPeriod.Value.TotalHours > 100000)

View file

@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Reflection.Metadata;
using System.Web; using System.Web;
using Dapper; using Dapper;
@ -27,8 +28,10 @@ public class Member
_avatarHosting = avatarHosting; _avatarHosting = avatarHosting;
} }
public async Task NewMember(Context ctx, string memberName) public async Task NewMember(Context ctx)
{ {
var memberName = await ctx.ParamResolveOpaque("name");
if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix); if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
memberName = memberName ?? throw new PKSyntaxError("You must pass a member name."); memberName = memberName ?? throw new PKSyntaxError("You must pass a member name.");
@ -124,17 +127,19 @@ public class Member
$"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {memberLimit} members). Once you reach this limit, you will be unable to create new members until existing members are deleted, or you can ask for your limit to be raised in the PluralKit support server: <https://discord.gg/PczBt78>"); $"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {memberLimit} members). Once you reach this limit, you will be unable to create new members until existing members are deleted, or you can ask for your limit to be raised in the PluralKit support server: <https://discord.gg/PczBt78>");
} }
public async Task ViewMember(Context ctx, PKMember target) public async Task ViewMember(Context ctx)
{ {
var target = await ctx.ParamResolveMember("target");
var system = await ctx.Repository.GetSystem(target.System); var system = await ctx.Repository.GetSystem(target.System);
await ctx.Reply( await ctx.Reply(
embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.Config, ctx.LookupContextFor(system.Id), ctx.Zone)); embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.Config, ctx.LookupContextFor(system.Id), ctx.Zone));
} }
public async Task Soulscream(Context ctx, PKMember target) public async Task Soulscream(Context ctx)
{ {
// this is for a meme, please don't take this code seriously. :) // this is for a meme, please don't take this code seriously. :)
var target = await ctx.ParamResolveMember("target");
var name = target.NameFor(ctx.LookupContextFor(target.System)); var name = target.NameFor(ctx.LookupContextFor(target.System));
var encoded = HttpUtility.UrlEncode(name); var encoded = HttpUtility.UrlEncode(name);

View file

@ -137,23 +137,29 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
var system = await _repo.GetSystemByAccount(evt.Author.Id); var system = await _repo.GetSystemByAccount(evt.Author.Id);
var config = system != null ? await _repo.GetSystemConfig(system.Id) : null; var config = system != null ? await _repo.GetSystemConfig(system.Id) : null;
var guildConfig = guild != null ? await _repo.GetGuild(guild.Id) : null; var guildConfig = guild != null ? await _repo.GetGuild(guild.Id) : null;
var ctx = new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes);
// parse parameters
Parameters parameters;
try try
{ {
var parameters = new Parameters(evt.Content?.Substring(cmdStart)); parameters = new Parameters(evt.Content?.Substring(cmdStart));
var resolved_parameters = await parameters.ResolveParameters(ctx);
await _tree.ExecuteCommand(ctx, resolved_parameters);
} }
catch (PKError e) catch (PKError e)
{ {
// don't send an "invalid command" response if the guild has those turned off // don't send an "invalid command" response if the guild has those turned off
// TODO: only dont send command not found, not every parse error (eg. missing params, syntax error...) // TODO: only dont send command not found, not every parse error (eg. missing params, syntax error...)
if (!(ctx.GuildConfig != null && ctx.GuildConfig!.InvalidCommandResponseEnabled != true)) if (!(guildConfig != null && guildConfig!.InvalidCommandResponseEnabled != true))
{ {
await ctx.Reply($"{Emojis.Error} {e.Message}"); await _rest.CreateMessage(channel.Id, new MessageRequest
{
Content = $"{Emojis.Error} {e.Message}",
});
} }
throw; throw;
} }
var ctx = new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes, parameters);
await _tree.ExecuteCommand(ctx);
} }
catch (PKError) catch (PKError)
{ {
@ -212,4 +218,4 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
return false; return false;
} }
} }

View file

@ -58,6 +58,7 @@ pub fn all() -> Vec<Command> {
(help::cmds()) (help::cmds())
.chain(system::cmds()) .chain(system::cmds())
.chain(member::cmds()) .chain(member::cmds())
.chain(config::cmds())
.chain(fun::cmds()) .chain(fun::cmds())
.collect() .collect()
} }

View file

@ -7,16 +7,14 @@ interface CommandResult {
Err(string error); Err(string error);
}; };
[Enum] [Enum]
interface ParameterKind { interface Parameter {
MemberRef(); MemberRef(string member);
SystemRef(); SystemRef(string system);
MemberPrivacyTarget(); MemberPrivacyTarget(string target);
PrivacyLevel(); PrivacyLevel(string level);
OpaqueString(); OpaqueString(string raw);
}; Toggle(boolean toggle);
dictionary Parameter { Reset();
string raw;
ParameterKind kind;
}; };
dictionary ParsedCommand { dictionary ParsedCommand {
string command_ref; string command_ref;

View file

@ -1 +1,32 @@
use super::*;
pub fn cmds() -> impl Iterator<Item = Command> {
use Token::*;
let cfg = ["config", "cfg"];
let autoproxy = ["autoproxy", "ap"];
[
command!(
[cfg, autoproxy, ["account", "ac"]],
"cfg_ap_account_show",
"Shows autoproxy status for the account"
),
command!(
[cfg, autoproxy, ["account", "ac"], Toggle("toggle")],
"cfg_ap_account_update",
"Toggles autoproxy for the account"
),
command!(
[cfg, autoproxy, ["timeout", "tm"]],
"cfg_ap_timeout_show",
"Shows the autoproxy timeout"
),
command!(
[cfg, autoproxy, ["timeout", "tm"], [Toggle("toggle"), Reset("reset"), FullString("timeout")]],
"cfg_ap_timeout_update",
"Sets the autoproxy timeout"
),
]
.into_iter()
}

View file

@ -19,6 +19,11 @@ pub fn cmds() -> impl Iterator<Item = Command> {
"member_show", "member_show",
"Shows information about a member" "Shows information about a member"
), ),
command!(
[member, MemberRef("target"), "soulscream"],
"member_soulscream",
"todo"
),
command!( command!(
[member, MemberRef("target"), description], [member, MemberRef("target"), description],
"member_desc_show", "member_desc_show",

View file

@ -36,48 +36,15 @@ pub enum CommandResult {
Err { error: String }, Err { error: String },
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub enum ParameterKind { pub enum Parameter {
MemberRef, MemberRef { member: String },
SystemRef, SystemRef { system: String },
MemberPrivacyTarget, MemberPrivacyTarget { target: String },
PrivacyLevel, PrivacyLevel { level: String },
OpaqueString, OpaqueString { raw: String },
} Toggle { toggle: bool },
Reset,
#[derive(Debug)]
pub struct Parameter {
raw: String,
kind: ParameterKind,
}
impl Parameter {
fn new(raw: impl ToString, kind: ParameterKind) -> Self {
Self {
raw: raw.to_string(),
kind,
}
}
}
macro_rules! parameter_impl {
($($name:ident $kind:ident),*) => {
impl Parameter {
$(
fn $name(raw: impl ToString) -> Self {
Self::new(raw, $crate::ParameterKind::$kind)
}
)*
}
};
}
parameter_impl! {
opaque OpaqueString,
member MemberRef,
system SystemRef,
member_privacy_target MemberPrivacyTarget,
privacy_level PrivacyLevel
} }
#[derive(Debug)] #[derive(Debug)]
@ -111,27 +78,17 @@ fn parse_command(input: String) -> CommandResult {
Ok((found_token, arg, new_pos)) => { Ok((found_token, arg, new_pos)) => {
current_pos = new_pos; current_pos = new_pos;
if let Token::Flag = found_token { if let Token::Flag = found_token {
flags.insert(arg.unwrap().into(), None); flags.insert(arg.unwrap().raw.into(), None);
// don't try matching flags as tree elements // don't try matching flags as tree elements
continue; continue;
} }
if let Some(arg) = arg.as_ref() { if let Some(arg) = arg.as_ref() {
// get param name from token
// TODO: idk if this should be on token itself, doesn't feel right, but does work
let param = match &found_token {
Token::FullString(n) => Some((n, Parameter::opaque(arg))),
Token::MemberRef(n) => Some((n, Parameter::member(arg))),
Token::MemberPrivacyTarget(n) => Some((n, Parameter::member_privacy_target(arg))),
Token::SystemRef(n) => Some((n, Parameter::system(arg))),
Token::PrivacyLevel(n) => Some((n, Parameter::privacy_level(arg))),
_ => None,
};
// insert arg as paramater if this is a parameter // insert arg as paramater if this is a parameter
if let Some((param_name, param)) = param { if let Some((param_name, param)) = arg.param.as_ref() {
params.insert(param_name.to_string(), param); params.insert(param_name.to_string(), param.clone());
} }
args.push(arg.to_string()); args.push(arg.raw.to_string());
} }
if let Some(next_tree) = local_tree.branches.get(&found_token) { if let Some(next_tree) = local_tree.branches.get(&found_token) {
@ -178,7 +135,7 @@ fn next_token(
possible_tokens: Vec<Token>, possible_tokens: Vec<Token>,
input: SmolStr, input: SmolStr,
current_pos: usize, current_pos: usize,
) -> Result<(Token, Option<SmolStr>, usize), Option<SmolStr>> { ) -> Result<(Token, Option<TokenMatchedValue>, usize), Option<SmolStr>> {
// get next parameter, matching quotes // get next parameter, matching quotes
let param = crate::string::next_param(input.clone(), current_pos); let param = crate::string::next_param(input.clone(), current_pos);
println!("matched: {param:?}\n---"); println!("matched: {param:?}\n---");
@ -191,7 +148,10 @@ fn next_token(
{ {
return Ok(( return Ok((
Token::Flag, Token::Flag,
Some(value.trim_start_matches('-').into()), Some(TokenMatchedValue {
raw: value,
param: None,
}),
new_pos, new_pos,
)); ));
} }

View file

@ -1,5 +1,9 @@
use std::str::FromStr;
use smol_str::{SmolStr, ToSmolStr}; use smol_str::{SmolStr, ToSmolStr};
use crate::Parameter;
type ParamName = &'static str; type ParamName = &'static str;
#[derive(Debug, Clone, Eq, Hash, PartialEq)] #[derive(Debug, Clone, Eq, Hash, PartialEq)]
@ -8,86 +12,180 @@ pub enum Token {
// todo: this is likely not the right way to represent this // todo: this is likely not the right way to represent this
Empty, Empty,
/// A bot-defined value ("member" in `pk;member MyName`) /// multi-token matching
Value(Vec<SmolStr>), Any(Vec<Token>),
/// A command defined by multiple values
// todo!
MultiValue(Vec<Vec<SmolStr>>),
/// A bot-defined command / subcommand (usually) (eg. "member" in `pk;member MyName`)
Value(Vec<SmolStr>),
/// Opaque string (eg. "name" in `pk;member new name`)
FullString(ParamName), FullString(ParamName),
/// Member reference (hid or member name) /// Member reference (hid or member name)
MemberRef(ParamName), MemberRef(ParamName),
/// todo: doc
MemberPrivacyTarget(ParamName), MemberPrivacyTarget(ParamName),
/// System reference /// System reference
SystemRef(ParamName), SystemRef(ParamName),
/// todo: doc
PrivacyLevel(ParamName), PrivacyLevel(ParamName),
// currently not included in command definitions /// on, off; yes, no; true, false
Toggle(ParamName),
/// reset, clear, default
Reset(ParamName),
// todo: currently not included in command definitions
// todo: flags with values // todo: flags with values
Flag, Flag,
} }
// #[macro_export]
// macro_rules! any {
// ($($token:expr),+) => {
// Token::Any(vec![$($token.to_token()),+])
// };
// }
#[derive(Debug)]
pub enum TokenMatchResult { pub enum TokenMatchResult {
/// Token did not match.
NoMatch, NoMatch,
/// Token matched, optionally with a value. /// Token matched, optionally with a value.
Match(Option<SmolStr>), Match(Option<TokenMatchedValue>),
MissingParameter { /// A required parameter was missing.
name: ParamName, MissingParameter { name: ParamName },
},
} }
// move this somewhere else #[derive(Debug)]
const MEMBER_PRIVACY_TARGETS: &[&str] = &["visibility", "name", "todo"]; pub struct TokenMatchedValue {
pub raw: SmolStr,
pub param: Option<(ParamName, Parameter)>,
}
impl Token { impl TokenMatchResult {
pub fn try_match(&self, input: Option<SmolStr>) -> TokenMatchResult { fn new_match(raw: impl Into<SmolStr>) -> Self {
// short circuit on empty things Self::Match(Some(TokenMatchedValue {
if matches!(self, Self::Empty) && input.is_none() { raw: raw.into(),
return TokenMatchResult::Match(None); param: None,
} else if input.is_none() { }))
return match self { }
Self::FullString(param_name) => TokenMatchResult::MissingParameter { name: param_name },
Self::MemberRef(param_name) => TokenMatchResult::MissingParameter { name: param_name },
Self::MemberPrivacyTarget(param_name) => TokenMatchResult::MissingParameter { name: param_name },
Self::SystemRef(param_name) => TokenMatchResult::MissingParameter { name: param_name },
Self::PrivacyLevel(param_name) => TokenMatchResult::MissingParameter { name: param_name },
_ => TokenMatchResult::NoMatch,
}
}
let input = input.as_ref().map(|s| s.trim()).unwrap(); fn new_match_param(raw: impl Into<SmolStr>, param_name: ParamName, param: Parameter) -> Self {
Self::Match(Some(TokenMatchedValue {
// try actually matching stuff raw: raw.into(),
match self { param: Some((param_name, param)),
Self::Empty => return TokenMatchResult::NoMatch, }))
Self::Flag => unreachable!(), // matched upstream
Self::Value(values) if values.iter().any(|v| v.eq(input)) => {
return TokenMatchResult::Match(None);
}
Self::Value(_) => {}
Self::MultiValue(_) => todo!(),
Self::FullString(_) => return TokenMatchResult::Match(Some(input.into())),
Self::SystemRef(_) => return TokenMatchResult::Match(Some(input.into())),
Self::MemberRef(_) => return TokenMatchResult::Match(Some(input.into())),
Self::MemberPrivacyTarget(_) if MEMBER_PRIVACY_TARGETS.contains(&input) => {
return TokenMatchResult::Match(Some(input.into()))
}
Self::MemberPrivacyTarget(_) => {}
Self::PrivacyLevel(_) if input == "public" || input == "private" => {
return TokenMatchResult::Match(Some(input.into()))
}
Self::PrivacyLevel(_) => {}
}
// note: must not add a _ case to the above match
// instead, for conditional matches, also add generic cases with no return
return TokenMatchResult::NoMatch;
} }
} }
impl Token {
pub fn try_match(&self, input: Option<SmolStr>) -> TokenMatchResult {
use TokenMatchResult::*;
let input = match input {
Some(input) => input,
None => {
// short circuit on:
return match self {
// empty token
Self::Empty => Match(None),
// missing paramaters
Self::FullString(param_name)
| Self::MemberRef(param_name)
| Self::MemberPrivacyTarget(param_name)
| Self::SystemRef(param_name)
| Self::PrivacyLevel(param_name)
| Self::Toggle(param_name)
| Self::Reset(param_name) => MissingParameter { name: param_name },
Self::Any(tokens) => tokens.is_empty().then_some(NoMatch).unwrap_or_else(|| {
let mut results = tokens.iter().map(|t| t.try_match(None));
results.find(|r| !matches!(r, NoMatch)).unwrap_or(NoMatch)
}),
// everything else doesnt match if no input anyway
Token::Value(_) => NoMatch,
Token::Flag => NoMatch,
// don't add a _ match here!
};
}
};
let input = input.trim();
// try actually matching stuff
match self {
Self::Empty => NoMatch,
Self::Flag => unreachable!(), // matched upstream (dusk: i don't really like this tbh)
Self::Any(tokens) => tokens
.iter()
.map(|t| t.try_match(Some(input.into())))
.find(|r| !matches!(r, NoMatch))
.unwrap_or(NoMatch),
Self::Value(values) => values
.iter()
.any(|v| v.eq(input))
.then(|| TokenMatchResult::new_match(input))
.unwrap_or(NoMatch),
Self::FullString(param_name) => TokenMatchResult::new_match_param(
input,
param_name,
Parameter::OpaqueString { raw: input.into() },
),
Self::SystemRef(param_name) => TokenMatchResult::new_match_param(
input,
param_name,
Parameter::SystemRef {
system: input.into(),
},
),
Self::MemberRef(param_name) => TokenMatchResult::new_match_param(
input,
param_name,
Parameter::MemberRef {
member: input.into(),
},
),
Self::MemberPrivacyTarget(param_name) => match MemberPrivacyTarget::from_str(input) {
Ok(target) => TokenMatchResult::new_match_param(
input,
param_name,
Parameter::MemberPrivacyTarget {
target: target.as_ref().into(),
},
),
Err(_) => NoMatch,
},
Self::PrivacyLevel(param_name) => match PrivacyLevel::from_str(input) {
Ok(level) => TokenMatchResult::new_match_param(
input,
param_name,
Parameter::PrivacyLevel {
level: level.as_ref().into(),
},
),
Err(_) => NoMatch,
},
Self::Toggle(param_name) => match Toggle::from_str(input) {
Ok(t) => TokenMatchResult::new_match_param(
input,
param_name,
Parameter::Toggle { toggle: t.0 },
),
Err(_) => NoMatch,
},
Self::Reset(param_name) => match Reset::from_str(input) {
Ok(_) => TokenMatchResult::new_match_param(input, param_name, Parameter::Reset),
Err(_) => NoMatch,
},
// don't add a _ match here!
}
}
}
/// Convenience trait to convert types into [`Token`]s.
pub trait ToToken { pub trait ToToken {
fn to_token(&self) -> Token; fn to_token(&self) -> Token;
} }
@ -109,3 +207,107 @@ impl ToToken for [&str] {
Token::Value(self.into_iter().map(|s| s.to_smolstr()).collect()) Token::Value(self.into_iter().map(|s| s.to_smolstr()).collect())
} }
} }
impl ToToken for [Token] {
fn to_token(&self) -> Token {
Token::Any(self.into_iter().map(|s| s.clone()).collect())
}
}
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
pub enum MemberPrivacyTarget {
Visibility,
Name,
// todo
}
impl AsRef<str> for MemberPrivacyTarget {
fn as_ref(&self) -> &str {
match self {
Self::Visibility => "visibility",
Self::Name => "name",
}
}
}
impl FromStr for MemberPrivacyTarget {
// todo: figure out how to represent these errors best
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"visibility" => Ok(Self::Visibility),
"name" => Ok(Self::Name),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
pub enum PrivacyLevel {
Public,
Private,
}
impl AsRef<str> for PrivacyLevel {
fn as_ref(&self) -> &str {
match self {
Self::Public => "public",
Self::Private => "private",
}
}
}
impl FromStr for PrivacyLevel {
type Err = (); // todo
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"public" => Ok(Self::Public),
"private" => Ok(Self::Private),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
pub struct Toggle(bool);
impl AsRef<str> for Toggle {
fn as_ref(&self) -> &str {
// on / off better than others for docs and stuff?
self.0.then_some("on").unwrap_or("off")
}
}
impl FromStr for Toggle {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"on" | "yes" | "true" | "enable" | "enabled" => Ok(Self(true)),
"off" | "no" | "false" | "disable" | "disabled" => Ok(Self(false)),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
pub struct Reset;
impl AsRef<str> for Reset {
fn as_ref(&self) -> &str {
"reset"
}
}
impl FromStr for Reset {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"reset" | "clear" | "default" => Ok(Self),
_ => Err(()),
}
}
}