From 482c9235072919b342700cddb38058c681c15bfd Mon Sep 17 00:00:00 2001 From: dusk Date: Tue, 7 Jan 2025 23:15:18 +0900 Subject: [PATCH] feat: better parameters handling, implement multi-token matching --- PluralKit.Bot/CommandMeta/CommandTree.cs | 51 ++- .../CommandSystem/Context/Context.cs | 12 +- .../Context/ContextEntityArgumentsExt.cs | 8 +- .../Context/ContextParametersExt.cs | 64 ++++ PluralKit.Bot/CommandSystem/Parameters.cs | 112 ++++--- PluralKit.Bot/Commands/Config.cs | 53 +-- PluralKit.Bot/Commands/Member.cs | 11 +- PluralKit.Bot/Handlers/MessageCreated.cs | 20 +- crates/commands/src/commands.rs | 1 + crates/commands/src/commands.udl | 18 +- crates/commands/src/commands/config.rs | 31 ++ crates/commands/src/commands/member.rs | 5 + crates/commands/src/lib.rs | 76 +---- crates/commands/src/token.rs | 310 +++++++++++++++--- 14 files changed, 521 insertions(+), 251 deletions(-) create mode 100644 PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index 60e8d7e4..7ce43b3d 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -4,30 +4,29 @@ namespace PluralKit.Bot; 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": - return ctx.Execute(Help, m => m.HelpRoot(ctx)); - case "help_commands": - return ctx.Reply( - "For the list of commands, see the website: "); - 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(MemberInfo, m => m.ViewMember(ctx, parameters.MemberParams["target"])); - case "member_new": - return ctx.Execute(MemberNew, m => m.NewMember(ctx, parameters.Raw.Params()["name"])); - case "fun_thunder": - return ctx.Execute(null, m => m.Thunder(ctx)); - case "fun_meow": - return ctx.Execute(null, m => m.Meow(ctx)); - default: - return ctx.Reply( - $"{Emojis.Error} Parsed command {parameters.Raw.Callback().AsCode()} not implemented in PluralKit.Bot!"); - } + "help" => ctx.Execute(Help, m => m.HelpRoot(ctx)), + "help_commands" => ctx.Reply( + "For the list of commands, see the website: "), + "help_proxy" => ctx.Reply( + "The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"), + "member_show" => ctx.Execute(MemberInfo, m => m.ViewMember(ctx)), + "member_new" => ctx.Execute(MemberNew, m => m.NewMember(ctx)), + "member_soulscream" => ctx.Execute(MemberInfo, m => m.Soulscream(ctx)), + "cfg_ap_account_show" => ctx.Execute(null, m => m.ViewAutoproxyAccount(ctx)), + "cfg_ap_account_update" => ctx.Execute(null, m => m.EditAutoproxyAccount(ctx)), + "cfg_ap_timeout_show" => ctx.Execute(null, m => m.ViewAutoproxyTimeout(ctx)), + "cfg_ap_timeout_update" => ctx.Execute(null, m => m.EditAutoproxyTimeout(ctx)), + "fun_thunder" => ctx.Execute(null, m => m.Thunder(ctx)), + "fun_meow" => ctx.Execute(null, m => m.Meow(ctx)), + _ => + // this should only ever occur when deving if commands are not implemented... + ctx.Reply( + $"{Emojis.Error} Parsed command {ctx.Parameters.Callback().AsCode()} not implemented in PluralKit.Bot!"), + }; if (ctx.Match("system", "s")) return HandleSystemCommand(ctx); if (ctx.Match("member", "m")) @@ -405,10 +404,6 @@ public partial class CommandTree await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private)); else if (ctx.Match("public", "shown", "show", "unhide", "unhidden")) await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public)); - else if (ctx.Match("soulscream")) - await ctx.Execute(MemberInfo, m => m.Soulscream(ctx, target)); - else if (!ctx.HasNext()) // Bare command - await ctx.Execute(MemberInfo, m => m.ViewMember(ctx, target)); else await PrintCommandNotFoundError(ctx, MemberInfo, MemberRename, MemberDisplayName, MemberServerName, MemberDesc, MemberPronouns, MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar, @@ -576,10 +571,6 @@ public partial class CommandTree if (!ctx.HasNext()) return ctx.Execute(null, m => m.ShowConfig(ctx)); - if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "account", "ac" })) - return ctx.Execute(null, m => m.AutoproxyAccount(ctx)); - if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "timeout", "tm" })) - return ctx.Execute(null, m => m.AutoproxyTimeout(ctx)); if (ctx.Match("timezone", "zone", "tz")) return ctx.Execute(null, m => m.SystemTimezone(ctx)); if (ctx.Match("ping")) diff --git a/PluralKit.Bot/CommandSystem/Context/Context.cs b/PluralKit.Bot/CommandSystem/Context/Context.cs index 344e85d5..ad075cde 100644 --- a/PluralKit.Bot/CommandSystem/Context/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context/Context.cs @@ -26,11 +26,9 @@ public class Context private readonly IMetrics _metrics; private readonly CommandMessageService _commandMessageService; - private Command? _currentCommand; - public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, PKSystem senderSystem, SystemConfig config, - GuildConfig? guildConfig, string[] prefixes) + GuildConfig? guildConfig, string[] prefixes, Parameters parameters) { Message = (Message)message; ShardId = shardId; @@ -50,6 +48,7 @@ public class Context DefaultPrefix = prefixes[0]; Rest = provider.Resolve(); Cluster = provider.Resolve(); + Parameters = parameters; } public readonly IDiscordCache Cache; @@ -75,6 +74,7 @@ public class Context public readonly string CommandPrefix; public readonly string DefaultPrefix; + public readonly Parameters Parameters; internal readonly IDatabase Database; internal readonly ModelRepository Repository; @@ -111,8 +111,6 @@ public class Context public async Task Execute(Command? commandDef, Func handler, bool deprecated = false) { - _currentCommand = commandDef; - 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}`."); @@ -153,8 +151,8 @@ public class Context public LookupContext LookupContextFor(SystemId systemId) { - var hasPrivateOverride = this.MatchFlag("private", "priv"); - var hasPublicOverride = this.MatchFlag("public", "pub"); + var hasPrivateOverride = Parameters.HasFlag("private", "priv"); + var hasPublicOverride = Parameters.HasFlag("public", "pub"); if (hasPrivateOverride && hasPublicOverride) throw new PKError("Cannot match both public and private flags at the same time."); diff --git a/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs index 13d31cdb..104f1ad8 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs @@ -59,7 +59,7 @@ public static class ContextEntityArgumentsExt return null; } - public static async Task ParseMember(this Context ctx, Parameters parameters, string input, SystemId? restrictToSystem = null) + public static async Task ParseMember(this Context ctx, string input, bool byId, SystemId? restrictToSystem = null) { // Member references can have one of three forms, depending on // 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 // 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 if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName) @@ -169,9 +169,9 @@ public static class ContextEntityArgumentsExt 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; if (isIDOnlyQuery) diff --git a/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs new file mode 100644 index 00000000..754403b0 --- /dev/null +++ b/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs @@ -0,0 +1,64 @@ +using PluralKit.Core; + +namespace PluralKit.Bot; + +public static class ContextParametersExt +{ + public static async Task 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 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 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 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 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 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 ParamResolveReset(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => param is Parameter.Reset + ); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Parameters.cs b/PluralKit.Bot/CommandSystem/Parameters.cs index f7831e80..e5e0efd2 100644 --- a/PluralKit.Bot/CommandSystem/Parameters.cs +++ b/PluralKit.Bot/CommandSystem/Parameters.cs @@ -1,14 +1,27 @@ +using System.Diagnostics; using PluralKit.Core; using uniffi.commands; 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 { private string _cb { get; init; } private List _args { get; init; } private Dictionary _flags { get; init; } - private Dictionary _params { get; init; } + private Dictionary _params { get; init; } // just used for errors, temporarily public string FullCommand { get; init; } @@ -31,68 +44,61 @@ public class Parameters } } - public async Task 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 IDictionary Flags() + public bool HasFlag(params string[] potentialMatches) { - return _flags; + return potentialMatches.Any(_flags.ContainsKey); } - private Dictionary Params(Func filter) + // resolves a single parameter + private async Task 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 Params() + public async Task ResolveParameter(Context ctx, string param_name, Func 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 MemberParams() - { - return Params(kind => kind == ParameterKind.MemberRef); - } - - public IDictionary 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 MemberParams; - public readonly Dictionary SystemParams; - - public ResolvedParameters(Parameters parameters, Dictionary member_params, Dictionary 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); - } -} +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Config.cs b/PluralKit.Bot/Commands/Config.cs index 1b8efab1..e4fecbdc 100644 --- a/PluralKit.Bot/Commands/Config.cs +++ b/PluralKit.Bot/Commands/Config.cs @@ -190,17 +190,17 @@ public class Config } 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); - if (!ctx.HasNext()) - { - await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(allowAutoproxy)}** for account <@{ctx.Author.Id}>."); - return; - } + await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(allowAutoproxy)}** for account <@{ctx.Author.Id}>."); + } - 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); if (allowAutoproxy == allow) @@ -213,31 +213,34 @@ public class Config await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>."); } - - public async Task AutoproxyTimeout(Context ctx) + public async Task ViewAutoproxyTimeout(Context ctx) { - if (!ctx.HasNext()) - { - var timeout = ctx.Config.LatchTimeout.HasValue - ? Duration.FromSeconds(ctx.Config.LatchTimeout.Value) - : (Duration?)null; + var timeout = ctx.Config.LatchTimeout.HasValue + ? Duration.FromSeconds(ctx.Config.LatchTimeout.Value) + : (Duration?)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)}."); - else if (timeout == Duration.Zero) - await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out."); - else - await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}."); - return; - } + 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)}."); + else if (timeout == Duration.Zero) + await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out."); + else + await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}."); + } + + 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 overflow = Duration.Zero; - if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) newTimeout = Duration.Zero; - else if (ctx.MatchClear()) newTimeout = null; + if (_toggle == false) newTimeout = Duration.Zero; + else if (_reset == true) newTimeout = null; else { - var timeoutStr = ctx.RemainderOrNull(); + // todo: we should parse date in the command parser + var timeoutStr = _newTimeout; 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.Value.TotalHours > 100000) diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index a32b1e58..ef2065cc 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Reflection.Metadata; using System.Web; using Dapper; @@ -27,8 +28,10 @@ public class Member _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); 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: "); } - 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); await ctx.Reply( 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. :) + var target = await ctx.ParamResolveMember("target"); var name = target.NameFor(ctx.LookupContextFor(target.System)); var encoded = HttpUtility.UrlEncode(name); diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index 293f7bb4..a2b34483 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -137,23 +137,29 @@ public class MessageCreated: IEventHandler 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; - var ctx = new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes); + + // parse parameters + Parameters parameters; try { - var parameters = new Parameters(evt.Content?.Substring(cmdStart)); - var resolved_parameters = await parameters.ResolveParameters(ctx); - await _tree.ExecuteCommand(ctx, resolved_parameters); + parameters = new Parameters(evt.Content?.Substring(cmdStart)); } catch (PKError e) { // 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...) - 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; } + + var ctx = new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes, parameters); + await _tree.ExecuteCommand(ctx); } catch (PKError) { @@ -212,4 +218,4 @@ public class MessageCreated: IEventHandler return false; } -} +} \ No newline at end of file diff --git a/crates/commands/src/commands.rs b/crates/commands/src/commands.rs index b904bb59..692e0d50 100644 --- a/crates/commands/src/commands.rs +++ b/crates/commands/src/commands.rs @@ -58,6 +58,7 @@ pub fn all() -> Vec { (help::cmds()) .chain(system::cmds()) .chain(member::cmds()) + .chain(config::cmds()) .chain(fun::cmds()) .collect() } diff --git a/crates/commands/src/commands.udl b/crates/commands/src/commands.udl index 22396de4..9eb372a3 100644 --- a/crates/commands/src/commands.udl +++ b/crates/commands/src/commands.udl @@ -7,16 +7,14 @@ interface CommandResult { Err(string error); }; [Enum] -interface ParameterKind { - MemberRef(); - SystemRef(); - MemberPrivacyTarget(); - PrivacyLevel(); - OpaqueString(); -}; -dictionary Parameter { - string raw; - ParameterKind kind; +interface Parameter { + MemberRef(string member); + SystemRef(string system); + MemberPrivacyTarget(string target); + PrivacyLevel(string level); + OpaqueString(string raw); + Toggle(boolean toggle); + Reset(); }; dictionary ParsedCommand { string command_ref; diff --git a/crates/commands/src/commands/config.rs b/crates/commands/src/commands/config.rs index 8b137891..f75018be 100644 --- a/crates/commands/src/commands/config.rs +++ b/crates/commands/src/commands/config.rs @@ -1 +1,32 @@ +use super::*; +pub fn cmds() -> impl Iterator { + 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() +} diff --git a/crates/commands/src/commands/member.rs b/crates/commands/src/commands/member.rs index 4ea763a1..42c3e1d2 100644 --- a/crates/commands/src/commands/member.rs +++ b/crates/commands/src/commands/member.rs @@ -19,6 +19,11 @@ pub fn cmds() -> impl Iterator { "member_show", "Shows information about a member" ), + command!( + [member, MemberRef("target"), "soulscream"], + "member_soulscream", + "todo" + ), command!( [member, MemberRef("target"), description], "member_desc_show", diff --git a/crates/commands/src/lib.rs b/crates/commands/src/lib.rs index 927f1503..b0d417e1 100644 --- a/crates/commands/src/lib.rs +++ b/crates/commands/src/lib.rs @@ -36,48 +36,15 @@ pub enum CommandResult { Err { error: String }, } -#[derive(Debug)] -pub enum ParameterKind { - MemberRef, - SystemRef, - MemberPrivacyTarget, - PrivacyLevel, - OpaqueString, -} - -#[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, Clone)] +pub enum Parameter { + MemberRef { member: String }, + SystemRef { system: String }, + MemberPrivacyTarget { target: String }, + PrivacyLevel { level: String }, + OpaqueString { raw: String }, + Toggle { toggle: bool }, + Reset, } #[derive(Debug)] @@ -111,27 +78,17 @@ fn parse_command(input: String) -> CommandResult { Ok((found_token, arg, new_pos)) => { current_pos = new_pos; 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 continue; } 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 - if let Some((param_name, param)) = param { - params.insert(param_name.to_string(), param); + if let Some((param_name, param)) = arg.param.as_ref() { + 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) { @@ -178,7 +135,7 @@ fn next_token( possible_tokens: Vec, input: SmolStr, current_pos: usize, -) -> Result<(Token, Option, usize), Option> { +) -> Result<(Token, Option, usize), Option> { // get next parameter, matching quotes let param = crate::string::next_param(input.clone(), current_pos); println!("matched: {param:?}\n---"); @@ -191,7 +148,10 @@ fn next_token( { return Ok(( Token::Flag, - Some(value.trim_start_matches('-').into()), + Some(TokenMatchedValue { + raw: value, + param: None, + }), new_pos, )); } diff --git a/crates/commands/src/token.rs b/crates/commands/src/token.rs index 6588fc8d..2f82b207 100644 --- a/crates/commands/src/token.rs +++ b/crates/commands/src/token.rs @@ -1,5 +1,9 @@ +use std::str::FromStr; + use smol_str::{SmolStr, ToSmolStr}; +use crate::Parameter; + type ParamName = &'static str; #[derive(Debug, Clone, Eq, Hash, PartialEq)] @@ -8,86 +12,180 @@ pub enum Token { // todo: this is likely not the right way to represent this Empty, - /// A bot-defined value ("member" in `pk;member MyName`) - Value(Vec), - /// A command defined by multiple values - // todo! - MultiValue(Vec>), + /// multi-token matching + Any(Vec), + /// A bot-defined command / subcommand (usually) (eg. "member" in `pk;member MyName`) + Value(Vec), + + /// Opaque string (eg. "name" in `pk;member new name`) FullString(ParamName), /// Member reference (hid or member name) MemberRef(ParamName), + /// todo: doc MemberPrivacyTarget(ParamName), /// System reference SystemRef(ParamName), + /// todo: doc 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 Flag, } +// #[macro_export] +// macro_rules! any { +// ($($token:expr),+) => { +// Token::Any(vec![$($token.to_token()),+]) +// }; +// } + +#[derive(Debug)] pub enum TokenMatchResult { + /// Token did not match. NoMatch, /// Token matched, optionally with a value. - Match(Option), - MissingParameter { - name: ParamName, - }, + Match(Option), + /// A required parameter was missing. + MissingParameter { name: ParamName }, } -// move this somewhere else -const MEMBER_PRIVACY_TARGETS: &[&str] = &["visibility", "name", "todo"]; +#[derive(Debug)] +pub struct TokenMatchedValue { + pub raw: SmolStr, + pub param: Option<(ParamName, Parameter)>, +} -impl Token { - pub fn try_match(&self, input: Option) -> TokenMatchResult { - // short circuit on empty things - if matches!(self, Self::Empty) && input.is_none() { - return TokenMatchResult::Match(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, - } - } +impl TokenMatchResult { + fn new_match(raw: impl Into) -> Self { + Self::Match(Some(TokenMatchedValue { + raw: raw.into(), + param: None, + })) + } - let input = input.as_ref().map(|s| s.trim()).unwrap(); - - // try actually matching stuff - match self { - 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; + fn new_match_param(raw: impl Into, param_name: ParamName, param: Parameter) -> Self { + Self::Match(Some(TokenMatchedValue { + raw: raw.into(), + param: Some((param_name, param)), + })) } } +impl Token { + pub fn try_match(&self, input: Option) -> 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 { fn to_token(&self) -> Token; } @@ -109,3 +207,107 @@ impl ToToken for [&str] { 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 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 { + match s { + "visibility" => Ok(Self::Visibility), + "name" => Ok(Self::Name), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +pub enum PrivacyLevel { + Public, + Private, +} + +impl AsRef 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 { + match s { + "public" => Ok(Self::Public), + "private" => Ok(Self::Private), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +pub struct Toggle(bool); + +impl AsRef 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 { + 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 for Reset { + fn as_ref(&self) -> &str { + "reset" + } +} + +impl FromStr for Reset { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "reset" | "clear" | "default" => Ok(Self), + _ => Err(()), + } + } +}