From adce55b02d98000cdb9052496400b0a0cd6884e4 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Mon, 15 Aug 2022 14:57:30 -0700 Subject: [PATCH] discord: Add ContainerComponents.Unmarshal This feature is similar to the one added a few commits prior. --- discord/component.go | 148 ++++++++++++++++++++++++++++++ discord/component_example_test.go | 84 +++++++++++++++++ discord/interaction.go | 16 ++-- internal/rfutil/rfutil.go | 26 ++++++ 4 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 discord/component_example_test.go create mode 100644 internal/rfutil/rfutil.go diff --git a/discord/component.go b/discord/component.go index 85249dd..b21c97c 100644 --- a/discord/component.go +++ b/discord/component.go @@ -2,7 +2,10 @@ package discord import ( "fmt" + "reflect" + "strings" + "github.com/diamondburned/arikawa/v3/internal/rfutil" "github.com/diamondburned/arikawa/v3/utils/json" "github.com/diamondburned/arikawa/v3/utils/json/option" "github.com/pkg/errors" @@ -39,6 +42,141 @@ func (t ComponentType) String() string { // type for component lists. type ContainerComponents []ContainerComponent +// Find finds any component with the given custom ID. +func (c *ContainerComponents) Find(customID ComponentID) Component { + for _, component := range *c { + switch component := component.(type) { + case *ActionRowComponent: + if component := component.Find(customID); component != nil { + return component + } + } + } + return nil +} + +// Unmarshal unmarshals the components 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. +// +// Each struct field will be used to search the tree of components for a +// matching custom ID. The struct must be a flat struct that lists all the +// components it needs using the custom ID. +// +// Supported Types +// +// The following types are supported: +// +// - string (SelectComponent if range = [n, 1], TextInputComponent) +// - bool (ButtonComponent or any component, true if present) +// - []string (SelectComponent) +// +// Any types that are derived from any of the above built-in types are also +// supported. +// +// Pointer types to any of the above types are also supported and will also +// implicitly imply optionality. +func (c *ContainerComponents) Unmarshal(v interface{}) error { + rv, rt, err := rfutil.StructValue(v) + if err != nil { + return err + } + + 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 + } + + component := c.Find(ComponentID(strings.TrimSuffix(name, "?"))) + fieldv := rv.Field(i) + fieldt := fieldStruct.Type + + if strings.HasSuffix(name, "?") { + name = strings.TrimSuffix(name, "?") + if component == nil { + // not found + continue + } + } else if fieldStruct.Type.Kind() == reflect.Ptr { + fieldt = fieldt.Elem() + if component == nil { + // 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 component == nil { + // not found AND the field is not a pointer, so error out + return fmt.Errorf("component %q is required but not found", name) + } + + switch fieldt.Kind() { + case reflect.Bool: + // Intended for ButtonComponents. + fieldv.Set(reflect.ValueOf(true).Convert(fieldt)) + case reflect.String: + var v string + + switch component := component.(type) { + case *TextInputComponent: + v = component.Value.Val + case *SelectComponent: + switch len(component.Options) { + case 0: + // ok + case 1: + v = component.Options[0].Value + default: + return fmt.Errorf("component %q selected more than one item (bug, check ValueRange)", name) + } + default: + return fmt.Errorf("component %q is of unsupported type %T", name, component) + } + + fieldv.Set(reflect.ValueOf(v).Convert(fieldt)) + case reflect.Slice: + elemt := fieldt.Elem() + + switch elemt.Kind() { + case reflect.String: + switch component := component.(type) { + case *SelectComponent: + fieldv.Set(reflect.MakeSlice(fieldt, len(component.Options), len(component.Options))) + for i, option := range component.Options { + fieldv.Index(i).Set(reflect.ValueOf(option.Value).Convert(elemt)) + } + default: + return fmt.Errorf("component %q is of unsupported type %T", name, component) + } + default: + return fmt.Errorf("field %s (%q) has unknown slice type %s", fieldStruct.Name, name, fieldt) + } + default: + return fmt.Errorf("field %s (%q) has unknown type %s", fieldStruct.Name, name, fieldt) + } + } + + return nil +} + // UnmarshalJSON unmarshals JSON into the component. It does type-checking and // will only accept container components. func (c *ContainerComponents) UnmarshalJSON(b []byte) error { @@ -197,6 +335,16 @@ func (a *ActionRowComponent) Type() ComponentType { func (a *ActionRowComponent) _cmp() {} func (a *ActionRowComponent) _ctn() {} +// Find finds any component with the given custom ID. +func (a *ActionRowComponent) Find(customID ComponentID) Component { + for _, component := range *a { + if component.ID() == customID { + return component + } + } + return nil +} + // MarshalJSON marshals the action row in the format Discord expects. func (a *ActionRowComponent) MarshalJSON() ([]byte, error) { var actionRow struct { diff --git a/discord/component_example_test.go b/discord/component_example_test.go new file mode 100644 index 0000000..cf675b2 --- /dev/null +++ b/discord/component_example_test.go @@ -0,0 +1,84 @@ +package discord_test + +import ( + "encoding/json" + "fmt" + "log" + + "github.com/diamondburned/arikawa/v3/discord" + "github.com/diamondburned/arikawa/v3/utils/json/option" +) + +func ExampleContainerComponents_Unmarshal() { + components := &discord.ContainerComponents{ + &discord.ActionRowComponent{ + &discord.TextInputComponent{ + CustomID: "text1", + Value: option.NewNullableString("hello"), + }, + }, + &discord.ActionRowComponent{ + &discord.TextInputComponent{ + CustomID: "text2", + Value: option.NewNullableString("hello 2"), + }, + &discord.TextInputComponent{ + CustomID: "text3", + Value: option.NewNullableString("hello 3"), + }, + }, + &discord.ActionRowComponent{ + &discord.SelectComponent{ + CustomID: "select1", + Options: []discord.SelectOption{ + {Value: "option 1"}, + {Value: "option 2"}, + }, + }, + &discord.ButtonComponent{ + CustomID: "button1", + }, + }, + &discord.ActionRowComponent{ + &discord.SelectComponent{ + CustomID: "select2", + Options: []discord.SelectOption{ + {Value: "option 1"}, + }, + }, + }, + } + + var data struct { + Text1 string `discord:"text1"` + Text2 string `discord:"text2?"` + Text3 *string `discord:"text3"` + Text4 string `discord:"text4?"` + Text5 *string `discord:"text5"` + Select1 []string `discord:"select1"` + Select2 string `discord:"select2"` + Button1 bool `discord:"button1"` + } + + if err := components.Unmarshal(&data); err != nil { + log.Fatalln(err) + } + + b, _ := json.MarshalIndent(data, "", " ") + fmt.Println(string(b)) + + // Output: + // { + // "Text1": "hello", + // "Text2": "hello 2", + // "Text3": "hello 3", + // "Text4": "", + // "Text5": null, + // "Select1": [ + // "option 1", + // "option 2" + // ], + // "Select2": "option 1", + // "Button1": true + // } +} diff --git a/discord/interaction.go b/discord/interaction.go index f43be0c..99f97d5 100644 --- a/discord/interaction.go +++ b/discord/interaction.go @@ -5,6 +5,7 @@ import ( "reflect" "strings" + "github.com/diamondburned/arikawa/v3/internal/rfutil" "github.com/diamondburned/arikawa/v3/utils/json" "github.com/pkg/errors" ) @@ -448,20 +449,17 @@ var optionKindMap = map[reflect.Kind]CommandOptionType{ // // Any types that are derived from any of the above built-in types are also // supported. +// +// Pointer types to any of the above types are also supported and will also +// implicitly imply optionality. 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") + rv, rt, err := rfutil.StructRValue(rv) + if err != nil { + return err } numField := rt.NumField() diff --git a/internal/rfutil/rfutil.go b/internal/rfutil/rfutil.go new file mode 100644 index 0000000..6273849 --- /dev/null +++ b/internal/rfutil/rfutil.go @@ -0,0 +1,26 @@ +package rfutil + +import ( + "errors" + "reflect" +) + +func StructValue(v interface{}) (reflect.Value, reflect.Type, error) { + rv := reflect.ValueOf(v) + return StructRValue(rv) +} + +func StructRValue(rv reflect.Value) (reflect.Value, reflect.Type, error) { + rt := rv.Type() + if rt.Kind() != reflect.Ptr { + return reflect.Value{}, nil, errors.New("v is not a pointer") + } + + rv = rv.Elem() + rt = rt.Elem() + if rt.Kind() != reflect.Struct { + return reflect.Value{}, nil, errors.New("v is not a pointer to a struct") + } + + return rv, rt, nil +}