diff --git a/handler/handler.go b/handler/handler.go index 8431d537..18099147 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -19,6 +19,7 @@ package handler import ( "errors" + "slices" "strings" "github.com/disgoorg/snowflake/v2" @@ -48,10 +49,11 @@ type handlerHolder[T any] struct { pattern string handler T t discord.InteractionType + t2 []int } -func (h *handlerHolder[T]) Match(path string, t discord.InteractionType) bool { - if h.t != t { +func (h *handlerHolder[T]) Match(path string, t discord.InteractionType, t2 int) bool { + if h.t != t || (len(h.t2) > 0 && !slices.Contains(h.t2, t2)) { return false } parts := splitPath(path) @@ -85,6 +87,39 @@ func (h *handlerHolder[T]) Handle(path string, event *InteractionEvent) error { Vars: event.Vars, Ctx: event.Ctx, }) + case SlashCommandHandler: + commandInteraction := event.Interaction.(discord.ApplicationCommandInteraction) + return handler(commandInteraction.Data.(discord.SlashCommandInteractionData), &CommandEvent{ + ApplicationCommandInteractionCreate: &events.ApplicationCommandInteractionCreate{ + GenericEvent: event.GenericEvent, + ApplicationCommandInteraction: commandInteraction, + Respond: event.Respond, + }, + Vars: event.Vars, + Ctx: event.Ctx, + }) + case UserCommandHandler: + commandInteraction := event.Interaction.(discord.ApplicationCommandInteraction) + return handler(commandInteraction.Data.(discord.UserCommandInteractionData), &CommandEvent{ + ApplicationCommandInteractionCreate: &events.ApplicationCommandInteractionCreate{ + GenericEvent: event.GenericEvent, + ApplicationCommandInteraction: commandInteraction, + Respond: event.Respond, + }, + Vars: event.Vars, + Ctx: event.Ctx, + }) + case MessageCommandHandler: + commandInteraction := event.Interaction.(discord.ApplicationCommandInteraction) + return handler(commandInteraction.Data.(discord.MessageCommandInteractionData), &CommandEvent{ + ApplicationCommandInteractionCreate: &events.ApplicationCommandInteractionCreate{ + GenericEvent: event.GenericEvent, + ApplicationCommandInteraction: commandInteraction, + Respond: event.Respond, + }, + Vars: event.Vars, + Ctx: event.Ctx, + }) case AutocompleteHandler: return handler(&AutocompleteEvent{ AutocompleteInteractionCreate: &events.AutocompleteInteractionCreate{ @@ -105,6 +140,28 @@ func (h *handlerHolder[T]) Handle(path string, event *InteractionEvent) error { Vars: event.Vars, Ctx: event.Ctx, }) + case ButtonComponentHandler: + componentInteraction := event.Interaction.(discord.ComponentInteraction) + return handler(componentInteraction.Data.(discord.ButtonInteractionData), &ComponentEvent{ + ComponentInteractionCreate: &events.ComponentInteractionCreate{ + GenericEvent: event.GenericEvent, + ComponentInteraction: componentInteraction, + Respond: event.Respond, + }, + Vars: event.Vars, + Ctx: event.Ctx, + }) + case SelectMenuComponentHandler: + componentInteraction := event.Interaction.(discord.ComponentInteraction) + return handler(componentInteraction.Data.(discord.SelectMenuInteractionData), &ComponentEvent{ + ComponentInteractionCreate: &events.ComponentInteractionCreate{ + GenericEvent: event.GenericEvent, + ComponentInteraction: componentInteraction, + Respond: event.Respond, + }, + Vars: event.Vars, + Ctx: event.Ctx, + }) case ModalHandler: return handler(&ModalEvent{ ModalSubmitInteractionCreate: &events.ModalSubmitInteractionCreate{ diff --git a/handler/mux.go b/handler/mux.go index 9379cb3b..5887d402 100644 --- a/handler/mux.go +++ b/handler/mux.go @@ -82,7 +82,7 @@ func (r *Mux) OnEvent(event bot.Event) { } // Match returns true if the given path matches the Route. -func (r *Mux) Match(path string, t discord.InteractionType) bool { +func (r *Mux) Match(path string, t discord.InteractionType, t2 int) bool { if r.pattern != "" { parts := splitPath(path) patternParts := splitPath(r.pattern) @@ -99,7 +99,7 @@ func (r *Mux) Match(path string, t discord.InteractionType) bool { } for _, matcher := range r.routes { - if matcher.Match(path, t) { + if matcher.Match(path, t, t2) { return true } } @@ -111,8 +111,17 @@ func (r *Mux) Handle(path string, event *InteractionEvent) error { handlerChain := Handler(func(event *InteractionEvent) error { path = parseVariables(path, r.pattern, event.Vars) + t := event.Type() + var t2 int + switch i := event.Interaction.(type) { + case discord.ApplicationCommandInteraction: + t2 = int(i.Data.Type()) + case discord.ComponentInteraction: + t2 = int(i.Data.Type()) + } + for _, route := range r.routes { - if route.Match(path, event.Type()) { + if route.Match(path, t, t2) { return route.Handle(path, event) } } @@ -189,6 +198,39 @@ func (r *Mux) Command(pattern string, h CommandHandler) { }) } +// SlashCommand registers the given SlashCommandHandler to the current Router. +func (r *Mux) SlashCommand(pattern string, h SlashCommandHandler) { + checkPattern(pattern) + r.handle(&handlerHolder[SlashCommandHandler]{ + pattern: pattern, + handler: h, + t: discord.InteractionTypeApplicationCommand, + t2: []int{discord.ApplicationCommandTypeSlash}, + }) +} + +// UserCommand registers the given UserCommandHandler to the current Router. +func (r *Mux) UserCommand(pattern string, h UserCommandHandler) { + checkPattern(pattern) + r.handle(&handlerHolder[UserCommandHandler]{ + pattern: pattern, + handler: h, + t: discord.InteractionTypeApplicationCommand, + t2: []int{discord.ApplicationCommandTypeUser}, + }) +} + +// MessageCommand registers the given MessageCommandHandler to the current Router. +func (r *Mux) MessageCommand(pattern string, h MessageCommandHandler) { + checkPattern(pattern) + r.handle(&handlerHolder[MessageCommandHandler]{ + pattern: pattern, + handler: h, + t: discord.InteractionTypeApplicationCommand, + t2: []int{discord.ApplicationCommandTypeMessage}, + }) +} + // Autocomplete registers the given AutocompleteHandler to the current Router. func (r *Mux) Autocomplete(pattern string, h AutocompleteHandler) { checkPattern(pattern) @@ -209,6 +251,34 @@ func (r *Mux) Component(pattern string, h ComponentHandler) { }) } +// ButtonComponent registers the given ButtonComponentHandler to the current Router. +func (r *Mux) ButtonComponent(pattern string, h ButtonComponentHandler) { + checkPattern(pattern) + r.handle(&handlerHolder[ButtonComponentHandler]{ + pattern: pattern, + handler: h, + t: discord.InteractionTypeComponent, + t2: []int{discord.ComponentTypeButton}, + }) +} + +// SelectMenuComponent registers the given SelectMenuComponentHandler to the current Router. +func (r *Mux) SelectMenuComponent(pattern string, h SelectMenuComponentHandler) { + checkPattern(pattern) + r.handle(&handlerHolder[SelectMenuComponentHandler]{ + pattern: pattern, + handler: h, + t: discord.InteractionTypeComponent, + t2: []int{ + discord.ComponentTypeStringSelectMenu, + discord.ComponentTypeUserSelectMenu, + discord.ComponentTypeRoleSelectMenu, + discord.ComponentTypeMentionableSelectMenu, + discord.ComponentTypeChannelSelectMenu, + }, + }) +} + // Modal registers the given ModalHandler to the current Router. func (r *Mux) Modal(pattern string, h ModalHandler) { checkPattern(pattern) diff --git a/handler/mux_test.go b/handler/mux_test.go new file mode 100644 index 00000000..c2de3f0c --- /dev/null +++ b/handler/mux_test.go @@ -0,0 +1,158 @@ +package handler + +import ( + "os" + "testing" + + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/events" + "github.com/disgoorg/disgo/rest" + "github.com/stretchr/testify/assert" +) + +func NewRecorder() *InteractionResponseRecorder { + return &InteractionResponseRecorder{} +} + +type InteractionResponseRecorder struct { + Response *discord.InteractionResponse +} + +func (i *InteractionResponseRecorder) Respond(responseType discord.InteractionResponseType, data discord.InteractionResponseData, opts ...rest.RequestOpt) error { + i.Response = &discord.InteractionResponse{ + Type: responseType, + Data: data, + } + return nil +} + +func TestCommandMux(t *testing.T) { + slashData, err := os.ReadFile("testdata/command/slash_command.json") + assert.NoError(t, err) + + userData, err := os.ReadFile("testdata/command/user_command.json") + assert.NoError(t, err) + + messageData, err := os.ReadFile("testdata/command/message_command.json") + assert.NoError(t, err) + + data := []struct { + data []byte + expected *discord.InteractionResponse + }{ + { + data: slashData, + expected: &discord.InteractionResponse{ + Type: discord.InteractionResponseTypeCreateMessage, + Data: discord.MessageCreate{ + Content: "bar", + }, + }, + }, + { + data: userData, + expected: &discord.InteractionResponse{ + Type: discord.InteractionResponseTypeCreateMessage, + Data: discord.MessageCreate{ + Content: "bar2", + }, + }, + }, + { + data: messageData, + expected: &discord.InteractionResponse{ + Type: discord.InteractionResponseTypeCreateMessage, + Data: discord.MessageCreate{ + Content: "bar3", + }, + }, + }, + } + + mux := New() + mux.SlashCommand("/foo", func(data discord.SlashCommandInteractionData, e *CommandEvent) error { + return e.CreateMessage(discord.MessageCreate{ + Content: "bar", + }) + }) + mux.UserCommand("/foo", func(data discord.UserCommandInteractionData, e *CommandEvent) error { + return e.CreateMessage(discord.MessageCreate{ + Content: "bar2", + }) + }) + mux.MessageCommand("/foo", func(data discord.MessageCommandInteractionData, e *CommandEvent) error { + return e.CreateMessage(discord.MessageCreate{ + Content: "bar3", + }) + }) + + for _, d := range data { + interaction, err := discord.UnmarshalInteraction(d.data) + assert.NoError(t, err) + + recorder := NewRecorder() + mux.OnEvent(&events.InteractionCreate{ + GenericEvent: events.NewGenericEvent(nil, 0, 0), + Interaction: interaction, + Respond: recorder.Respond, + }) + assert.Equal(t, d.expected, recorder.Response) + } +} + +func TestComponentMux(t *testing.T) { + buttonData, err := os.ReadFile("testdata/component/button_component.json") + assert.NoError(t, err) + + selectMenuData, err := os.ReadFile("testdata/component/select_menu_component.json") + assert.NoError(t, err) + + data := []struct { + data []byte + expected *discord.InteractionResponse + }{ + { + data: buttonData, + expected: &discord.InteractionResponse{ + Type: discord.InteractionResponseTypeCreateMessage, + Data: discord.MessageCreate{ + Content: "bar", + }, + }, + }, + { + data: selectMenuData, + expected: &discord.InteractionResponse{ + Type: discord.InteractionResponseTypeCreateMessage, + Data: discord.MessageCreate{ + Content: "bar2", + }, + }, + }, + } + + mux := New() + mux.ButtonComponent("/foo", func(data discord.ButtonInteractionData, e *ComponentEvent) error { + return e.CreateMessage(discord.MessageCreate{ + Content: "bar", + }) + }) + mux.SelectMenuComponent("/foo", func(data discord.SelectMenuInteractionData, e *ComponentEvent) error { + return e.CreateMessage(discord.MessageCreate{ + Content: "bar2", + }) + }) + + for _, d := range data { + interaction, err := discord.UnmarshalInteraction(d.data) + assert.NoError(t, err) + + recorder := NewRecorder() + mux.OnEvent(&events.InteractionCreate{ + GenericEvent: events.NewGenericEvent(nil, 0, 0), + Interaction: interaction, + Respond: recorder.Respond, + }) + assert.Equal(t, d.expected, recorder.Response) + } +} diff --git a/handler/router.go b/handler/router.go index f602f500..da553cb4 100644 --- a/handler/router.go +++ b/handler/router.go @@ -6,13 +6,18 @@ import ( ) type ( - InteractionHandler func(e *InteractionEvent) error - CommandHandler func(e *CommandEvent) error - AutocompleteHandler func(e *AutocompleteEvent) error - ComponentHandler func(e *ComponentEvent) error - ModalHandler func(e *ModalEvent) error - NotFoundHandler func(e *InteractionEvent) error - ErrorHandler func(e *InteractionEvent, err error) + InteractionHandler func(e *InteractionEvent) error + CommandHandler func(e *CommandEvent) error + SlashCommandHandler func(data discord.SlashCommandInteractionData, e *CommandEvent) error + UserCommandHandler func(data discord.UserCommandInteractionData, e *CommandEvent) error + MessageCommandHandler func(data discord.MessageCommandInteractionData, e *CommandEvent) error + AutocompleteHandler func(e *AutocompleteEvent) error + ComponentHandler func(e *ComponentEvent) error + ButtonComponentHandler func(data discord.ButtonInteractionData, e *ComponentEvent) error + SelectMenuComponentHandler func(data discord.SelectMenuInteractionData, e *ComponentEvent) error + ModalHandler func(e *ModalEvent) error + NotFoundHandler func(e *InteractionEvent) error + ErrorHandler func(e *InteractionEvent, err error) ) var ( @@ -26,7 +31,7 @@ var ( // Route is a basic interface for a route in a Router. type Route interface { // Match returns true if the given path matches the Route. - Match(path string, t discord.InteractionType) bool + Match(path string, t discord.InteractionType, t2 int) bool // Handle handles the given interaction event. Handle(path string, e *InteractionEvent) error @@ -59,12 +64,24 @@ type Router interface { // Command registers the given CommandHandler to the current Router. Command(pattern string, h CommandHandler) + // UserCommand registers the given UserCommandHandler to the current Router. + UserCommand(pattern string, h UserCommandHandler) + + // MessageCommand registers the given MessageCommandHandler to the current Router. + MessageCommand(pattern string, h MessageCommandHandler) + // Autocomplete registers the given AutocompleteHandler to the current Router. Autocomplete(pattern string, h AutocompleteHandler) // Component registers the given ComponentHandler to the current Router. Component(pattern string, h ComponentHandler) + // ButtonComponent registers the given ButtonComponentHandler to the current Router. + ButtonComponent(pattern string, h ButtonComponentHandler) + + // SelectMenuComponent registers the given SelectMenuComponentHandler to the current Router. + SelectMenuComponent(pattern string, h SelectMenuComponentHandler) + // Modal registers the given ModalHandler to the current Router. Modal(pattern string, h ModalHandler) } diff --git a/handler/testdata/command/message_command.json b/handler/testdata/command/message_command.json new file mode 100644 index 00000000..b5879870 --- /dev/null +++ b/handler/testdata/command/message_command.json @@ -0,0 +1,65 @@ +{ + "application_id": "775799577604522054", + "channel_id": "772908445358620702", + "data": { + "id": "866818195033292851", + "name": "foo", + "resolved": { + "messages": { + "867793854505943041": { + "attachments": [], + "author": { + "avatar": "a_f03401914fb4f3caa9037578ab980920", + "discriminator": "6538", + "id": "167348773423415296", + "public_flags": 1, + "username": "ian" + }, + "channel_id": "772908445358620702", + "components": [], + "content": "some message", + "edited_timestamp": null, + "embeds": [], + "flags": 0, + "id": "867793854505943041", + "mention_everyone": false, + "mention_roles": [], + "mentions": [], + "pinned": false, + "timestamp": "2021-07-22T15:42:57.744000+00:00", + "tts": false, + "type": 0 + } + } + }, + "target_id": "867793854505943041", + "type": 3 + }, + "guild_id": "772904309264089089", + "guild_locale": "en-US", + "app_permissions": "442368", + "id": "867793873336926249", + "locale": "en-US", + "member": { + "avatar": null, + "deaf": false, + "is_pending": false, + "joined_at": "2020-11-02T20:46:57.364000+00:00", + "mute": false, + "nick": null, + "pending": false, + "permissions": "274877906943", + "premium_since": null, + "roles": ["785609923542777878"], + "user": { + "avatar": "a_f03401914fb4f3caa9037578ab980920", + "discriminator": "6538", + "id": "167348773423415296", + "public_flags": 1, + "username": "ian" + } + }, + "token": "UNIQUE_TOKEN", + "type": 2, + "version": 1 +} \ No newline at end of file diff --git a/handler/testdata/command/slash_command.json b/handler/testdata/command/slash_command.json new file mode 100644 index 00000000..19fd0ce4 --- /dev/null +++ b/handler/testdata/command/slash_command.json @@ -0,0 +1,33 @@ +{ + "type": 2, + "token": "A_UNIQUE_TOKEN", + "member": { + "user": { + "id": "53908232506183680", + "username": "Mason", + "avatar": "a_d5efa99b3eeaa7dd43acca82f5692432", + "discriminator": "1337", + "public_flags": 131141 + }, + "roles": ["539082325061836999"], + "premium_since": null, + "permissions": "2147483647", + "pending": false, + "nick": null, + "mute": false, + "joined_at": "2017-03-13T19:19:14.040000+00:00", + "is_pending": false, + "deaf": false + }, + "id": "786008729715212338", + "guild_id": "290926798626357999", + "app_permissions": "442368", + "guild_locale": "en-US", + "locale": "en-US", + "data": { + "type": 1, + "name": "foo", + "id": "771825006014889984" + }, + "channel_id": "645027906669510667" +} \ No newline at end of file diff --git a/handler/testdata/command/user_command.json b/handler/testdata/command/user_command.json new file mode 100644 index 00000000..817e16bd --- /dev/null +++ b/handler/testdata/command/user_command.json @@ -0,0 +1,61 @@ +{ + "application_id": "775799577604522054", + "channel_id": "772908445358620702", + "data": { + "id": "866818195033292850", + "name": "foo", + "resolved": { + "members": { + "809850198683418695": { + "avatar": null, + "is_pending": false, + "joined_at": "2021-02-12T18:25:07.972000+00:00", + "nick": null, + "pending": false, + "permissions": "246997699136", + "premium_since": null, + "roles": [] + } + }, + "users": { + "809850198683418695": { + "avatar": "afc428077119df8aabbbd84b0dc90c74", + "bot": true, + "discriminator": "7302", + "id": "809850198683418695", + "public_flags": 0, + "username": "VoltyDemo" + } + } + }, + "target_id": "809850198683418695", + "type": 2 + }, + "guild_id": "772904309264089089", + "guild_locale": "en-US", + "app_permissions": "442368", + "id": "867794291820986368", + "locale": "en-US", + "member": { + "avatar": null, + "deaf": false, + "is_pending": false, + "joined_at": "2020-11-02T20:46:57.364000+00:00", + "mute": false, + "nick": null, + "pending": false, + "permissions": "274877906943", + "premium_since": null, + "roles": ["785609923542777878"], + "user": { + "avatar": "a_f03401914fb4f3caa9037578ab980920", + "discriminator": "6538", + "id": "167348773423415296", + "public_flags": 1, + "username": "ian" + } + }, + "token": "UNIQUE_TOKEN", + "type": 2, + "version": 1 +} \ No newline at end of file diff --git a/handler/testdata/component/button_component.json b/handler/testdata/component/button_component.json new file mode 100644 index 00000000..179702ea --- /dev/null +++ b/handler/testdata/component/button_component.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "type": 3, + "token": "unique_interaction_token", + "message": { + "type": 0, + "tts": false, + "timestamp": "2021-05-19T02:12:51.710000+00:00", + "pinned": false, + "mentions": [], + "mention_roles": [], + "mention_everyone": false, + "id": "844397162624450620", + "flags": 0, + "embeds": [], + "edited_timestamp": null, + "content": "This is a message with components.", + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "label": "Click me!", + "style": 1, + "custom_id": "foo" + } + ] + } + ], + "channel_id": "345626669114982402", + "author": { + "username": "Mason", + "public_flags": 131141, + "id": "53908232506183680", + "discriminator": "1337", + "avatar": "a_d5efa99b3eeaa7dd43acca82f5692432" + }, + "attachments": [] + }, + "member": { + "user": { + "username": "Mason", + "public_flags": 131141, + "id": "53908232506183680", + "discriminator": "1337", + "avatar": "a_d5efa99b3eeaa7dd43acca82f5692432" + }, + "roles": [ + "290926798626357999" + ], + "premium_since": null, + "permissions": "17179869183", + "pending": false, + "nick": null, + "mute": false, + "joined_at": "2017-03-13T19:19:14.040000+00:00", + "is_pending": false, + "deaf": false, + "avatar": null + }, + "id": "846462639134605312", + "guild_id": "290926798626357999", + "data": { + "custom_id": "foo", + "component_type": 2 + }, + "channel_id": "345626669114982999", + "application_id": "290926444748734465" +} \ No newline at end of file diff --git a/handler/testdata/component/select_menu_component.json b/handler/testdata/component/select_menu_component.json new file mode 100644 index 00000000..ca703c26 --- /dev/null +++ b/handler/testdata/component/select_menu_component.json @@ -0,0 +1,119 @@ +{ + "application_id": "845027738276462632", + "channel_id": "772908445358620702", + "data": { + "component_type":3, + "custom_id": "foo", + "values": [ + "mage", + "rogue" + ] + }, + "guild_id": "772904309264089089", + "id": "847587388497854464", + "member": { + "avatar": null, + "deaf": false, + "is_pending": false, + "joined_at": "2020-11-02T19:25:47.248000+00:00", + "mute": false, + "nick": "Bot Man", + "pending": false, + "permissions": "17179869183", + "premium_since": null, + "roles": [ + "785609923542777878" + ], + "user":{ + "avatar": "a_d5efa99b3eeaa7dd43acca82f5692432", + "discriminator": "1337", + "id": "53908232506183680", + "public_flags": 131141, + "username": "Mason" + } + }, + "message":{ + "application_id": "845027738276462632", + "attachments": [], + "author": { + "avatar": null, + "bot": true, + "discriminator": "5284", + "id": "845027738276462632", + "public_flags": 0, + "username": "Interactions Test" + }, + "channel_id": "772908445358620702", + "components": [ + { + "components": [ + { + "custom_id": "foo", + "max_values": 1, + "min_values": 1, + "options": [ + { + "description": "Sneak n stab", + "emoji":{ + "id": "625891304148303894", + "name": "rogue" + }, + "label": "Rogue", + "value": "rogue" + }, + { + "description": "Turn 'em into a sheep", + "emoji":{ + "id": "625891304081063986", + "name": "mage" + }, + "label": "Mage", + "value": "mage" + }, + { + "description": "You get heals when I'm done doing damage", + "emoji":{ + "id": "625891303795982337", + "name": "priest" + }, + "label": "Priest", + "value": "priest" + } + ], + "placeholder": "Choose a class", + "type": 3 + } + ], + "type": 1 + } + ], + "content": "Mason is looking for new arena partners. What classes do you play?", + "edited_timestamp": null, + "embeds": [], + "flags": 0, + "id": "847587334500646933", + "interaction": { + "id": "847587333942935632", + "name": "dropdown", + "type": 2, + "user": { + "avatar": "a_d5efa99b3eeaa7dd43acca82f5692432", + "discriminator": "1337", + "id": "53908232506183680", + "public_flags": 131141, + "username": "Mason" + } + }, + "mention_everyone": false, + "mention_roles":[], + "mentions":[], + "pinned": false, + "timestamp": "2021-05-27T21:29:27.956000+00:00", + "tts": false, + "type": 20, + "webhook_id": "845027738276462632" + }, + "token": "UNIQUE_TOKEN", + "type": 3, + "version": 1 +} \ No newline at end of file