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) +}