From 47c06557c2a30fc05fc2f4d839fcfd8f764e4b22 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Sun, 14 Aug 2022 23:32:49 -0700 Subject: [PATCH] discord: Add CommandInteractionOptions.Unmarshal This commit adds an Unmarshal method into CommandInteractionOptions. It is probably the first commit to break the rule of keeping package discord simple. The goal of this rationale is that package discord should continue prioritizing convenience and ease of use by providing small, helpful additions without being oversized. This method, while it uses reflect, is actually fairly small in implementation. Its functionality should be kept to a bare minimum and as such will not cover every single use case (but should cover most). --- discord/interaction.go | 136 ++++++++++++++++++++++++++++ discord/interaction_example_test.go | 89 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 discord/interaction_example_test.go diff --git a/discord/interaction.go b/discord/interaction.go index bc92df2..b3148bb 100644 --- a/discord/interaction.go +++ b/discord/interaction.go @@ -1,6 +1,8 @@ package discord import ( + "fmt" + "reflect" "strings" "github.com/diamondburned/arikawa/v3/utils/json" @@ -399,6 +401,140 @@ type CommandInteractionOption struct { Options CommandInteractionOptions `json:"options"` } +var optionSupportedSnowflakeTypes = map[reflect.Type]struct{}{ + reflect.TypeOf(ChannelID(0)): {}, + reflect.TypeOf(UserID(0)): {}, + reflect.TypeOf(RoleID(0)): {}, + reflect.TypeOf(MessageID(0)): {}, + reflect.TypeOf(Snowflake(0)): {}, +} + +// Unmarshal unmarshals the options into the struct pointer v. Each struct field +// must be exported and is of a supported type. +// +// Fields that don't satisfy any of the above are ignored. The "discord" struct +// tag with a value "-" is ignored. Fields that aren't found in the list of +// options and have a "?" at the end of the "discord" struct tag are ignored. +// +// Supported Types +// +// The following types are supported: +// +// - ChannelID +// - UserID +// - RoleID +// - MessageID +// - Snowflake +// - string +// - bool +// - int* (int, int8, int16, int32, int64) +// - float* (float32, float64) +// - (any struct and struct pointer) +// +// Any types that are derived from any of the above built-in types are also +// supported. +func (o CommandInteractionOptions) Unmarshal(v interface{}) error { + return o.unmarshal(reflect.ValueOf(v)) +} + +func (o CommandInteractionOptions) unmarshal(rv reflect.Value) error { + rt := rv.Type() + if rt.Kind() != reflect.Ptr { + return errors.New("v is not a pointer") + } + + rv = rv.Elem() + rt = rt.Elem() + if rt.Kind() != reflect.Struct { + return errors.New("v is not a pointer to a struct") + } + + numField := rt.NumField() + for i := 0; i < numField; i++ { + fieldStruct := rt.Field(i) + if !fieldStruct.IsExported() { + continue + } + + name := fieldStruct.Tag.Get("discord") + switch name { + case "-": + continue + case "?": + name = fieldStruct.Name + "?" + case "": + name = fieldStruct.Name + } + + option := o.Find(strings.TrimSuffix(name, "?")) + fieldv := rv.Field(i) + fieldt := fieldStruct.Type + + if strings.HasSuffix(name, "?") { + name = strings.TrimSuffix(name, "?") + if option.Type == 0 { + // not found + continue + } + } else if fieldStruct.Type.Kind() == reflect.Ptr { + fieldt = fieldt.Elem() + if option.Type == 0 { + // not found + fieldv.Set(reflect.NewAt(fieldt, nil)) + continue + } + // found, so allocate new value and use that to set + newv := reflect.New(fieldt) + fieldv.Set(newv) + fieldv = newv.Elem() + } else if option.Type == 0 { + // not found AND the field is not a pointer, so error out + return fmt.Errorf("option %q is required but not found", name) + } + + if _, ok := optionSupportedSnowflakeTypes[fieldt]; ok { + snowflake, err := option.SnowflakeValue() + if err != nil { + return errors.Wrapf(err, "option %q is not a valid snowflake", name) + } + + fieldv.Set(reflect.ValueOf(snowflake).Convert(fieldt)) + continue + } + + switch fieldt.Kind() { + case reflect.Struct: + if err := option.Options.unmarshal(fieldv.Addr()); err != nil { + return errors.Wrapf(err, "option %q has invalid suboptions", name) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i64, err := option.IntValue() + if err != nil { + return errors.Wrapf(err, "option %q is not a valid int64", name) + } + fieldv.Set(reflect.ValueOf(i64).Convert(fieldt)) + case reflect.Float32, reflect.Float64: + i64, err := option.IntValue() + if err != nil { + return errors.Wrapf(err, "option %q is not a valid int", name) + } + fieldv.Set(reflect.ValueOf(i64).Convert(fieldt)) + case reflect.Bool: + b, err := option.BoolValue() + if err != nil { + return errors.Wrapf(err, "option %q is not a valid bool", name) + } + fieldv.Set(reflect.ValueOf(b).Convert(fieldt)) + case reflect.String: + fieldv.Set(reflect.ValueOf(option.String()).Convert(fieldt)) + default: + return fmt.Errorf("field %s (%q) has unknown type %s", fieldStruct.Name, name, fieldt) + } + } + + return nil +} + // Find returns the named command option func (o CommandInteractionOptions) Find(name string) CommandInteractionOption { for _, opt := range o { diff --git a/discord/interaction_example_test.go b/discord/interaction_example_test.go new file mode 100644 index 0000000..de6a8ed --- /dev/null +++ b/discord/interaction_example_test.go @@ -0,0 +1,89 @@ +package discord_test + +import ( + "encoding/json" + "fmt" + "log" + + "github.com/diamondburned/arikawa/v3/discord" + internaljson "github.com/diamondburned/arikawa/v3/utils/json" +) + +func ExampleCommandInteractionOptions_Unmarshal() { + options := discord.CommandInteractionOptions{ + opt(discord.ChannelOptionType, "channel_id", 1), + opt(discord.StringOptionType, "string1", "hello"), + opt(discord.StringOptionType, "string2", "hello"), + opt(discord.StringOptionType, "string3", "hello"), + opt(discord.SubcommandOptionType, "sub", discord.CommandInteractionOptions{ + { + Type: discord.RoleOptionType, + Name: "role_id", + Value: mustJSON("2"), + }, + }), + } + + var quickCommand struct { + ChannelID discord.ChannelID `discord:"channel_id"` + String1 string + OptionalString2 *string `discord:"string2"` + OptionalString3 string `discord:"string3?"` + OptionalString4 *string `discord:"string4"` + OptionalString5 string `discord:"string5?"` + Suboption struct { + RoleID discord.RoleID `discord:"role_id"` + } `discord:"sub"` + OptionalSuboption2 *struct { + RoleID discord.RoleID `discord:"role_id"` + } `discord:"sub2"` + OptionalSuboption3 struct { + RoleID discord.RoleID `discord:"role_id"` + } `discord:"sub3?"` + } + + if err := options.Unmarshal(&quickCommand); err != nil { + log.Fatalln(err) + } + + b, _ := json.MarshalIndent(quickCommand, "", " ") + fmt.Println(string(b)) + + // Output: + // { + // "ChannelID": "1", + // "String1": "hello", + // "OptionalString2": "hello", + // "OptionalString3": "hello", + // "OptionalString4": null, + // "OptionalString5": "", + // "Suboption": { + // "RoleID": "2" + // }, + // "OptionalSuboption2": null, + // "OptionalSuboption3": { + // "RoleID": null + // } + // } +} + +func opt(t discord.CommandOptionType, name string, v interface{}) discord.CommandInteractionOption { + o := discord.CommandInteractionOption{ + Type: t, + Name: name, + } + if opts, ok := v.(discord.CommandInteractionOptions); ok { + o.Options = opts + } else { + o.Value = mustJSON(v) + } + return o +} + +func mustJSON(v interface{}) internaljson.Raw { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return internaljson.Raw(b) +}