diff --git a/bot/arguments.go b/bot/arguments.go index a12baee..7a374aa 100644 --- a/bot/arguments.go +++ b/bot/arguments.go @@ -30,47 +30,43 @@ type ManualParser interface { 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. -type ArgumentParts struct { - Command string - Arguments []string -} +type ArgumentParts []string var _ ManualParser = (*ArgumentParts)(nil) +// ParseContent implements ManualParser. func (r *ArgumentParts) ParseContent(args []string) error { - r.Command = args[0] - - if len(args) > 1 { - r.Arguments = args[1:] - } - + *r = args return nil } func (r ArgumentParts) Arg(n int) string { - if n < 0 || n >= len(r.Arguments) { + if n < 0 || n >= len(r) { return "" } - - return r.Arguments[n] + return r[n] } func (r ArgumentParts) After(n int) string { - if n < 0 || n >= len(r.Arguments) { + if n < 0 || n > len(r) { return "" } - - return strings.Join(r.Arguments[n:], " ") + return strings.Join(r[n:], " ") } func (r ArgumentParts) String() string { - return r.Command + " " + strings.Join(r.Arguments, " ") + return strings.Join(r, " ") } 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 @@ -142,7 +138,7 @@ func newArgument(t reflect.Type, variadic bool) (*Argument, error) { } return &Argument{ - String: t.String(), + String: fromUsager(t), rtype: t, pointer: ptr, custom: &mt, @@ -158,7 +154,7 @@ func newArgument(t reflect.Type, variadic bool) (*Argument, error) { } return &Argument{ - String: t.String(), + String: fromUsager(t), rtype: t, pointer: ptr, manual: &mt, @@ -242,7 +238,7 @@ func newArgument(t reflect.Type, variadic bool) (*Argument, error) { } return &Argument{ - String: t.String(), + String: fromUsager(t), rtype: t, fn: fn, }, nil @@ -264,12 +260,9 @@ func quickRet(v interface{}, err error, t reflect.Type) (reflect.Value, error) { func fromUsager(typeI reflect.Type) string { if typeI.Implements(typeIUsager) { - mt, ok := typeI.MethodByName("Usage") - if !ok { - panic("BUG: type IUsager does not implement Usage") - } + mt, _ := typeI.MethodByName("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() } diff --git a/bot/arguments_test.go b/bot/arguments_test.go index de43ba2..eb19646 100644 --- a/bot/arguments_test.go +++ b/bot/arguments_test.go @@ -51,15 +51,6 @@ func testArgs(t *testing.T, expect interface{}, input string) { // 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 { parsed bool } diff --git a/bot/command.go b/bot/command.go index df87037..3077769 100644 --- a/bot/command.go +++ b/bot/command.go @@ -85,6 +85,9 @@ type MethodContext struct { // Command is the Discord command used to call the method. 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 // argument accepts multiple strings. Variadic bool @@ -163,6 +166,10 @@ func parseMethod(value reflect.Value, method reflect.Method) *MethodContext { } func (cctx *MethodContext) addMiddleware(mw *MiddlewareContext) { + // Skip if mismatch type: + if !mw.command.isEvent(cctx.command.event) { + return + } cctx.middlewares = append(cctx.middlewares, mw) } diff --git a/bot/ctx.go b/bot/ctx.go index 24362e1..cf4fdad 100644 --- a/bot/ctx.go +++ b/bot/ctx.go @@ -94,6 +94,18 @@ type Context struct { // This is false by default and only applies to MessageCreate. 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 // commands or the reflect functions. This also includes invalid usage // errors or unknown command errors. Returning an empty string means @@ -176,7 +188,7 @@ func Wait() { // } // // 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 // 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 // once in runtime, thus fairly harmless after development. 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 { panic(err) } - return s } // RegisterSubcommand registers and adds cmd to the list of subcommands. It will // also return the resulting Subcommand. 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) if err != nil { 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. s.NeedsName() + if name != "" { + s.Command = name + } + if err := s.InitCommands(ctx); err != nil { 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 for _, sub := range ctx.subcommands { if sub.Command == s.Command { - return nil, errors.New( - "New subcommand has duplicate name: " + s.Command) + return nil, errors.New("New subcommand has duplicate name: " + s.Command) } } @@ -333,66 +359,68 @@ func (ctx *Context) Call(event interface{}) error { return ctx.callCmd(event) } -// Help generates one. This function is used more for reference than an actual -// help message. As such, it only uses exported fields or methods. +// Help generates a full Help message. It serves mainly as a reference for +// people to reimplement and change. 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 { - return ctx.help(false) -} - -func (ctx *Context) help(hideAdmin bool) string { - // const indent = " " - - // var help strings.Builder - - // // 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 "" +// IndentLine prefixes every line from input with a single-level indentation. +func IndentLines(input string) string { + const indent = " " + var lines = strings.Split(input, "\n") + for i := range lines { + lines[i] = indent + lines[i] + } + return strings.Join(lines, "\n") } diff --git a/bot/ctx_call.go b/bot/ctx_call.go index 0e5eed4..2f58786 100644 --- a/bot/ctx_call.go +++ b/bot/ctx_call.go @@ -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 // anyway, as they might've used the raw content. - if len(cmd.Arguments) < 1 { + if len(cmd.Arguments) == 0 { goto Call } @@ -230,8 +230,8 @@ func (ctx *Context) callMessageCreate(mc *gateway.MessageCreateEvent, value refl // could contain multiple whitespaces, and the parser would not // count them. var seekTo = cmd.Command - // Implicit plumbing behavior. - if seekTo == "" { + // We can't rely on the plumbing behavior. + if sub.plumbed != nil { 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 } @@ -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 } diff --git a/bot/ctx_test.go b/bot/ctx_test.go index 45e7d92..3aba102 100644 --- a/bot/ctx_test.go +++ b/bot/ctx_test.go @@ -11,6 +11,7 @@ import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/gateway" + "github.com/diamondburned/arikawa/handler" "github.com/diamondburned/arikawa/state" ) @@ -18,7 +19,7 @@ type testc struct { Ctx *Context Return chan interface{} Counter uint64 - Typed bool + Typed int8 } func (t *testc) Setup(sub *Subcommand) { @@ -28,8 +29,14 @@ func (t *testc) Setup(sub *Subcommand) { sub.AddMiddleware("*", func(*gateway.MessageCreateEvent) { 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) { t.Return <- strconv.FormatUint(t.Counter, 10) } @@ -37,14 +44,14 @@ func (t *testc) Send(_ *gateway.MessageCreateEvent, args ...string) error { t.Return <- args return errors.New("oh no") } -func (t *testc) Custom(_ *gateway.MessageCreateEvent, c *customManualParsed) { - t.Return <- c.args +func (t *testc) Custom(_ *gateway.MessageCreateEvent, c *ArgumentParts) { + t.Return <- []string(*c) } func (t *testc) Variadic(_ *gateway.MessageCreateEvent, c ...*customParsed) { t.Return <- c[len(c)-1] } -func (t *testc) TrailCustom(_ *gateway.MessageCreateEvent, s string, c *customManualParsed) { - t.Return <- c.args +func (t *testc) TrailCustom(_ *gateway.MessageCreateEvent, s string, c ArgumentParts) { + t.Return <- c } func (t *testc) Content(_ *gateway.MessageCreateEvent, c RawArguments) { t.Return <- c @@ -53,7 +60,7 @@ func (t *testc) NoArgs(*gateway.MessageCreateEvent) error { return errors.New("passed") } func (t *testc) OnTyping(*gateway.TypingStartEvent) { - t.Typed = true + t.Typed-- } func TestNewContext(t *testing.T) { @@ -74,7 +81,8 @@ func TestNewContext(t *testing.T) { func TestContext(t *testing.T) { var given = &testc{} var state = &state.State{ - Store: state.NewDefaultStore(nil), + Store: state.NewDefaultStore(nil), + Handler: handler.New(), } s, err := NewSubcommand(given) @@ -83,6 +91,9 @@ func TestContext(t *testing.T) { } var ctx = &Context{ + Name: "arikawa/bot test", + Description: "Just a test.", + Subcommand: s, State: state, ParseArgs: DefaultArgsParser(), @@ -103,20 +114,31 @@ func TestContext(t *testing.T) { }) t.Run("find commands", func(t *testing.T) { - cmd := ctx.FindMethod("", "NoArgs") + cmd := ctx.FindCommand("", "NoArgs") if cmd == nil { t.Fatal("Failed to find NoArgs") } }) - // t.Run("help", func(t *testing.T) { - // if h := ctx.Help(); h == "" { - // t.Fatal("Empty help?") - // } - // if h := ctx.HelpAdmin(); h == "" { - // t.Fatal("Empty admin help?") - // } - // }) + t.Run("help", func(t *testing.T) { + ctx.MustRegisterSubcommandCustom(&testc{}, "helper") + + h := ctx.Help() + if h == "" { + 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) { ctx.HasPrefix = NewPrefix("pls do ") @@ -134,7 +156,8 @@ func TestContext(t *testing.T) { t.Fatal("Failed to call with TypingStart:", err) } - if !given.Typed { + // -1 none ran + if given.Typed != 1 { 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) { 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) } + + 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 { @@ -226,11 +265,7 @@ func TestContext(t *testing.T) { ctx.HasPrefix = NewPrefix("run ") sub := &testc{} - - _, err := ctx.RegisterSubcommand(sub) - if err != nil { - t.Fatal("Failed to register subcommand:", err) - } + ctx.MustRegisterSubcommand(sub) if err := testMessage("run testc noop"); err != nil { t.Fatal("Unexpected error:", err) @@ -242,13 +277,53 @@ func TestContext(t *testing.T) { 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.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) { + 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 ret := make(chan interface{}) given.Return = ret @@ -262,15 +337,13 @@ func expect(ctx *Context, given *testc, expects interface{}, content string) (ca var callCh = make(chan error) go func() { - callCh <- ctx.callCmd(m) + callCh <- ctx.Call(m) }() select { case arg := <-ret: - if !reflect.DeepEqual(arg, expects) { - return fmt.Errorf("returned argument is invalid: %v", arg) - } call = <-callCh + reflect.ValueOf(into).Elem().Set(reflect.ValueOf(arg)) return case call = <-callCh: diff --git a/bot/error.go b/bot/error.go index 9249697..75ee5b5 100644 --- a/bot/error.go +++ b/bot/error.go @@ -46,11 +46,11 @@ func (err *ErrInvalidUsage) Unwrap() error { } var InvalidUsageString = func(err *ErrInvalidUsage) string { - if err.Index == 0 { + if err.Index == 0 && err.Wrap != nil { 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." } diff --git a/bot/error_test.go b/bot/error_test.go new file mode 100644 index 0000000..c86cebb --- /dev/null +++ b/bot/error_test.go @@ -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.") + } + }) +} diff --git a/bot/subcommand.go b/bot/subcommand.go index 0b86439..e82d141 100644 --- a/bot/subcommand.go +++ b/bot/subcommand.go @@ -24,14 +24,17 @@ var ( typeICusP = reflect.TypeOf((*CustomParser)(nil)).Elem() typeIParser = reflect.TypeOf((*Parser)(nil)).Elem() typeIUsager = reflect.TypeOf((*Usager)(nil)).Elem() - typeSetupFn = func() reflect.Type { - method, _ := reflect.TypeOf((*CanSetup)(nil)). - Elem(). - MethodByName("Setup") - return method.Type - }() + typeSetupFn = methodType((*CanSetup)(nil), "Setup") + typeHelpFn = methodType((*CanHelp)(nil), "Help") ) +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 // manpages. var HelpUnderline = true @@ -62,8 +65,14 @@ func underline(word string) string { // func() // type Subcommand struct { + // Description is a string that's appended after the subcommand name in + // (*Context).Help(). 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, // will be empty for Context): StructName string @@ -74,11 +83,6 @@ type Subcommand struct { // a string content or a SendMessageData. 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 // SendMessageData, with error as the second argument. @@ -98,6 +102,7 @@ type Subcommand struct { ptrValue reflect.Value ptrType reflect.Type + helper func() string command interface{} } @@ -109,6 +114,14 @@ type CanSetup interface { 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 // this function, but instead use (*Context).RegisterSubcommand(). 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. -// Empty means unchanged. The returned bool is true when the command is found. -func (sub *Subcommand) ChangeCommandInfo(methodName, cmd, desc string) bool { - for _, c := range sub.Commands { - if c.MethodName != methodName || !c.isEvent(typeMessageCreate) { +// Empty means unchanged. This function panics if methodName is not found. +func (sub *Subcommand) ChangeCommandInfo(methodName, cmd, desc string) { + var command = sub.FindCommand(methodName) + 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 } - if cmd != "" { - c.Command = cmd - } - if desc != "" { - c.Description = desc + buf.WriteString(sub.Command + " " + cmd.Command) + + // Write the usages first. + for _, usage := range cmd.Usage() { + // 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 { - // // The header part: - // var header string - - // 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" +// Hide marks a command as hidden, meaning it won't be shown in help and its +// UnknownCommand errors will be suppressed. +func (sub *Subcommand) Hide(methodName string) { + sub.FindCommand(methodName).Hidden = true } func (sub *Subcommand) reflectCommands() error { @@ -274,6 +267,11 @@ func (sub *Subcommand) InitCommands(ctx *Context) error { v.Setup(sub) } + // See if struct implements CanHelper: + if v, ok := sub.command.(CanHelp); ok { + sub.helper = v.Help + } + return nil } @@ -327,6 +325,9 @@ func (sub *Subcommand) parseCommands() error { 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{}) { var mw *MiddlewareContext // Allow *MiddlewareContext to be passed into. @@ -342,21 +343,20 @@ func (sub *Subcommand) AddMiddleware(methodName string, middleware interface{}) if method = strings.TrimSpace(method); method == "*" { // Append middleware to global middleware slice. sub.globalmws = append(sub.globalmws, mw) - } else { - // Append middleware to that individual function. - sub.FindCommand(method).addMiddleware(mw) + continue } + // Append middleware to that individual function. + sub.findMethod(method).addMiddleware(mw) } } -func (sub *Subcommand) walkMiddlewares(ev reflect.Value) error { - for _, mw := range sub.globalmws { - _, err := mw.call(ev) - if err != nil { - return err +func (sub *Subcommand) findMethod(name string) *MethodContext { + for _, ev := range sub.Events { + if ev.MethodName == name { + return ev } } - return nil + return sub.FindCommand(name) } func (sub *Subcommand) eventCallers(evT reflect.Type) (callers []caller) { @@ -384,17 +384,9 @@ func (sub *Subcommand) eventCallers(evT reflect.Type) (callers []caller) { return } -// SetPlumb sets the method as the plumbed command. This means that all calls -// without the second command argument will call this method in a subcommand. It -// panics if sub.Command is empty. +// SetPlumb sets the method as the plumbed command. func (sub *Subcommand) SetPlumb(methodName string) { - if sub.Command == "" { - panic("SetPlumb called on a main command with sub.Command empty.") - } - - method := sub.FindCommand(methodName) - method.Command = "" - sub.plumbed = method + sub.plumbed = sub.FindCommand(methodName) } func lowerFirstLetter(name string) string { diff --git a/bot/subcommand_test.go b/bot/subcommand_test.go index 1954cb3..134d1b8 100644 --- a/bot/subcommand_test.go +++ b/bot/subcommand_test.go @@ -1,9 +1,22 @@ package bot import ( + "strings" "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) { _, err := NewSubcommand(&testc{}) if err != nil { @@ -42,7 +55,7 @@ func TestSubcommand(t *testing.T) { foundNoArgs bool ) - for _, this := range sub.Methods { + for _, this := range sub.Commands { switch this.Command { case "send": 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) { - if h := sub.Help("", false); h == "" { + h := sub.Help() + if h == "" { 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.") + } }) }