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:
diamondburned 2022-10-13 23:00:25 -07:00
parent 329ad0f404
commit 181dcb1bdd
No known key found for this signature in database
GPG Key ID: D78C4471CE776659
4 changed files with 920 additions and 0 deletions

92
api/cmdroute/fntypes.go Normal file
View 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
View 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
View 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
View 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)
}