mirror of
https://github.com/diamondburned/arikawa.git
synced 2025-01-20 19:47:12 +00:00
api: Introduce api/cmdroute
This commit introduces a slash commands and autocompletion router. It abstracts the switch-cases that the user has to do in each InteractionEvent handler away. The router is largely inspired by go-chi's design. Refer to the tests for examples.
This commit is contained in:
parent
329ad0f404
commit
181dcb1bdd
92
api/cmdroute/fntypes.go
Normal file
92
api/cmdroute/fntypes.go
Normal file
|
@ -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)
|
||||
}
|
145
api/cmdroute/middlewares.go
Normal file
145
api/cmdroute/middlewares.go
Normal file
|
@ -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()
|
||||
}
|
295
api/cmdroute/router.go
Normal file
295
api/cmdroute/router.go
Normal file
|
@ -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
|
||||
}
|
388
api/cmdroute/router_test.go
Normal file
388
api/cmdroute/router_test.go
Normal file
|
@ -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)
|
||||
}
|
Loading…
Reference in a new issue