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
|
@ -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)
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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 New Issue