Bot: Added more tests and the Help API

This commit is contained in:
diamondburned 2020-05-13 20:42:31 -07:00 committed by diamondburned (Forefront)
parent 6613aa5b41
commit 729979088c
10 changed files with 418 additions and 245 deletions

View File

@ -30,47 +30,43 @@ type ManualParser interface {
ParseContent([]string) error ParseContent([]string) error
} }
// ArgumentParts implements ManualParseable, in case you want to parse arguments // ArgumentParts implements ManualParser, in case you want to parse arguments
// manually. It borrows the library's argument parser. // manually. It borrows the library's argument parser.
type ArgumentParts struct { type ArgumentParts []string
Command string
Arguments []string
}
var _ ManualParser = (*ArgumentParts)(nil) var _ ManualParser = (*ArgumentParts)(nil)
// ParseContent implements ManualParser.
func (r *ArgumentParts) ParseContent(args []string) error { func (r *ArgumentParts) ParseContent(args []string) error {
r.Command = args[0] *r = args
if len(args) > 1 {
r.Arguments = args[1:]
}
return nil return nil
} }
func (r ArgumentParts) Arg(n int) string { func (r ArgumentParts) Arg(n int) string {
if n < 0 || n >= len(r.Arguments) { if n < 0 || n >= len(r) {
return "" return ""
} }
return r[n]
return r.Arguments[n]
} }
func (r ArgumentParts) After(n int) string { func (r ArgumentParts) After(n int) string {
if n < 0 || n >= len(r.Arguments) { if n < 0 || n > len(r) {
return "" return ""
} }
return strings.Join(r[n:], " ")
return strings.Join(r.Arguments[n:], " ")
} }
func (r ArgumentParts) String() string { func (r ArgumentParts) String() string {
return r.Command + " " + strings.Join(r.Arguments, " ") return strings.Join(r, " ")
} }
func (r ArgumentParts) Length() int { func (r ArgumentParts) Length() int {
return len(r.Arguments) return len(r)
}
// Usage implements Usager.
func (r ArgumentParts) Usage() string {
return "strings"
} }
// CustomParser has a CustomParse method, which would be passed in the full // CustomParser has a CustomParse method, which would be passed in the full
@ -142,7 +138,7 @@ func newArgument(t reflect.Type, variadic bool) (*Argument, error) {
} }
return &Argument{ return &Argument{
String: t.String(), String: fromUsager(t),
rtype: t, rtype: t,
pointer: ptr, pointer: ptr,
custom: &mt, custom: &mt,
@ -158,7 +154,7 @@ func newArgument(t reflect.Type, variadic bool) (*Argument, error) {
} }
return &Argument{ return &Argument{
String: t.String(), String: fromUsager(t),
rtype: t, rtype: t,
pointer: ptr, pointer: ptr,
manual: &mt, manual: &mt,
@ -242,7 +238,7 @@ func newArgument(t reflect.Type, variadic bool) (*Argument, error) {
} }
return &Argument{ return &Argument{
String: t.String(), String: fromUsager(t),
rtype: t, rtype: t,
fn: fn, fn: fn,
}, nil }, nil
@ -264,12 +260,9 @@ func quickRet(v interface{}, err error, t reflect.Type) (reflect.Value, error) {
func fromUsager(typeI reflect.Type) string { func fromUsager(typeI reflect.Type) string {
if typeI.Implements(typeIUsager) { if typeI.Implements(typeIUsager) {
mt, ok := typeI.MethodByName("Usage") mt, _ := typeI.MethodByName("Usage")
if !ok {
panic("BUG: type IUsager does not implement Usage")
}
vs := mt.Func.Call([]reflect.Value{reflect.New(typeI.Elem())}) vs := mt.Func.Call([]reflect.Value{reflect.New(typeI).Elem()})
return vs[0].String() return vs[0].String()
} }

View File

@ -51,15 +51,6 @@ func testArgs(t *testing.T, expect interface{}, input string) {
// used for ctx_test.go // used for ctx_test.go
type customManualParsed struct {
args []string
}
func (c *customManualParsed) ParseContent(args []string) error {
c.args = args
return nil
}
type customParsed struct { type customParsed struct {
parsed bool parsed bool
} }

View File

@ -85,6 +85,9 @@ type MethodContext struct {
// Command is the Discord command used to call the method. // Command is the Discord command used to call the method.
Command string // plumb if empty Command string // plumb if empty
// Hidden if true will not be shown by (*Subcommand).HelpGenerate().
Hidden bool
// Variadic is true if the function is a variadic one or if the last // Variadic is true if the function is a variadic one or if the last
// argument accepts multiple strings. // argument accepts multiple strings.
Variadic bool Variadic bool
@ -163,6 +166,10 @@ func parseMethod(value reflect.Value, method reflect.Method) *MethodContext {
} }
func (cctx *MethodContext) addMiddleware(mw *MiddlewareContext) { func (cctx *MethodContext) addMiddleware(mw *MiddlewareContext) {
// Skip if mismatch type:
if !mw.command.isEvent(cctx.command.event) {
return
}
cctx.middlewares = append(cctx.middlewares, mw) cctx.middlewares = append(cctx.middlewares, mw)
} }

View File

@ -94,6 +94,18 @@ type Context struct {
// This is false by default and only applies to MessageCreate. // This is false by default and only applies to MessageCreate.
AllowBot bool AllowBot bool
// QuietUnknownCommand, if true, will not make the bot reply with an unknown
// command error into the chat. This will apply to all other subcommands.
// SilentUnknown controls whether or not an ErrUnknownCommand should be
// returned (instead of a silent error).
SilentUnknown struct {
// Command when true will silent only unknown commands. Known
// subcommands with unknown commands will still error out.
Command bool
// Subcommand when true will suppress unknown subcommands.
Subcommand bool
}
// FormatError formats any errors returned by anything, including the method // FormatError formats any errors returned by anything, including the method
// commands or the reflect functions. This also includes invalid usage // commands or the reflect functions. This also includes invalid usage
// errors or unknown command errors. Returning an empty string means // errors or unknown command errors. Returning an empty string means
@ -176,7 +188,7 @@ func Wait() {
// } // }
// //
// cmds := &Commands{} // cmds := &Commands{}
// c, err := rfrouter.New(session, cmds) // c, err := bot.New(session, cmds)
// //
// The default prefix is "~", which means commands must start with "~" followed // The default prefix is "~", which means commands must start with "~" followed
// by the command name in the first argument, else it will be ignored. // by the command name in the first argument, else it will be ignored.
@ -241,17 +253,28 @@ func (ctx *Context) FindCommand(structname, methodname string) *MethodContext {
// fails. This is recommended, as subcommands won't change after initializing // fails. This is recommended, as subcommands won't change after initializing
// once in runtime, thus fairly harmless after development. // once in runtime, thus fairly harmless after development.
func (ctx *Context) MustRegisterSubcommand(cmd interface{}) *Subcommand { func (ctx *Context) MustRegisterSubcommand(cmd interface{}) *Subcommand {
s, err := ctx.RegisterSubcommand(cmd) return ctx.MustRegisterSubcommandCustom(cmd, "")
}
// MustReisterSubcommandCustom works similarly to MustRegisterSubcommand, but
// takeks an extra argument for a command name override.
func (ctx *Context) MustRegisterSubcommandCustom(cmd interface{}, name string) *Subcommand {
s, err := ctx.RegisterSubcommandCustom(cmd, name)
if err != nil { if err != nil {
panic(err) panic(err)
} }
return s return s
} }
// RegisterSubcommand registers and adds cmd to the list of subcommands. It will // RegisterSubcommand registers and adds cmd to the list of subcommands. It will
// also return the resulting Subcommand. // also return the resulting Subcommand.
func (ctx *Context) RegisterSubcommand(cmd interface{}) (*Subcommand, error) { func (ctx *Context) RegisterSubcommand(cmd interface{}) (*Subcommand, error) {
return ctx.RegisterSubcommandCustom(cmd, "")
}
// RegisterSubcommand registers and adds cmd to the list of subcommands with a
// custom command name (optional).
func (ctx *Context) RegisterSubcommandCustom(cmd interface{}, name string) (*Subcommand, error) {
s, err := NewSubcommand(cmd) s, err := NewSubcommand(cmd)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "Failed to add subcommand") return nil, errors.Wrap(err, "Failed to add subcommand")
@ -260,6 +283,10 @@ func (ctx *Context) RegisterSubcommand(cmd interface{}) (*Subcommand, error) {
// Register the subcommand's name. // Register the subcommand's name.
s.NeedsName() s.NeedsName()
if name != "" {
s.Command = name
}
if err := s.InitCommands(ctx); err != nil { if err := s.InitCommands(ctx); err != nil {
return nil, errors.Wrap(err, "Failed to initialize subcommand") return nil, errors.Wrap(err, "Failed to initialize subcommand")
} }
@ -267,8 +294,7 @@ func (ctx *Context) RegisterSubcommand(cmd interface{}) (*Subcommand, error) {
// Do a collision check // Do a collision check
for _, sub := range ctx.subcommands { for _, sub := range ctx.subcommands {
if sub.Command == s.Command { if sub.Command == s.Command {
return nil, errors.New( return nil, errors.New("New subcommand has duplicate name: " + s.Command)
"New subcommand has duplicate name: " + s.Command)
} }
} }
@ -333,66 +359,68 @@ func (ctx *Context) Call(event interface{}) error {
return ctx.callCmd(event) return ctx.callCmd(event)
} }
// Help generates one. This function is used more for reference than an actual // Help generates a full Help message. It serves mainly as a reference for
// help message. As such, it only uses exported fields or methods. // people to reimplement and change.
func (ctx *Context) Help() string { func (ctx *Context) Help() string {
return ctx.help(true) // Generate the header.
buf := strings.Builder{}
buf.WriteString("__Help__")
// Name an
if ctx.Name != "" {
buf.WriteString(": " + ctx.Name)
}
if ctx.Description != "" {
buf.WriteString("\n" + IndentLines(ctx.Description))
}
// Separators
buf.WriteString("\n---\n")
// Generate all commands
if help := ctx.Subcommand.Help(); help != "" {
buf.WriteString("__Commands__\n")
buf.WriteString(IndentLines(help))
buf.WriteByte('\n')
}
var subcommands = ctx.Subcommands()
var subhelps = make([]string, 0, len(subcommands))
for _, sub := range subcommands {
if sub.Hidden {
continue
}
help := sub.Help()
if help == "" {
continue
}
help = IndentLines(help)
var header = "**" + sub.Command + "**"
if sub.Description != "" {
header += ": " + sub.Description
}
subhelps = append(subhelps, header+"\n"+help)
}
if len(subhelps) > 0 {
buf.WriteString("---\n")
buf.WriteString("__Subcommands__\n")
buf.WriteString(IndentLines(strings.Join(subhelps, "\n")))
}
return buf.String()
} }
func (ctx *Context) HelpAdmin() string { // IndentLine prefixes every line from input with a single-level indentation.
return ctx.help(false) func IndentLines(input string) string {
} const indent = " "
var lines = strings.Split(input, "\n")
func (ctx *Context) help(hideAdmin bool) string { for i := range lines {
// const indent = " " lines[i] = indent + lines[i]
}
// var help strings.Builder return strings.Join(lines, "\n")
// // Generate the headers and descriptions
// help.WriteString("__Help__")
// if ctx.Name != "" {
// help.WriteString(": " + ctx.Name)
// }
// if ctx.Description != "" {
// help.WriteString("\n" + indent + ctx.Description)
// }
// if ctx.Flag.Is(AdminOnly) {
// // That's it.
// return help.String()
// }
// // Separators
// help.WriteString("\n---\n")
// // Generate all commands
// help.WriteString("__Commands__")
// help.WriteString(ctx.Subcommand.Help(indent, hideAdmin))
// help.WriteByte('\n')
// var subHelp = strings.Builder{}
// var subcommands = ctx.Subcommands()
// for _, sub := range subcommands {
// if help := sub.Help(indent, hideAdmin); help != "" {
// for _, line := range strings.Split(help, "\n") {
// subHelp.WriteString(indent)
// subHelp.WriteString(line)
// subHelp.WriteByte('\n')
// }
// }
// }
// if subHelp.Len() > 0 {
// help.WriteString("---\n")
// help.WriteString("__Subcommands__\n")
// help.WriteString(subHelp.String())
// }
// return help.String()
// TODO
return ""
} }

View File

@ -125,7 +125,7 @@ func (ctx *Context) callMessageCreate(mc *gateway.MessageCreateEvent, value refl
// Here's an edge case: when the handler takes no arguments, we allow that // Here's an edge case: when the handler takes no arguments, we allow that
// anyway, as they might've used the raw content. // anyway, as they might've used the raw content.
if len(cmd.Arguments) < 1 { if len(cmd.Arguments) == 0 {
goto Call goto Call
} }
@ -230,8 +230,8 @@ func (ctx *Context) callMessageCreate(mc *gateway.MessageCreateEvent, value refl
// could contain multiple whitespaces, and the parser would not // could contain multiple whitespaces, and the parser would not
// count them. // count them.
var seekTo = cmd.Command var seekTo = cmd.Command
// Implicit plumbing behavior. // We can't rely on the plumbing behavior.
if seekTo == "" { if sub.plumbed != nil {
seekTo = sub.Command seekTo = sub.Command
} }
@ -314,7 +314,8 @@ func (ctx *Context) findCommand(parts []string) ([]string, *MethodContext, *Subc
} }
} }
if s.QuietUnknownCommand || ctx.QuietUnknownCommand { // If unknown command is disabled or the subcommand is hidden:
if ctx.SilentUnknown.Subcommand || s.Hidden {
return nil, nil, nil, Break return nil, nil, nil, Break
} }
@ -324,7 +325,7 @@ func (ctx *Context) findCommand(parts []string) ([]string, *MethodContext, *Subc
} }
} }
if ctx.QuietUnknownCommand { if ctx.SilentUnknown.Command {
return nil, nil, nil, Break return nil, nil, nil, Break
} }

View File

@ -11,6 +11,7 @@ import (
"github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/discord"
"github.com/diamondburned/arikawa/gateway" "github.com/diamondburned/arikawa/gateway"
"github.com/diamondburned/arikawa/handler"
"github.com/diamondburned/arikawa/state" "github.com/diamondburned/arikawa/state"
) )
@ -18,7 +19,7 @@ type testc struct {
Ctx *Context Ctx *Context
Return chan interface{} Return chan interface{}
Counter uint64 Counter uint64
Typed bool Typed int8
} }
func (t *testc) Setup(sub *Subcommand) { func (t *testc) Setup(sub *Subcommand) {
@ -28,8 +29,14 @@ func (t *testc) Setup(sub *Subcommand) {
sub.AddMiddleware("*", func(*gateway.MessageCreateEvent) { sub.AddMiddleware("*", func(*gateway.MessageCreateEvent) {
t.Counter++ t.Counter++
}) })
// stub middleware for testing
sub.AddMiddleware("OnTyping", func(*gateway.TypingStartEvent) {
t.Typed = 2
})
sub.Hide("Hidden")
} }
func (t *testc) Noop(*gateway.MessageCreateEvent) {} func (t *testc) Hidden(*gateway.MessageCreateEvent) {}
func (t *testc) Noop(*gateway.MessageCreateEvent) {}
func (t *testc) GetCounter(*gateway.MessageCreateEvent) { func (t *testc) GetCounter(*gateway.MessageCreateEvent) {
t.Return <- strconv.FormatUint(t.Counter, 10) t.Return <- strconv.FormatUint(t.Counter, 10)
} }
@ -37,14 +44,14 @@ func (t *testc) Send(_ *gateway.MessageCreateEvent, args ...string) error {
t.Return <- args t.Return <- args
return errors.New("oh no") return errors.New("oh no")
} }
func (t *testc) Custom(_ *gateway.MessageCreateEvent, c *customManualParsed) { func (t *testc) Custom(_ *gateway.MessageCreateEvent, c *ArgumentParts) {
t.Return <- c.args t.Return <- []string(*c)
} }
func (t *testc) Variadic(_ *gateway.MessageCreateEvent, c ...*customParsed) { func (t *testc) Variadic(_ *gateway.MessageCreateEvent, c ...*customParsed) {
t.Return <- c[len(c)-1] t.Return <- c[len(c)-1]
} }
func (t *testc) TrailCustom(_ *gateway.MessageCreateEvent, s string, c *customManualParsed) { func (t *testc) TrailCustom(_ *gateway.MessageCreateEvent, s string, c ArgumentParts) {
t.Return <- c.args t.Return <- c
} }
func (t *testc) Content(_ *gateway.MessageCreateEvent, c RawArguments) { func (t *testc) Content(_ *gateway.MessageCreateEvent, c RawArguments) {
t.Return <- c t.Return <- c
@ -53,7 +60,7 @@ func (t *testc) NoArgs(*gateway.MessageCreateEvent) error {
return errors.New("passed") return errors.New("passed")
} }
func (t *testc) OnTyping(*gateway.TypingStartEvent) { func (t *testc) OnTyping(*gateway.TypingStartEvent) {
t.Typed = true t.Typed--
} }
func TestNewContext(t *testing.T) { func TestNewContext(t *testing.T) {
@ -74,7 +81,8 @@ func TestNewContext(t *testing.T) {
func TestContext(t *testing.T) { func TestContext(t *testing.T) {
var given = &testc{} var given = &testc{}
var state = &state.State{ var state = &state.State{
Store: state.NewDefaultStore(nil), Store: state.NewDefaultStore(nil),
Handler: handler.New(),
} }
s, err := NewSubcommand(given) s, err := NewSubcommand(given)
@ -83,6 +91,9 @@ func TestContext(t *testing.T) {
} }
var ctx = &Context{ var ctx = &Context{
Name: "arikawa/bot test",
Description: "Just a test.",
Subcommand: s, Subcommand: s,
State: state, State: state,
ParseArgs: DefaultArgsParser(), ParseArgs: DefaultArgsParser(),
@ -103,20 +114,31 @@ func TestContext(t *testing.T) {
}) })
t.Run("find commands", func(t *testing.T) { t.Run("find commands", func(t *testing.T) {
cmd := ctx.FindMethod("", "NoArgs") cmd := ctx.FindCommand("", "NoArgs")
if cmd == nil { if cmd == nil {
t.Fatal("Failed to find NoArgs") t.Fatal("Failed to find NoArgs")
} }
}) })
// t.Run("help", func(t *testing.T) { t.Run("help", func(t *testing.T) {
// if h := ctx.Help(); h == "" { ctx.MustRegisterSubcommandCustom(&testc{}, "helper")
// t.Fatal("Empty help?")
// } h := ctx.Help()
// if h := ctx.HelpAdmin(); h == "" { if h == "" {
// t.Fatal("Empty admin help?") t.Fatal("Empty help?")
// } }
// })
if strings.Contains(h, "hidden") {
t.Fatal("Hidden command shown in help.")
}
if !strings.Contains(h, "arikawa/bot test") {
t.Fatal("Name not found.")
}
if !strings.Contains(h, "Just a test.") {
t.Fatal("Description not found.")
}
})
t.Run("middleware", func(t *testing.T) { t.Run("middleware", func(t *testing.T) {
ctx.HasPrefix = NewPrefix("pls do ") ctx.HasPrefix = NewPrefix("pls do ")
@ -134,7 +156,8 @@ func TestContext(t *testing.T) {
t.Fatal("Failed to call with TypingStart:", err) t.Fatal("Failed to call with TypingStart:", err)
} }
if !given.Typed { // -1 none ran
if given.Typed != 1 {
t.Fatal("Typed bool is false") t.Fatal("Typed bool is false")
} }
}) })
@ -182,11 +205,27 @@ func TestContext(t *testing.T) {
t.Run("call command custom trailing manual parser", func(t *testing.T) { t.Run("call command custom trailing manual parser", func(t *testing.T) {
ctx.HasPrefix = NewPrefix("!") ctx.HasPrefix = NewPrefix("!")
expects := []string{} expects := ArgumentParts{"arikawa"}
if err := expect(ctx, given, expects, "!trailCustom hime_arikawa"); err != nil { if err := sendMsg(ctx, given, &expects, "!trailCustom hime arikawa"); err != nil {
t.Fatal("Unexpected call error:", err) t.Fatal("Unexpected call error:", err)
} }
if expects.Length() != 1 {
t.Fatal("Unexpected ArgumentParts length.")
}
if expects.After(1)+expects.After(2)+expects.After(-1) != "" {
t.Fatal("Unexpected ArgumentsParts after.")
}
if expects.String() != "arikawa" {
t.Fatal("Unexpected ArgumentsParts string.")
}
if expects.Arg(0) != "arikawa" {
t.Fatal("Unexpected ArgumentParts arg 0")
}
if expects.Arg(1) != "" {
t.Fatal("Unexpected ArgumentParts arg 1")
}
}) })
testMessage := func(content string) error { testMessage := func(content string) error {
@ -226,11 +265,7 @@ func TestContext(t *testing.T) {
ctx.HasPrefix = NewPrefix("run ") ctx.HasPrefix = NewPrefix("run ")
sub := &testc{} sub := &testc{}
ctx.MustRegisterSubcommand(sub)
_, err := ctx.RegisterSubcommand(sub)
if err != nil {
t.Fatal("Failed to register subcommand:", err)
}
if err := testMessage("run testc noop"); err != nil { if err := testMessage("run testc noop"); err != nil {
t.Fatal("Unexpected error:", err) t.Fatal("Unexpected error:", err)
@ -242,13 +277,53 @@ func TestContext(t *testing.T) {
t.Fatal("Unexpected call error:", err) t.Fatal("Unexpected call error:", err)
} }
if cmd := ctx.FindMethod("testc", "Noop"); cmd == nil { if cmd := ctx.FindCommand("testc", "Noop"); cmd == nil {
t.Fatal("Failed to find subcommand Noop") t.Fatal("Failed to find subcommand Noop")
} }
}) })
t.Run("register subcommand custom", func(t *testing.T) {
ctx.MustRegisterSubcommandCustom(&testc{}, "arikawa")
})
t.Run("duplicate subcommand", func(t *testing.T) {
_, err := ctx.RegisterSubcommandCustom(&testc{}, "arikawa")
if err := err.Error(); !strings.Contains(err, "duplicate") {
t.Fatal("Unexpected error:", err)
}
})
t.Run("start", func(t *testing.T) {
cancel := ctx.Start()
defer cancel()
ctx.HasPrefix = NewPrefix("!")
given.Return = make(chan interface{})
ctx.Handler.Call(&gateway.MessageCreateEvent{
Message: discord.Message{
Content: "!content hime arikawa best trap",
},
})
if c := (<-given.Return).(RawArguments); c != "hime arikawa best trap" {
t.Fatal("Unexpected content:", c)
}
})
} }
func expect(ctx *Context, given *testc, expects interface{}, content string) (call error) { func expect(ctx *Context, given *testc, expects interface{}, content string) (call error) {
var v interface{}
if call = sendMsg(ctx, given, &v, content); call != nil {
return
}
if !reflect.DeepEqual(v, expects) {
return fmt.Errorf("returned argument is invalid: %v", v)
}
return nil
}
func sendMsg(ctx *Context, given *testc, into interface{}, content string) (call error) {
// Return channel for testing // Return channel for testing
ret := make(chan interface{}) ret := make(chan interface{})
given.Return = ret given.Return = ret
@ -262,15 +337,13 @@ func expect(ctx *Context, given *testc, expects interface{}, content string) (ca
var callCh = make(chan error) var callCh = make(chan error)
go func() { go func() {
callCh <- ctx.callCmd(m) callCh <- ctx.Call(m)
}() }()
select { select {
case arg := <-ret: case arg := <-ret:
if !reflect.DeepEqual(arg, expects) {
return fmt.Errorf("returned argument is invalid: %v", arg)
}
call = <-callCh call = <-callCh
reflect.ValueOf(into).Elem().Set(reflect.ValueOf(arg))
return return
case call = <-callCh: case call = <-callCh:

View File

@ -46,11 +46,11 @@ func (err *ErrInvalidUsage) Unwrap() error {
} }
var InvalidUsageString = func(err *ErrInvalidUsage) string { var InvalidUsageString = func(err *ErrInvalidUsage) string {
if err.Index == 0 { if err.Index == 0 && err.Wrap != nil {
return "Invalid usage, error: " + err.Wrap.Error() + "." return "Invalid usage, error: " + err.Wrap.Error() + "."
} }
if len(err.Args) == 0 { if err.Index == 0 || len(err.Args) == 0 {
return "Missing arguments. Refer to help." return "Missing arguments. Refer to help."
} }

56
bot/error_test.go Normal file
View File

@ -0,0 +1,56 @@
package bot
import (
"errors"
"strings"
"testing"
)
func TestInvalidUsage(t *testing.T) {
t.Run("fmt", func(t *testing.T) {
err := ErrInvalidUsage{
Prefix: "!",
Args: []string{"hime", "arikawa"},
Index: 1,
Wrap: errors.New("test error"),
}
str := err.Error()
if !strings.Contains(str, "test error") {
t.Fatal("does not contain 'test error':", str)
}
if !strings.Contains(str, "__arikawa__") {
t.Fatal("Unexpected highlight index:", str)
}
})
t.Run("missing arguments", func(t *testing.T) {
err := ErrInvalidUsage{}
str := err.Error()
if str != "Missing arguments. Refer to help." {
t.Fatal("Unexpected error:", str)
}
})
t.Run("no index", func(t *testing.T) {
err := ErrInvalidUsage{Wrap: errors.New("astolfo")}
str := err.Error()
if str != "Invalid usage, error: astolfo." {
t.Fatal("Unexpected error:", str)
}
})
t.Run("unwrap", func(t *testing.T) {
var err = errors.New("hackadoll no. 3")
var wrap = &ErrInvalidUsage{
Wrap: err,
}
if !errors.Is(wrap, err) {
t.Fatal("Failed to unwrap, errors mismatch.")
}
})
}

View File

@ -24,14 +24,17 @@ var (
typeICusP = reflect.TypeOf((*CustomParser)(nil)).Elem() typeICusP = reflect.TypeOf((*CustomParser)(nil)).Elem()
typeIParser = reflect.TypeOf((*Parser)(nil)).Elem() typeIParser = reflect.TypeOf((*Parser)(nil)).Elem()
typeIUsager = reflect.TypeOf((*Usager)(nil)).Elem() typeIUsager = reflect.TypeOf((*Usager)(nil)).Elem()
typeSetupFn = func() reflect.Type { typeSetupFn = methodType((*CanSetup)(nil), "Setup")
method, _ := reflect.TypeOf((*CanSetup)(nil)). typeHelpFn = methodType((*CanHelp)(nil), "Help")
Elem().
MethodByName("Setup")
return method.Type
}()
) )
func methodType(iface interface{}, name string) reflect.Type {
method, _ := reflect.TypeOf(iface).
Elem().
MethodByName(name)
return method.Type
}
// HelpUnderline formats command arguments with an underline, similar to // HelpUnderline formats command arguments with an underline, similar to
// manpages. // manpages.
var HelpUnderline = true var HelpUnderline = true
@ -62,8 +65,14 @@ func underline(word string) string {
// func(<AnyEvent>) // func(<AnyEvent>)
// //
type Subcommand struct { type Subcommand struct {
// Description is a string that's appended after the subcommand name in
// (*Context).Help().
Description string Description string
// Hidden if true will not be shown by (*Context).Help(). It will
// also cause unknown command errors to be suppressed.
Hidden bool
// Raw struct name, including the flag (only filled for actual subcommands, // Raw struct name, including the flag (only filled for actual subcommands,
// will be empty for Context): // will be empty for Context):
StructName string StructName string
@ -74,11 +83,6 @@ type Subcommand struct {
// a string content or a SendMessageData. // a string content or a SendMessageData.
SanitizeMessage func(content string) string SanitizeMessage func(content string) string
// QuietUnknownCommand, if true, will not make the bot reply with an unknown
// command error into the chat. If this is set in Context, it will apply to
// all other subcommands.
QuietUnknownCommand bool
// Commands can actually return either a string, an embed, or a // Commands can actually return either a string, an embed, or a
// SendMessageData, with error as the second argument. // SendMessageData, with error as the second argument.
@ -98,6 +102,7 @@ type Subcommand struct {
ptrValue reflect.Value ptrValue reflect.Value
ptrType reflect.Type ptrType reflect.Type
helper func() string
command interface{} command interface{}
} }
@ -109,6 +114,14 @@ type CanSetup interface {
Setup(*Subcommand) Setup(*Subcommand)
} }
// CanHelp is an interface that subcommands can implement to return its own help
// message. Those messages will automatically be indented into suitable sections
// by the default Help() implementation. Unlike Usager or CanSetup, the Help()
// method will be called every time it's needed.
type CanHelp interface {
Help() string
}
// NewSubcommand is used to make a new subcommand. You usually wouldn't call // NewSubcommand is used to make a new subcommand. You usually wouldn't call
// this function, but instead use (*Context).RegisterSubcommand(). // this function, but instead use (*Context).RegisterSubcommand().
func NewSubcommand(cmd interface{}) (*Subcommand, error) { func NewSubcommand(cmd interface{}) (*Subcommand, error) {
@ -148,91 +161,71 @@ func (sub *Subcommand) FindCommand(methodName string) *MethodContext {
} }
// ChangeCommandInfo changes the matched methodName's Command and Description. // ChangeCommandInfo changes the matched methodName's Command and Description.
// Empty means unchanged. The returned bool is true when the command is found. // Empty means unchanged. This function panics if methodName is not found.
func (sub *Subcommand) ChangeCommandInfo(methodName, cmd, desc string) bool { func (sub *Subcommand) ChangeCommandInfo(methodName, cmd, desc string) {
for _, c := range sub.Commands { var command = sub.FindCommand(methodName)
if c.MethodName != methodName || !c.isEvent(typeMessageCreate) { if cmd != "" {
command.Command = cmd
}
if desc != "" {
command.Description = desc
}
}
// Help calls the subcommand's Help() or auto-generates one with HelpGenerate()
// if the subcommand doesn't implement CanHelp.
func (sub *Subcommand) Help() string {
// Check if the subcommand implements CanHelp.
if sub.helper != nil {
return sub.helper()
}
return sub.HelpGenerate()
}
// HelpGenerate auto-generates a help message. Use this only if you want to
// override the Subcommand's help, else use Help().
func (sub *Subcommand) HelpGenerate() string {
// A wider space character.
const s = "\u2000"
var buf strings.Builder
for i, cmd := range sub.Commands {
if cmd.Hidden {
continue continue
} }
if cmd != "" { buf.WriteString(sub.Command + " " + cmd.Command)
c.Command = cmd
} // Write the usages first.
if desc != "" { for _, usage := range cmd.Usage() {
c.Description = desc // Is the last argument trailing? If so, append ellipsis.
if cmd.Variadic {
usage += "..."
}
// Uses \u2000, which is wider than a space.
buf.WriteString(s + "__" + usage + "__")
} }
return true // Write the description if there's any.
if cmd.Description != "" {
buf.WriteString(": " + cmd.Description)
}
// Add a new line if this isn't the last command.
if i != len(sub.Commands)-1 {
buf.WriteByte('\n')
}
} }
return false return buf.String()
} }
func (sub *Subcommand) Help(indent string, hideAdmin bool) string { // Hide marks a command as hidden, meaning it won't be shown in help and its
// // The header part: // UnknownCommand errors will be suppressed.
// var header string func (sub *Subcommand) Hide(methodName string) {
sub.FindCommand(methodName).Hidden = true
// if sub.Command != "" {
// header += "**" + sub.Command + "**"
// }
// if sub.Description != "" {
// if header != "" {
// header += ": "
// }
// header += sub.Description
// }
// header += "\n"
// // The commands part:
// var commands = ""
// for i, cmd := range sub.Commands {
// if cmd.Flag.Is(AdminOnly) && hideAdmin {
// continue
// }
// switch {
// case sub.Command != "" && cmd.Command != "":
// commands += indent + sub.Command + " " + cmd.Command
// case sub.Command != "":
// commands += indent + sub.Command
// default:
// commands += indent + cmd.Command
// }
// // Write the usages first.
// for _, usage := range cmd.Usage() {
// commands += " " + underline(usage)
// }
// // Is the last argument trailing? If so, append ellipsis.
// if cmd.Variadic {
// commands += "..."
// }
// // Write the description if there's any.
// if cmd.Description != "" {
// commands += ": " + cmd.Description
// }
// // Add a new line if this isn't the last command.
// if i != len(sub.Commands)-1 {
// commands += "\n"
// }
// }
// if commands == "" {
// return ""
// }
// return header + commands
// TODO
// TODO: Interface Helper implements Help() string
return "TODO"
} }
func (sub *Subcommand) reflectCommands() error { func (sub *Subcommand) reflectCommands() error {
@ -274,6 +267,11 @@ func (sub *Subcommand) InitCommands(ctx *Context) error {
v.Setup(sub) v.Setup(sub)
} }
// See if struct implements CanHelper:
if v, ok := sub.command.(CanHelp); ok {
sub.helper = v.Help
}
return nil return nil
} }
@ -327,6 +325,9 @@ func (sub *Subcommand) parseCommands() error {
return nil return nil
} }
// AddMiddleware adds a middleware into multiple or all methods, including
// commands and events. Multiple method names can be comma-delimited. For all
// methods, use a star (*).
func (sub *Subcommand) AddMiddleware(methodName string, middleware interface{}) { func (sub *Subcommand) AddMiddleware(methodName string, middleware interface{}) {
var mw *MiddlewareContext var mw *MiddlewareContext
// Allow *MiddlewareContext to be passed into. // Allow *MiddlewareContext to be passed into.
@ -342,21 +343,20 @@ func (sub *Subcommand) AddMiddleware(methodName string, middleware interface{})
if method = strings.TrimSpace(method); method == "*" { if method = strings.TrimSpace(method); method == "*" {
// Append middleware to global middleware slice. // Append middleware to global middleware slice.
sub.globalmws = append(sub.globalmws, mw) sub.globalmws = append(sub.globalmws, mw)
} else { continue
// Append middleware to that individual function.
sub.FindCommand(method).addMiddleware(mw)
} }
// Append middleware to that individual function.
sub.findMethod(method).addMiddleware(mw)
} }
} }
func (sub *Subcommand) walkMiddlewares(ev reflect.Value) error { func (sub *Subcommand) findMethod(name string) *MethodContext {
for _, mw := range sub.globalmws { for _, ev := range sub.Events {
_, err := mw.call(ev) if ev.MethodName == name {
if err != nil { return ev
return err
} }
} }
return nil return sub.FindCommand(name)
} }
func (sub *Subcommand) eventCallers(evT reflect.Type) (callers []caller) { func (sub *Subcommand) eventCallers(evT reflect.Type) (callers []caller) {
@ -384,17 +384,9 @@ func (sub *Subcommand) eventCallers(evT reflect.Type) (callers []caller) {
return return
} }
// SetPlumb sets the method as the plumbed command. This means that all calls // SetPlumb sets the method as the plumbed command.
// without the second command argument will call this method in a subcommand. It
// panics if sub.Command is empty.
func (sub *Subcommand) SetPlumb(methodName string) { func (sub *Subcommand) SetPlumb(methodName string) {
if sub.Command == "" { sub.plumbed = sub.FindCommand(methodName)
panic("SetPlumb called on a main command with sub.Command empty.")
}
method := sub.FindCommand(methodName)
method.Command = ""
sub.plumbed = method
} }
func lowerFirstLetter(name string) string { func lowerFirstLetter(name string) string {

View File

@ -1,9 +1,22 @@
package bot package bot
import ( import (
"strings"
"testing" "testing"
) )
func TestUnderline(t *testing.T) {
HelpUnderline = false
if underline("astolfo") != "astolfo" {
t.Fatal("Unexpected underlining with HelpUnderline = false")
}
HelpUnderline = true
if underline("arikawa hime") != "__arikawa hime__" {
t.Fatal("Unexpected normal style with HelpUnderline = true")
}
}
func TestNewSubcommand(t *testing.T) { func TestNewSubcommand(t *testing.T) {
_, err := NewSubcommand(&testc{}) _, err := NewSubcommand(&testc{})
if err != nil { if err != nil {
@ -42,7 +55,7 @@ func TestSubcommand(t *testing.T) {
foundNoArgs bool foundNoArgs bool
) )
for _, this := range sub.Methods { for _, this := range sub.Commands {
switch this.Command { switch this.Command {
case "send": case "send":
foundSend = true foundSend = true
@ -77,10 +90,29 @@ func TestSubcommand(t *testing.T) {
} }
}) })
t.Run("init commands", func(t *testing.T) {
ctx := &Context{}
if err := sub.InitCommands(ctx); err != nil {
t.Fatal("Failed to init commands:", err)
}
})
t.Run("help commands", func(t *testing.T) { t.Run("help commands", func(t *testing.T) {
if h := sub.Help("", false); h == "" { h := sub.Help()
if h == "" {
t.Fatal("Empty subcommand help?") t.Fatal("Empty subcommand help?")
} }
if strings.Contains(h, "hidden") {
t.Fatal("Hidden command shown in help:\n", h)
}
})
t.Run("change command", func(t *testing.T) {
sub.ChangeCommandInfo("Noop", "crossdressing", "best")
if h := sub.Help(); !strings.Contains(h, "crossdressing: best") {
t.Fatal("Changed command is not in help.")
}
}) })
} }