diff --git a/api/cmdroute/fntypes.go b/api/cmdroute/fntypes.go new file mode 100644 index 0000000..e48cd43 --- /dev/null +++ b/api/cmdroute/fntypes.go @@ -0,0 +1,92 @@ +package cmdroute + +import ( + "context" + + "github.com/diamondburned/arikawa/v3/api" + "github.com/diamondburned/arikawa/v3/discord" +) + +// InteractionHandler is similar to webhook.InteractionHandler, but it also +// includes a context. +type InteractionHandler interface { + // HandleInteraction is expected to return a response synchronously, either + // to be followed-up later by deferring the response or to be responded + // immediately. + HandleInteraction(context.Context, *discord.InteractionEvent) *api.InteractionResponse +} + +// InteractionHandlerFunc is a function that implements InteractionHandler. +type InteractionHandlerFunc func(context.Context, *discord.InteractionEvent) *api.InteractionResponse + +var _ InteractionHandler = InteractionHandlerFunc(nil) + +// HandleInteraction implements InteractionHandler. +func (f InteractionHandlerFunc) HandleInteraction(ctx context.Context, e *discord.InteractionEvent) *api.InteractionResponse { + return f(ctx, e) +} + +// Middleware is a function type that wraps a Handler. It can be used as a +// middleware for the handler. +type Middleware = func(next InteractionHandler) InteractionHandler + +/* + * Command + */ + +// CommandData is passed to a CommandHandler's HandleCommand method. +type CommandData struct { + discord.CommandInteractionOption + Event *discord.InteractionEvent +} + +// CommandHandler is a slash command handler. +type CommandHandler interface { + // HandleCommand is expected to return a response synchronously, either to + // be followed-up later by deferring the response or to be responded + // immediately. + // + // All HandleCommand invocations are given a 3-second deadline. If the + // handler does not return a response within the deadline, the response will + // be automatically deferred in a goroutine, and the returned response will + // be sent to the user through the API instead. + HandleCommand(ctx context.Context, data CommandData) *api.InteractionResponseData +} + +// CommandHandlerFunc is a function that implements CommandHandler. +type CommandHandlerFunc func(ctx context.Context, data CommandData) *api.InteractionResponseData + +var _ CommandHandler = CommandHandlerFunc(nil) + +// HandleCommand implements CommandHandler. +func (f CommandHandlerFunc) HandleCommand(ctx context.Context, data CommandData) *api.InteractionResponseData { + return f(ctx, data) +} + +/* + * Autocomplete + */ + +// AutocompleteData is passed to an Autocompleter's Autocomplete method. +type AutocompleteData struct { + discord.AutocompleteOption + Event *discord.InteractionEvent +} + +// Autocompleter is a type for an autocompleter. +type Autocompleter interface { + // Autocomplete is expected to return a list of choices synchronously. + // If nil is returned, then no responses will be sent. The function must + // return an empty slice if there are no choices. + Autocomplete(ctx context.Context, data AutocompleteData) api.AutocompleteChoices +} + +// AutocompleterFunc is a function that implements the Autocompleter interface. +type AutocompleterFunc func(ctx context.Context, data AutocompleteData) api.AutocompleteChoices + +var _ Autocompleter = (AutocompleterFunc)(nil) + +// Autocomplete implements webhook.InteractionHandler. +func (f AutocompleterFunc) Autocomplete(ctx context.Context, data AutocompleteData) api.AutocompleteChoices { + return f(ctx, data) +} diff --git a/api/cmdroute/middlewares.go b/api/cmdroute/middlewares.go new file mode 100644 index 0000000..6acd130 --- /dev/null +++ b/api/cmdroute/middlewares.go @@ -0,0 +1,145 @@ +package cmdroute + +import ( + "context" + "time" + + "github.com/diamondburned/arikawa/v3/api" + "github.com/diamondburned/arikawa/v3/discord" +) + +type ctxKey uint8 + +const ( + _ ctxKey = iota + ctxCtx + deferTicketCtx +) + +// UseContext returns a middleware that override the handler context to the +// given context. This middleware should only be used once in the parent-most +// router. +func UseContext(ctx context.Context) Middleware { + return func(next InteractionHandler) InteractionHandler { + return InteractionHandlerFunc(func(_ context.Context, ev *discord.InteractionEvent) *api.InteractionResponse { + return next.HandleInteraction(ctx, ev) + }) + } +} + +// FollowUpSender is a type that can send follow-up messages. Usually, anything +// that extends *api.Client can be used as a FollowUpSender. +type FollowUpSender interface { + FollowUpInteraction(appID discord.AppID, token string, data api.InteractionResponseData) (*discord.Message, error) +} + +// DeferOpts is the options for Deferrable(). +type DeferOpts struct { + // Timeout is the timeout for the handler to return a response. If the + // handler does not return within this timeout, then it is deferred. + // + // Defaults to 1.5 seconds. + Timeout time.Duration + // Flags is the flags to set on the response. + Flags discord.MessageFlags + // Error is called when a follow-up message fails to send. If nil, it does + // nothing. + Error func(err error) + // Done is called when the handler is done. If nil, it does nothing. + Done func(*discord.Message) +} + +// Deferrable marks a router as deferrable, meaning if the handler does not +// return a response within the deadline, the response will be automatically +// deferred. +func Deferrable(client FollowUpSender, opts DeferOpts) Middleware { + if opts.Timeout == 0 { + opts.Timeout = 1*time.Second + 500*time.Millisecond + } + + return func(next InteractionHandler) InteractionHandler { + return InteractionHandlerFunc(func(ctx context.Context, ev *discord.InteractionEvent) *api.InteractionResponse { + timeout, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + respCh := make(chan *api.InteractionResponse, 1) + go func() { + ctx := context.WithValue(ctx, deferTicketCtx, DeferTicket{ + ctx: timeout, + deferFn: cancel, + }) + + resp := next.HandleInteraction(ctx, ev) + if resp != nil && opts.Flags > 0 { + if resp.Data != nil { + resp.Data.Flags = opts.Flags + } else { + resp.Data = &api.InteractionResponseData{ + Flags: opts.Flags, + } + } + } + + respCh <- resp + }() + + select { + case resp := <-respCh: + return resp + case <-timeout.Done(): + go func() { + resp := <-respCh + if resp == nil || resp.Data == nil { + return + } + m, err := client.FollowUpInteraction(ev.AppID, ev.Token, *resp.Data) + if err != nil && opts.Error != nil { + opts.Error(err) + } + if m != nil && opts.Done != nil { + opts.Done(m) + } + }() + return &api.InteractionResponse{ + Type: api.DeferredMessageInteractionWithSource, + Data: &api.InteractionResponseData{ + Flags: opts.Flags, + }, + } + } + }) + } +} + +// DeferTicket is a ticket that can be used to defer a slash command. It can be +// used to manually send a response later. +type DeferTicket struct { + ctx context.Context + deferFn context.CancelFunc +} + +// DeferTicketFromContext returns the DeferTicket from the context. If no ticket +// is found, it returns a zero-value ticket. +func DeferTicketFromContext(ctx context.Context) DeferTicket { + ticket, _ := ctx.Value(deferTicketCtx).(DeferTicket) + return ticket +} + +// IsDeferred returns true if the handler has been deferred. +func (t DeferTicket) IsDeferred() bool { + return t.Context().Err() != nil +} + +// Context returns the context that is done when the handler is deferred. If +// DeferTicket is zero-value, it returns the background context. +func (t DeferTicket) Context() context.Context { + if t.ctx == nil { + return context.Background() + } + return t.ctx +} + +// Defer defers the response. If DeferTicket is zero-value, it does nothing. +func (t DeferTicket) Defer() { + t.deferFn() +} diff --git a/api/cmdroute/router.go b/api/cmdroute/router.go new file mode 100644 index 0000000..6e56971 --- /dev/null +++ b/api/cmdroute/router.go @@ -0,0 +1,295 @@ +package cmdroute + +import ( + "context" + + "github.com/diamondburned/arikawa/v3/api" + "github.com/diamondburned/arikawa/v3/api/webhook" + "github.com/diamondburned/arikawa/v3/discord" +) + +// Router is a router for slash commands. A zero-value Router is a valid router. +type Router struct { + nodes map[string]routeNode + mws []Middleware + stack []*Router +} + +type routeNode struct { + sub *Router + cmd CommandHandler + com Autocompleter +} + +var _ webhook.InteractionHandler = (*Router)(nil) + +// NewRouter creates a new Router. +func NewRouter() *Router { + r := &Router{} + r.init() + return r +} + +func (r *Router) init() { + if r.stack == nil { + r.stack = []*Router{r} + } + if r.nodes == nil { + r.nodes = make(map[string]routeNode, 4) + } +} + +// Use adds a middleware to the router. The middleware is applied to all +// subcommands and subrouters. Middlewares are applied in the order they are +// added, with the middlewares in the parent router being applied first. +func (r *Router) Use(mws ...Middleware) { + r.init() + r.mws = append(r.mws, mws...) +} + +// Sub creates a subrouter that handles all subcommands that are under the +// parent command of the given name. +func (r *Router) Sub(name string, f func(r *Router)) { + r.init() + + node, ok := r.nodes[name] + if ok && node.sub == nil { + panic("cmdroute: command " + name + " already exists") + } + + sub := NewRouter() + sub.stack = append(append([]*Router(nil), r.stack...), sub) + f(sub) + + r.nodes[name] = routeNode{sub: sub} +} + +// Add registers a slash command handler for the given command name. +func (r *Router) Add(name string, h CommandHandler) { + r.init() + + node, ok := r.nodes[name] + if ok { + panic("cmdroute: command " + name + " already exists") + } + + node.cmd = h + r.nodes[name] = node +} + +// AddFunc is a convenience function that calls Handle with a +// CommandHandlerFunc. +func (r *Router) AddFunc(name string, f CommandHandlerFunc) { + r.Add(name, f) +} + +// HandleInteraction implements webhook.InteractionHandler. It only handles +// events of type CommandInteraction, otherwise nil is returned. +func (r *Router) HandleInteraction(ev *discord.InteractionEvent) *api.InteractionResponse { + switch data := ev.Data.(type) { + case *discord.CommandInteraction: + return r.HandleCommand(ev, data) + case *discord.AutocompleteInteraction: + return r.HandleAutocompletion(ev, data) + default: + return nil + } +} + +func (r *Router) handleInteraction(ev *discord.InteractionEvent, fn InteractionHandlerFunc) *api.InteractionResponse { + h := InteractionHandler(fn) + + // Apply middlewares, parent last, first one added last. This ensures that + // when we call the handler, the middlewares are applied in the order they + // were added. + for i := len(r.stack) - 1; i >= 0; i-- { + r := r.stack[i] + for j := len(r.mws) - 1; j >= 0; j-- { + h = r.mws[j](h) + } + } + + return h.HandleInteraction(context.Background(), ev) +} + +// HandleCommand implements CommandHandler. It applies middlewares onto the +// handler to be executed. +func (r *Router) HandleCommand(ev *discord.InteractionEvent, data *discord.CommandInteraction) *api.InteractionResponse { + cmdType := discord.SubcommandOptionType + if cmdIsGroup(data) { + cmdType = discord.SubcommandGroupOptionType + } + + found, ok := r.findHandler(ev, discord.CommandInteractionOption{ + Type: cmdType, + Name: data.Name, + Options: data.Options, + }) + if !ok { + return nil + } + + return found.router.handleCommand(ev, found) +} + +func (r *Router) handleCommand(ev *discord.InteractionEvent, found handlerData) *api.InteractionResponse { + return r.handleInteraction(ev, + func(ctx context.Context, ev *discord.InteractionEvent) *api.InteractionResponse { + data := found.handler.HandleCommand(ctx, CommandData{ + CommandInteractionOption: found.data, + Event: ev, + }) + if data == nil { + return nil + } + + return &api.InteractionResponse{ + Type: api.MessageInteractionWithSource, + Data: data, + } + }, + ) +} + +func cmdIsGroup(data *discord.CommandInteraction) bool { + for _, opt := range data.Options { + switch opt.Type { + case discord.SubcommandGroupOptionType, discord.SubcommandOptionType: + return true + } + } + return false +} + +type handlerData struct { + router *Router + handler CommandHandler + data discord.CommandInteractionOption +} + +func (r *Router) findHandler(ev *discord.InteractionEvent, data discord.CommandInteractionOption) (handlerData, bool) { + node, ok := r.nodes[data.Name] + if !ok { + return handlerData{}, false + } + + switch { + case node.sub != nil: + if len(data.Options) != 1 || data.Type != discord.SubcommandGroupOptionType { + break + } + return node.sub.findHandler(ev, data.Options[0]) + case node.cmd != nil: + if data.Type != discord.SubcommandOptionType { + break + } + return handlerData{ + router: r, + handler: node.cmd, + data: data, + }, true + } + + return handlerData{}, false +} + +// AddAutocompleter registers an autocompleter for the given command name. +func (r *Router) AddAutocompleter(name string, ac Autocompleter) { + r.init() + + node, ok := r.nodes[name] + if !ok || node.cmd == nil { + panic("cmdroute: command " + name + " does not exist or is not a (sub)command") + } + + node.com = ac + r.nodes[name] = node +} + +// AddAutocompleterFunc is a convenience function that calls AddAutocompleter +// with an AutocompleterFunc. +func (r *Router) AddAutocompleterFunc(name string, f AutocompleterFunc) { + r.AddAutocompleter(name, f) +} + +// HandleAutocompletion handles an autocompletion event. +func (r *Router) HandleAutocompletion(ev *discord.InteractionEvent, data *discord.AutocompleteInteraction) *api.InteractionResponse { + cmdType := discord.SubcommandOptionType + if autocompIsGroup(data) { + cmdType = discord.SubcommandGroupOptionType + } + + found, ok := r.findAutocompleter(ev, discord.AutocompleteOption{ + Type: cmdType, + Name: data.Name, + Options: data.Options, + }) + if !ok { + return nil + } + + return found.router.handleAutocompletion(ev, found) +} + +func (r *Router) handleAutocompletion(ev *discord.InteractionEvent, found autocompleterData) *api.InteractionResponse { + return r.handleInteraction(ev, + func(ctx context.Context, ev *discord.InteractionEvent) *api.InteractionResponse { + choices := found.handler.Autocomplete(ctx, AutocompleteData{ + AutocompleteOption: found.data, + Event: ev, + }) + if choices == nil { + return nil + } + + return &api.InteractionResponse{ + Type: api.AutocompleteResult, + Data: &api.InteractionResponseData{ + Choices: choices, + }, + } + }, + ) +} + +func autocompIsGroup(data *discord.AutocompleteInteraction) bool { + for _, opt := range data.Options { + switch opt.Type { + case discord.SubcommandGroupOptionType, discord.SubcommandOptionType: + return true + } + } + return false +} + +type autocompleterData struct { + router *Router + handler Autocompleter + data discord.AutocompleteOption +} + +func (r *Router) findAutocompleter(ev *discord.InteractionEvent, data discord.AutocompleteOption) (autocompleterData, bool) { + node, ok := r.nodes[data.Name] + if !ok { + return autocompleterData{}, false + } + + switch { + case node.sub != nil: + if len(data.Options) != 1 || data.Type != discord.SubcommandGroupOptionType { + break + } + return node.sub.findAutocompleter(ev, data.Options[0]) + case node.com != nil: + if data.Type != discord.SubcommandOptionType { + break + } + return autocompleterData{ + router: r, + handler: node.com, + data: data, + }, true + } + + return autocompleterData{}, false +} diff --git a/api/cmdroute/router_test.go b/api/cmdroute/router_test.go new file mode 100644 index 0000000..f8646be --- /dev/null +++ b/api/cmdroute/router_test.go @@ -0,0 +1,388 @@ +package cmdroute + +import ( + "bytes" + "context" + "fmt" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/diamondburned/arikawa/v3/api" + "github.com/diamondburned/arikawa/v3/discord" + "github.com/diamondburned/arikawa/v3/utils/json" + "github.com/diamondburned/arikawa/v3/utils/json/option" +) + +func TestRouter(t *testing.T) { + t.Run("command", func(t *testing.T) { + r := NewRouter() + r.Add("test", assertHandler(t, mockOptions)) + r.HandleInteraction(newInteractionEvent(discord.CommandInteraction{ + ID: 4, + Name: "test", + Options: mockOptions, + })) + }) + + t.Run("subcommand", func(t *testing.T) { + r := NewRouter() + r.Sub("test", func(r *Router) { r.Add("sub", assertHandler(t, mockOptions)) }) + r.HandleInteraction(newInteractionEvent(discord.CommandInteraction{ + ID: 4, + Name: "test", + Options: []discord.CommandInteractionOption{ + { + Name: "sub", + Type: discord.SubcommandOptionType, + Options: mockOptions, + }, + }, + })) + }) + + t.Run("unknown", func(t *testing.T) { + r := NewRouter() + r.AddFunc("test", func(ctx context.Context, data CommandData) *api.InteractionResponseData { + t.Fatal("unexpected call") + return nil + }) + r.HandleInteraction(newInteractionEvent(discord.CommandInteraction{ + ID: 4, + Name: "unknown", + })) + }) + + t.Run("return", func(t *testing.T) { + data := &api.InteractionResponseData{ + Content: option.NewNullableString("pong"), + } + + r := NewRouter() + r.AddFunc("ping", func(_ context.Context, _ CommandData) *api.InteractionResponseData { + return data + }) + resp := r.HandleInteraction(newInteractionEvent(discord.CommandInteraction{ + ID: 4, + Name: "ping", + Options: mockOptions, + })) + + if resp.Data != data { + t.Fatal("unexpected response") + } + }) + + t.Run("autocomplete", func(t *testing.T) { + choices := []string{ + "foo", + "bar", + "baz", + } + + r := NewRouter() + r.AddFunc("ping", func(_ context.Context, _ CommandData) *api.InteractionResponseData { + return nil + }) + r.AddAutocompleterFunc("ping", func(_ context.Context, comp AutocompleteData) api.AutocompleteChoices { + var data struct { + Str string `discord:"str"` + } + + if err := comp.Options.Unmarshal(&data); err != nil { + t.Fatal("unexpected error:", err) + } + + switch comp.Options.Focused().Name { + case "str": + matches := api.AutocompleteStringChoices{} + for _, choice := range choices { + if strings.HasPrefix(choice, data.Str) { + matches = append(matches, discord.StringChoice{ + Name: strings.ToUpper(choice), + Value: choice, + }) + } + } + return matches + default: + return nil + } + }) + + assertInteractionResp(t, + r.HandleInteraction(&discord.InteractionEvent{ + Token: "token", + Data: &discord.AutocompleteInteraction{ + Name: "ping", + CommandType: discord.ChatInputCommand, + Options: []discord.AutocompleteOption{ + { + Type: discord.StringOptionType, + Name: "str", + Value: json.Raw(`"b"`), + Focused: true, + }, + }, + }, + }), + &api.InteractionResponse{ + Type: api.AutocompleteResult, + Data: &api.InteractionResponseData{ + Choices: api.AutocompleteStringChoices{ + {Name: "BAR", Value: "bar"}, + {Name: "BAZ", Value: "baz"}, + }, + }, + }, + ) + }) + + t.Run("middlewares", func(t *testing.T) { + var stack []string + pushStack := func(s string) Middleware { + return func(next InteractionHandler) InteractionHandler { + return InteractionHandlerFunc(func(ctx context.Context, ev *discord.InteractionEvent) *api.InteractionResponse { + stack = append(stack, s) + return next.HandleInteraction(ctx, ev) + }) + } + } + + r := NewRouter() + r.Use(pushStack("root1")) + r.Use(pushStack("root2")) + r.Sub("test", func(r *Router) { + r.Use(pushStack("sub1.1")) + r.Use(pushStack("sub1.2")) + r.Sub("sub1", func(r *Router) { + r.Use(pushStack("sub2.1")) + r.Use(pushStack("sub2.2")) + r.Add("sub2", assertHandler(t, mockOptions)) + }) + }) + r.HandleInteraction(newInteractionEvent(discord.CommandInteraction{ + ID: 4, + Name: "test", + Options: []discord.CommandInteractionOption{ + { + Name: "sub1", + Type: discord.SubcommandGroupOptionType, + Options: []discord.CommandInteractionOption{ + { + Name: "sub2", + Type: discord.SubcommandOptionType, + Options: mockOptions, + }, + }, + }, + }, + })) + + expects := []string{ + "root1", + "root2", + "sub1.1", + "sub1.2", + "sub2.1", + "sub2.2", + } + if len(stack) != len(expects) { + t.Fatalf("expected stack to have %d elements, got %d", len(expects), len(stack)) + } + + for i := range expects { + if stack[i] != expects[i] { + t.Fatalf("expected stack[%d] to be %q, got %q", i, expects[i], stack[i]) + } + } + }) + + t.Run("deferred", func(t *testing.T) { + var wg sync.WaitGroup + + client := mockFollowUp(t, []followUpData{ + { + token: "mock token", + appID: 200, + d: api.InteractionResponse{ + Type: api.MessageInteractionWithSource, + Data: &api.InteractionResponseData{ + Content: option.NewNullableString("pong-defer"), + Flags: discord.EphemeralMessage, + }, + }, + }, + }) + + assertDeferred := func(t *testing.T, ctx context.Context, yes bool) { + t.Helper() + ticket := DeferTicketFromContext(ctx) + if ticket.Context() == context.Background() { + t.Error("expected ticket to be non-zero") + } + if ticket.IsDeferred() != yes { + if yes { + t.Error("expected ticket to not be deferred") + } else { + t.Error("expected ticket to be deferred") + } + } + } + + r := NewRouter() + r.Use(Deferrable(client, DeferOpts{ + Timeout: 100 * time.Millisecond, + Flags: discord.EphemeralMessage, + Error: func(err error) { t.Error(err) }, + Done: func(*discord.Message) { wg.Done() }, + })) + r.AddFunc("ping", func(ctx context.Context, data CommandData) *api.InteractionResponseData { + assertDeferred(t, ctx, false) + return &api.InteractionResponseData{ + Content: option.NewNullableString("pong"), + } + }) + r.AddFunc("ping-defer", func(ctx context.Context, data CommandData) *api.InteractionResponseData { + assertDeferred(t, ctx, false) + time.Sleep(200 * time.Millisecond) + assertDeferred(t, ctx, true) + return &api.InteractionResponseData{ + Content: option.NewNullableString("pong-defer"), + } + }) + + assertInteractionResp(t, + r.HandleInteraction(newInteractionEvent(discord.CommandInteraction{ + ID: 4, + Name: "ping", + Options: mockOptions, + })), + &api.InteractionResponse{ + Type: api.MessageInteractionWithSource, + Data: &api.InteractionResponseData{ + Content: option.NewNullableString("pong"), + Flags: discord.EphemeralMessage, + }, + }, + ) + + wg.Add(1) + assertInteractionResp(t, + r.HandleInteraction(newInteractionEvent(discord.CommandInteraction{ + ID: 4, + Name: "ping-defer", + Options: mockOptions, + })), + &api.InteractionResponse{ + Type: api.DeferredMessageInteractionWithSource, + Data: &api.InteractionResponseData{ + Flags: discord.EphemeralMessage, + }, + }, + ) + + wg.Wait() + }) +} + +func newInteractionEvent(data discord.CommandInteraction) *discord.InteractionEvent { + return &discord.InteractionEvent{ + ID: 100, + AppID: 200, + ChannelID: 300, + Token: "mock token", + Data: &data, + } +} + +var mockOptions = []discord.CommandInteractionOption{ + { + Name: "value1", + Type: discord.NumberOptionType, + Value: json.Raw("1"), + }, + { + Name: "value2", + Type: discord.StringOptionType, + Value: json.Raw("\"2\""), + }, +} + +func assertHandler(t *testing.T, opts discord.CommandInteractionOptions) CommandHandler { + return CommandHandlerFunc(func(ctx context.Context, data CommandData) *api.InteractionResponseData { + if len(data.Options) != len(opts) { + t.Fatalf("expected %d options, got %d", len(opts), len(data.Options)) + } + + for i, opt := range opts { + if data.Options[i].Name != opt.Name { + t.Fatalf("expected option %d to be %q, got %q", i, opt.Name, data.Options[i].Name) + } + + if !bytes.Equal(data.Options[i].Value, opt.Value) { + t.Fatalf("expected option %d to be %q, got %q", i, opt.Value, data.Options[i].Value) + } + } + + return nil + }) +} + +type mockedFollowUpSender struct { + t *testing.T + d []followUpData +} + +type followUpData struct { + appID discord.AppID + token string + d api.InteractionResponse +} + +func mockFollowUp(t *testing.T, data []followUpData) *mockedFollowUpSender { + return &mockedFollowUpSender{ + t: t, + d: data, + } +} + +func (m *mockedFollowUpSender) FollowUpInteraction(appID discord.AppID, token string, d api.InteractionResponseData) (*discord.Message, error) { + expect := m.d[0] + m.d = m.d[1:] + + if appID != expect.appID { + m.t.Errorf("expected appID to be %d, got %d", expect.appID, appID) + } + + if token != expect.token { + m.t.Errorf("expected token to be %q, got %q", expect.token, token) + } + + if !reflect.DeepEqual(d, *expect.d.Data) { + m.t.Errorf("unexpected interaction data\n"+ + "expected: %#v\n"+ + "got: %#v", expect.d.Data, d) + } + + return &discord.Message{}, nil +} + +func assertInteractionResp(t *testing.T, got, expect *api.InteractionResponse) { + if !reflect.DeepEqual(got, expect) { + t.Fatalf("unexpected interaction\n"+ + "expected: %s\n"+ + "got: %s", + strInteractionResp(expect), + strInteractionResp(got)) + } +} + +func strInteractionResp(resp *api.InteractionResponse) string { + if resp == nil { + return "(*api.InteractionResponse)(nil)" + } + return fmt.Sprintf("%d:%#v", resp.Type, resp.Data) +}